自动化工具 React Native 代码覆盖率获取探索 (二)

陈恒捷 for PPmoney · 2017年06月05日 · 最后由 yuanshandai 回复于 2022年09月07日 · 10643 次阅读
本帖已被设为精华帖!

前文:React Native 代码覆盖率获取探索 (一)

简单回顾一下上一次探索:

  1. 确定了使用覆盖率工具 istanbul 结合 Facebook 的单元测试框架 Jest 可以收集到单元测试覆盖率,但这种方式获取到的只是单元测试覆盖率,并非集成测试覆盖率,运行环境也并非在实际的 react-native app 中。
  2. 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 接口,因此可用于作为单独的覆盖率报告生成网站。

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 。

核心方法:

  1. middleware 端添加针对浏览器使用 js 的 handler :app.use(im.createClientHandler(__dirname));,使得所有浏览器获取的 js 文件都是插桩后的文件。
  2. 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 这个工具的插桩方式:

image_1bhpoiqht1j748igru1fq31me89.png-69.2kB

主要有几种。

  • 【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 端的覆盖率数据吧。

主要修改步骤:

  1. 对 client.js 添加定时回传覆盖率数据的函数
  2. 对 js 代码进行插桩
  3. 修改 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

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 42 条回复 时间 点赞

@Lihuazhang rn 手工测试的覆盖率也能收集到咯。

陈恒捷 回复

@vigossjjj 我们现在也支持么?

恒温 回复

哇塞,原来 @vigossjjj 到支付宝啦。

陈恒捷 回复

嗯 主攻覆盖率

恒温 回复

好奇的点击来,竟然还被 @ 了,H5 覆盖率方案是有的,但是目前没有资源做,排期咯

思寒_seveniruby 将本帖设为了精华贴 06月06日 11:04

厉害呀,恒捷。
我在厂内貌似没找到 RN 覆盖率的文章。而且厂内好像现在 WEEX 这一块适用范围比较广一点!

testly 回复

这个方案从原理上说, weex 应该也能支持的。

点赞,期待合并到覆盖率平台上。

陈恒捷 如何量化测试覆盖率 中提及了此贴 09月14日 07:35

你好 请教个问题,如果将 RN 打成 jsbundle 包还能支持收集覆盖率吗

NAISI 回复

没尝试过,不大确定。理论上只要打包前有做好插桩,还是可以收集到覆盖率的。

您好想问下,我使用 babel-istanbul-plugin 进行插桩,按照官方操作后,输入 window.coverage命令,发现就只有一个文件有覆盖率数据,其他文件都没有。这是什么原因?

6dingdong6 回复

信息量有点少,不好定位。

建议你单独发帖把完整步骤及相关日志补充下?

请问如果是 vue 项目要如何插桩呢?

Yangjunfengss 回复

可以试试文中的套路?如果是有具体哪个点卡住了可以具体发出来。

@chenhengjie123 横捷,打扰了,我按照你的文章,写了一个简单的 APP。
我的代码上传到了 git: https://github.com/OnTheWay111/AwesomeProject.git
看 log 可以正常上传覆盖率成功,但是没有发现覆盖率数据,可以帮忙看看吗?

覆盖率 HTTP 页面:

手机模拟器以及 log:显示提交覆盖率数据成功

原因是:window.coverage undefined,具体原因还未找到
可能原因:计数器插入失败

仅楼主可见

nyc 插桩 ReactNative 的 js 文件失败的问题解决了,主要是 nyc 的版本问题,降版本到 14.1.1 可以了。

注意:package.json 直接指定 nyc 版本和全局 nyc 版本的区别,如果是直接运行 nyc instrument,用的是全局版本

问题解决了,解决方法如下,特别感谢 @zsx10110
我之前使用的降版本方式是 package.json 中修改版本号,然后 npm install , 然后 nyc instrument 插桩,注意:此时使用的 nyc 是全局 nyc,并不是 node_modules 中的 nyc,所以用的还是全局版本的 nyc,导致降版本无效;
感谢@zsx10110 大大,成功帮我解决了,使用如下命令,强制降版本全局 nyc:

npm install -g nyc@14.1.1 --registry https://registry.npm.taobao.org

在路上 ReactNative 多端代码覆盖率调研及实践 中提及了此贴 05月20日 19:13

你好,我想请问一下,如果 js 文件中包含 typeScript,istanbul 是不是就无法获取覆盖率了?

Kepler-ZZ 回复

好久没做这块相关的工作了,不大确定。 @zailushang 帮忙看下有没有这个限制?

陈恒捷 回复

这块我没继续做了,其他同事在做。
@zsx10110 大佬帮忙看下?

Kepler-ZZ 回复

能不能提供更详细的信息 我们这边 typescript 的插桩是可以的,并且有能够获取到覆盖率数据,但是我们这边都是前端项目,不是针对 react-native 的。

saii 回复

感谢~没事,我就是想确认一下,因为一开始是用 JSCover,发现无法对 TypeScript 进行插桩,于是改用 Istanbul,我这边也是前端项目,我现在阻塞在回传函数这一步,浏览器一直报错: 求大佬指点,网上百度了一些方法,不知具体道怎么操作。😭

tv.html:1 Access to XMLHttpRequest at 'http://localhost:8088/coverage/client' from origin 'https://localhost:8081' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Kepler-ZZ 回复

这个是前端的跨域请求了 你可以改下你那个 8088 的服务,让他允许跨域请求就可以了。

saii 回复

这个 8088 的服务就是 istanbul-middleware,网上说添加下面这条语句就行,但是我不知道要加在哪个位置😢

Kepler-ZZ 回复

https://www.jianshu.com/p/fc038a92d1eb 看看这个文章 网上比较多 关于 express 设置跨域的方式

saii 回复

谢谢大佬,解决了

saii 回复

但是产出的报告有很多问题诶,代码行总数对不上,一共 5 万多行代码,行覆盖那里显示只有 2 万行,且未标注覆盖情况。不知道是怎么回事,求大佬指点😭

展示问题已解决,浏览器的原因,使用 chrome 无痕浏览打开就是黑白,换成普通模式浏览打开就正常。

但是行覆盖总数仍然对不上

数据问题我大概知道了,覆盖率工具只统计有效代码行,会去除注释、空行这些吧。应该是这个原因吧,所以报告上的代码行数是一定会小于等于实际显示的总行数

Kepler-ZZ 回复

我有个问题呀,你说的 5w 多的代码是指的你的源码吗?然后 2w 多是指的插桩后的报告吧。这个报告能够对应到你真正的源码不?
我看到你的报告中的 vplayer 看上去不像是你们的源码呀

saii 回复

我们的播放器工程,编译之后生成了 vplayer.js 文件,我是直接对这个文件进行添加回传函数 + 插桩。报告和 vplayer.js 原文件内容能够对应上。5 万的数据来自这里,是我一开始理解错了,这里显示的行数并不是真正的有效代码行数,所以 2 万多的数据是对的。

saii 回复

嗨,想问一下,覆盖率报告渲染的问题又出现了,请问有什么办法解决吗?在线观看的覆盖率报告是没有渲染的黑白的。下载下来的报告渲染正常。

Kepler-ZZ 回复

这个我没有遇到过,如果你不嫌麻烦的话,可以弄一个最小的仓库内容出来 然后我帮你看看是什么情况,不然这样子描述 我帮不上什么忙

saii 回复

那方便加微信沟通吗大佬?😀


图一我启动后台服务成功了,并且图二表明 window.__ coverage __有值,成功生成覆盖率数据。但上传 http://localhost:8889/coverage/client 的时候总是报错,具体错误如图 3,有没有大佬解答一下这个是什么原因?

mjylfz 回复

已经解决,地址不能写 localhost,而是要改成自己的 ip

我用了一个 vue 项目,试着插桩看下覆盖率,参照恒捷大神的 demo(https://github.com/chenhengjie123/middleware-browser-coverage-demo),
浏览器端 js 插桩了,启动项目后可以正常上报内容,服务端也添加 hookhandler 了,现在遇到一个问题是访问 ip:端口/coverage, 页面返回的状态码是 200,但没有像 demo 中那样是个 js 文件列表,直接是空白的,
请问大神,这个是哪个设置控制的那

zhanglimin 回复

打开浏览器的开发者工具,看下 network 里返回的是什么,以及 console 有没有报错?

另外,你这里说得有点太简单了,很多细节没提及。麻烦把具体步骤,最好是配置好的整个 vue 项目分享一下吧?

陈恒捷 回复

感谢答复,目前出来覆盖率结果页面了,参照您的 demo 步骤,demo 从 server 端到 client 端很清晰。把

//zhanglm add begin
function matcher(req) {
    var parsed = url.parse(req.url);
    return parsed.pathname && parsed.pathname.match(/\.js$/) && !parsed.pathname.match(/jquery/);
}
if (coverageRequired) {
    console.log('Turn on coverage reporting at /coverage');
    app.use('/coverage', istanbulMiddleware.createHandler());
    // app.use(istanbulMiddleware.createClientHandler(publicDir,{ matcher: matcher }));
}
//zhanglm add end

这部分放在了

var app = express()

之后,以前是放在

var server = app.listen(port)

上一行了。覆盖率页面出来了,但原因还不是很明白,明天再看下。整了两天的一个小结果,感谢。

zhanglimin 回复

不客气,出来了就好。

// 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);
您好,这段代码为什么写在 setup.js 呢?

saii 关于 nodejs 覆盖率的探索 中提及了此贴 08月08日 23:41
zhntester 回复

放这个位置纯粹是为了方便。setup.js 是总入口,不用担心不会加载到,而这段代码也不算复杂,所就直接写在这个位置了。

实际工程里使用,如果有更复杂的上报策略,建议抽离单独 js 文件出来吧。

Kepler-ZZ 回复

你好,在线查看覆盖率报告渲染不对、显示黑白的问题,你最后怎么解决的。下载下来显示是正确的

您好,我使用 nyc instrument 进行了插桩,可是插桩后的文件,编译失败,原因是:import 没有在文件的第一行,请问您是否遇到过这个问题,以及是怎么解决的呢?期待您的回复

2楼 已删除
spxinjie6 vue 代码覆盖率怎么实现? 中提及了此贴 08月29日 16:58
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册