前文:React Native 代码覆盖率获取探索 (一)
简单回顾一下上一次探索:
- 确定了使用覆盖率工具 istanbul 结合 Facebook 的单元测试框架 Jest 可以收集到单元测试覆盖率,但这种方式获取到的只是单元测试覆盖率,并非集成测试覆盖率,运行环境也并非在实际的 react-native app 中。
- istanbul-middleware 可以实现独立把 istanbul 嵌入至被测程序中,实时获取集成测试覆盖率,可能类似 jacoco 的 on-the-fly 模式,更符合需要。
接下来,到了打基础的时候了。先了解下 istanbul 到底是个怎样的工具,然后再沿着 istanbul-middleware 的方向继续探索,找到可行的方案
探究 istanbul
istanbul 官方网站:https://istanbul.js.org
主要的组件:
- nyc: 命令行工具。主要用于把 istanbul 更方便地嵌入到单元测试中,也支持插桩文件生成、覆盖率报告生成等单独功能。
- babel-plugin-istanbul: babel 插件。提供对 ES6 规范的插桩支持。
- istanbul-api: istanbul 的公共 api 。经过初步封装,主要用于给外部嵌入 istanbul 。
- istanbul-lib-coverage: 提供包括合并、汇总及解析覆盖率数据在内功能的 api 。
- istanbul-lib-hook: 提供对
require,vm.createScript,vm.runInThisContext三个位置进行自动插桩的钩子方法。 - istanbul-lib-instrument: 核心模块,负责进行插桩的库。
- istanbul-lib-report: 覆盖率报告的核心函数库。可以理解为给不同报告生成器使用的公共函数库。
- istanbul-lib-source-maps: 负责通过 source map 进行覆盖率信息映射的库。(source map 记录实际执行 js 与 js 源码间的映射关系。相关信息建议参考 JavaScript Source Map 详解)
- istanbul-reports: 各种报告生成器。
- test-exclude: nyc 使用的 include/exclude 逻辑对应的实现库。
- istanbul-middleware: 用于在功能测试中使用覆盖率的组件,包括脱离单测框架,在代码中直接嵌入覆盖率的钩子方法,以及一个可以收集覆盖率数据后自动生成覆盖率报告的网站应用。
新旧 istanbule 说明:
现在搜索 istanbul 的时候,会发现一些旧的文档和新的文档差异比较大,经过寻找,发现原来 istanbul 中间转过手,所以也把它这段历史简单记录一下。
istanbul 最早的时候,相关的组件都属于 gotwarlost,包括 istanbul 本体、istanbul-middleware 组件等。
但从 0.4.0 版本开始, gotwarlost 不再维护 istanbul ,交给 istanbuljs 继续开发维护。现在大部分使用的 istanbul 就是这个版本。此时 istanbul 大部分组件给了 istanbuljs,但少量组件(如 istanbul-middleware)istanbuljs 并没有接手维护。
目前新的 istanbul 主要针对的是单元测试领域,基本上所有官方文档及实践分享,都是针对如何嵌入到单测中的。而对于功能测试领域,仍然只有 istanbul-middleware 一枝独秀。
istanbul-middleware 探索
把官方的 readme 及 示例项目源码 完全看了一遍,终于大致了解了这个组件是干嘛的了。
istanbul-middleware(后面简称 middleware)本质上是一个基于 express 的网站。但其包含了数个针对 istanbul 覆盖率收集及报告生成的 http 接口,因此可用于作为单独的覆盖率报告生成网站。
- 覆盖率相关接口信息(基础路径为 coverage ,如重置覆盖率数据,需要访问的路径是 http://localhost/coverage/reset ):
| URL | Description |
|---|---|
| GET / | 动态生成覆盖率 html 报告。和平时单测生成的静态版本一样,可以通过点击逐级深入,查看更细节的覆盖率数据。 |
| POST /reset | 把覆盖率数据重置成基线(可以理解成清空当前覆盖率数据) |
| GET /download | 下载一个包含 json 、lcov、html 三种格式覆盖率报告的压缩包 |
| POST /client | 用于从浏览器主动发送覆盖率对象。覆盖率对象必须是 json 格式,且发送时 header 中必须有 Content-type: application/json 。这个对象需要和当前服务端已有的统计数据保持一致。补充:即不能把不同程序的覆盖率数据都一起发给同一个 middleware 服务端。 |
- 覆盖率收集的两种方式
middleware 支持 server 端及 browser 端的覆盖率数据收集。
server 端
通过 hook require 方法,自动在运行时给 server 端文件插桩。同时添加 /coverage 路径的 handler ,处理上述的覆盖率接口请求。
核心方法:im.hookLoader(__dirname);,app.use('/coverage', im.createHandler()); 。
强烈建议看下官方 test/app 文件夹中的示例程序,看完会有更清晰的了解。
browser 端
middleware 只能通过上述的 POST /client 收集覆盖率数据并生成报告,覆盖率数据的上传需要由 browser 端自行处理。即 browser 端的 js 文件需要预先进行插桩再给浏览器运行,并加入定时回传 window.__coverage__ 对象(即覆盖率数据)给 middleware 。
核心方法:
- middleware 端添加针对浏览器使用 js 的 handler :
app.use(im.createClientHandler(__dirname));,使得所有浏览器获取的 js 文件都是插桩后的文件。 - js 文件端添加定时回传覆盖率数据的方法(每隔 2 秒自动回传,fetch 方法是 react native 提供的网络请求方法):
setInterval(function(){
fetch('http://localhost:8889/coverage/client', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(window.__coverage__)
}).then((response) => console.log(response.json()))
}, 2000);
插桩原理小结
参考 istanbul 代码覆盖率工具研究 - Teazean ,以及 middleware 的用法,简单总结下 instanbul 这个工具的插桩方式:

主要有几种。
- 【server】代码添加 hook:使用 middleware ,在项目入口 js 添加 middleware 的 handler ,给所有 require 函数添加钩子,在 require 时自动进行插桩。middleware 的示例项目里面用的就是这种方法。主要适用于集成测试。
- 【server】自动插桩:使用 nyc(istanbul 的命令行工具)结合各种单测框架,自动在运行单测时给所有 js 文件进行插桩(具体是在解析器层插桩,还是接近 middleware 的方式,暂时未探究到),无需改动任何源码。主要适用于单元测试。
- 【client】手动插桩:使用 nyc instrument 命令,把正常 js 变成插桩后 js ,然后再把这个 js 放到浏览器中运行。主要适用于在浏览器或 react native 这类无法实现运行时插桩的场景,既可用于单测(需自行解决覆盖率数据返回给单测框架的问题),也可用于集成测试。接近于 jacoco 的 offline 方式。
- 【client】自动插桩:使用 middleware 的
createClientHandler,把指定目录的所有 js 请求(浏览器才会请求 js ,服务端都是 require 来使用 js 的)都返回插桩后的 js 文件。
从这几种插桩方式看出,对于获取类似 react-native 这样运行环境下的覆盖率数据,必须使用运行前插桩的模式,并把覆盖率数据以某种形式返回给后端进行数据解析及报告生成。
普通网站实践
好了,基础基本都学好了,可以开始再次启航了~
一开始,先不要那么难直接挑战 react-native 。我们先做个小 demo ,尝试让 middleware 收集来自 browser 端的覆盖率数据吧。
主要修改步骤:
- 对 client.js 添加定时回传覆盖率数据的函数
- 对 js 代码进行插桩
- 修改 middleware 中源码目录指向
具体代码修改内容已上传 github ,一个步骤对应一次提交 :https://github.com/chenhengjie123/middleware-browser-coverage-demo
PS:实际上第二、第三步可以忽略不做,因为 createClientHandler 本身已经完成了自动插桩的功能。但为了提前给后面的 RN 进行试验(RN 采用的是所有 js 打包成一个文件后再请求,此时 middleware 这种方式就无效了),所以加上第二、第三步。
React-Native APP 实战
终于来到重点了。其实根据前面的普通网站实践,RN 基本也是差不多的套路了。主要的不同点,在于我们还需要自己搭建好 middleware 后台服务。
继续以 f8app 为例,加入覆盖率收集服务。f8app 开发环境具体搭建过程请查看 React Native 代码覆盖率获取探索 (一),此处不再详述。
所有源码均放在了 https://github.com/chenhengjie123/f8app_coverage_demo 上,不关心过程的同学可以直接上去根据 readme 运行 demo 。后面只说关键代码,非关键部分请直接查阅 github 源码。
- 建立 middleware 后台服务
其实在前面的 middleware 示例项目里,middleware 后台服务的主要源码都已经给出了,我们仿照它的格式,把多余部分去掉就好。修改后的 index.js 文件内容如下:
var express = require('express'),
im = require('istanbul-middleware'),
isCoverageEnabled = true,
app = express(),
port = 8889;
// add the coverage handler
console.log('Coverage reporting at /coverage');
app.use('/coverage', im.createHandler({ verbose: true, resetOnGet: true }));
console.log('Starting server at: http://localhost:' + port);
app.listen(port);
为了方便,我们把 index.js 另外放到一个名为 f8app_coverage_middleware 的目录,与 f8app 平级。同时也补充上对应依赖库的 package.json 文件。
目前目录结构如下:
.
├── f8app
│ ├── LICENSE
│ ├── README.md
│ ├── android
│ ├── index.android.js
│ ├── index.ios.js
│ ├── ios
│ ├── js
│ ├── logs
│ ├── node_modules
│ ├── npm-shrinkwrap.json
│ ├── package.json
│ ├── scripts
│ └── server
└── f8app_coverage_middleware
├── index.js
├── package.json
└── start_middleware.sh
- 代码添加自动回传覆盖率
// 拷贝源码到 middleware 目录
$ cp -r f8app/js f8app_coverage_middleware
$ cd f8app_coverage_middleware
然后在 f8app_coverage_middleware/js/setup.js 尾部进行如下修改,实现每隔两秒回传覆盖率数据:
--- a/f8app_coverage_middleware/js/setup.js
+++ b/f8app_coverage_middleware/js/setup.js
@@ -84,4 +84,18 @@ global.LOG = (...args) => {
return args[args.length - 1];
};
+// post window.__coverage__ to server every 2 seconds
+setInterval(function() {
+ fetch('http://localhost:8889/coverage/client', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(window.__coverage__)
+ })
+ .then(function() {
+ console.log("success!")
+ })
+}, 2000);
+
module.exports = setup;
- 代码插桩
接下来,我们需要把 f8app 的 js 都通过命令进行插桩。
// 插桩后代码输出到 f8app/js 目录,覆盖原有内容
$ nyc instrument js ../f8app/js
为了方便后续更新插桩文件,建了个 instrument_js.sh 做到一键更新插桩文件。
- 启动覆盖率后台服务
$ cd f8app_coverage_middleware && ./start_middleware.sh
- 启动 react-native ios 客户端(记得先启动好 f8app 运行环境所需的程序)
$ cd f8app && react-native run-ios
待 ios 客户端启动完毕后,等待约 2 秒,然后打开 http://localhost:8889/coverage 即可查看覆盖率报告。
目前方案已知问题及解决方案
由于 middleware 默认识别相对路径,会造成查看具体文件行级别覆盖率时报类似如下的错误:
Error: ENOENT: no such file or directory, open 'actions/installation.js'
at Error (native)
at Object.fs.openSync (fs.js:549:18)
at Object.fs.readFileSync (fs.js:397:15)
...
原因:middleware 文件解析是根据 url 中的 p 参数值进行解析的。由于 client 只会回传相对路径,因此 middleware 会找不到对应文件。将其生成 html 页面时使用的路径改为绝对路径即可。
解决方法:
打开 f8app_coverage_middleware/node_modules/istanbul-middleware/lib/core.js ,添加下面 + 号开头的代码(实际添加时不需要加上这个 + 号):
fileCoverage = coverage[outputNode.fullPath()];
+ // 临时修复 `no such file or directory` 报错问题
+ var path = require('path');
+ fileCoverage.path = path.resolve(__dirname, '..', '..', '..', 'js', fileCoverage.path);
utils.addDerivedInfoForFile(fileCoverage);
report.writeDetailPage(res, outputNode, fileCoverage);
小结
断断续续搞了几周,这篇文章也改了 3 版,终于把 demo 基本搞定了。在这过程中走过不少弯路,例如一开始是把 middleware 也加入到 f8app 项目,直接在 f8app 的 js 里加 handler,结果发现由于依赖 express ,在 rn 运行环境下完全启动不了。因此沉下心,认真把 istanbul 啃了下来,总算找到了一条正确的路,完成了覆盖率的收集。
回头看了下,沿途风景还是不错的。这里也以自己的这段经历提醒下大家:真的不要一上来就按照自己的思路写代码呀。沉下心,打好基础,你能收获比一个可用的程序更多的知识。
下一步就是如何将其工程化,加入到覆盖率平台里了。这部分后续做完再进行分享。
参考地址:
多进程下的测试覆盖率
istanbul 代码覆盖率工具研究 - Teazean

未知地区 10F
没尝试过,不大确定。理论上只要打包前有做好插桩,还是可以收集到覆盖率的。
未知地区 9F
你好 请教个问题,如果将 RN 打成 jsbundle 包还能支持收集覆盖率吗
未知地区 8F
点赞,期待合并到覆盖率平台上。
未知地区 7F
这个方案从原理上说, weex 应该也能支持的。
未知地区 6F
厉害呀,恒捷。
我在厂内貌似没找到 RN 覆盖率的文章。而且厂内好像现在 WEEX 这一块适用范围比较广一点!
未知地区 5F
好奇的点击来,竟然还被 @ 了,H5 覆盖率方案是有的,但是目前没有资源做,排期咯
未知地区 4F
嗯 主攻覆盖率
未知地区 3F
哇塞,原来 @vigossjjj 到支付宝啦。
未知地区 2F
@vigossjjj 我们现在也支持么?
未知地区 1F
@Lihuazhang rn 手工测试的覆盖率也能收集到咯。