探索istanbul/nyc代码覆盖工具的原理

探索istanbul/nyc代码覆盖工具的原理

代码覆盖率的检查是项目测试能力中的一环,每次开发者提交的change拥有高覆盖率的指标往往能够保证该功能的稳定;

istanbuljs是前端常用的代码覆盖率检查的工具,包括了对JS代码中:statement(语句)、line(行数)、function(函数)、branch(条件,循环等)的执行统计能力;

Github上的istanbul主要有两个仓库:

1) 老项目:gotwarlost/istanbul,最新版本为0.4.5,已经于2017年5月不再维护;

2) 新项目:istanbuljs/nyc,当前版本为14,官方站点为:https://istanbul.js.org

提示:前者的star数虽然比较多,但已不再维护;使用与研究推荐后者为主;

老项目项目起名istanbul,取自:伊斯坦布尔城市;后来项目更变有了新名字:nyc,取自:纽约市,总体上出自乐队《They Might Be Giants》的歌曲,下面摘抄于作者的Github解释

nyc was designed to be a tool that extended on the functionality of Istanbul; introducing support for subprocesses, and a zero-configuration approach to coverage. I called it nyc for two reasons:
as a joke, because in the They Might Be Giants song Istanbul Not Constantinople, they mention Istanbul, Constantinople, new Amsterdam, and New York City in conjunction.
because I wanted a short name, so that folks could add it to existing test scripts: mocha becomes nyc mocha.


本文讲解围绕后者项目,主要介绍该工具的实现原理,基于此向快应用的开发者提供代码检查的能力,辅助提升开发者对项目的管理维护;

如果让我们个人实现代码覆盖检查的能力,思路上可能有3种方式:

1) 通过Proxy类似拦截机制;

这种方式可能对于函数/属性的存取有效,但是对于表达式与条件语句难以做到;

2) 通过对源代码进行AST级别的包装重写;

简言之,就是将源码转换为AST的IR(Intermediate Representation),然后逐个语法的遍历并包装,记录调用频次与实际值,最后将其还原为最终JS代码;

这种实现方式在JS的其它场景(如:webpack中loader/plugin对代码的检查替换)与其它语言中也很常见,istanbul也恰恰采用的是这种思路

3) 通过JS引擎内置的计数能力,并展现给开发者;

这种方式对开发者的使用仅限于接口的协议约定,但要求JS引擎本身具备这种能力,难度较大;关注这块的同学可以了解一下c8相关资料

nyc的使用

传统的前端项目测试,假设使用mocha测试,我们会执行以下的测试用例:

mocha test/suite/**/*.spec.js

接着开发者安装 nyc npm类库(npm install nyc -D),然后加个nyc前缀就会自动 完成运行时的覆盖统计与最终结果展现;

// nyc无参数的默认用法
nyc mocha test/suite/**/*.spec.js
// nyc携带参数:html格式
nyc --reporter=html mocha test/suite/**/*.spec.js
// 也可以通过项目根目录增加配置文件(如:nyc.config.js)的形式,然后执行上面的命令
module.exports = {
  reporter: ['html']
}

该命令执行时,会依次完成以下几件事:

1) 将项目src源码目录中的JS文件做转换并缓存起来;

2) 接着将后面的mocha参数放到子进程(child_process)执行,即:调用mocha执行测试用例;

3) 测试用例中所测试的src源码文件实际是上面的缓存文件,期间完成的语句与分支执行的统计,记录在JS内存的某个变量中;

4) 子进程的测试用例执行结束之后,将统计数据发送给nyc命令所在线程并保存到文件,默认目录名为:.nyc_output,它记录了本次测试的代码覆盖最完整的数据;

5) nyc线程内部调用report方法,完成从原始数据到可读性良好的内容转换;它会将 .nyc_output 目录中的数据读取出来,并使用 html格式 进行转换,并默认生成在目录coverage 下;

此时开发者打开其中的任何html页面即可查看效果,示例如下:

html格式的目录展示
html格式的具体某个测试用例展示

当然,nyc为开发者提供了很多其它的转换格式,可以灵活应用;

nyc实现过程

整体上,nyc工具的实现步骤分为以下三部:

1) 目标转换:对目标代码完成包装转换,称为:instrument

2) 运行统计:运行时执行转换的代码,并保存,我称为:coverage,官方没定义;

3) 格式转换:提供多种report格式,完成内容展现,称为:report

意欲阅读源码的开发者,通过入口文件 nyc/bin/nyc.jsnyc/index.js 即可领会;

nyc步骤一:instrument

这一步nyc会使用库istanbul-lib-instrument,它又间接使用库babel-plugin-istanbul完成JS代码的解析转换

之前老的istanbul库使用的是:esprima与escodegen来完成AST解析与转换的;

功能上,使用下面的命令完成代码转换:

// 将lib目录中的JS文件转换并放到output目录下
nyc instrument ./lib ./output

例如某个JS文件转换前后的对比,之前:

import moduleA from './moduleA.js'
import moduleB from './moduleB.js'

function fn1 (v1) {
  if (v1) {
    return moduleA
  }
  else {
    return moduleB
  }
}

export default {
  fn1
}

转换之后:

var cov_c2l56zfse = function () {
  var path = "/home/mi/allspace/work/codespace/codes/opensource/istanbul-demos/demo/demo1.js";
  var hash = "f84ecfeca02b9b8fa7ec45134a339b3595b73d0c";
  var global = new Function("return this")();
  var gcv = "__coverage__";
  var coverageData = {
    path: "/home/mi/allspace/work/codespace/codes/opensource/istanbul-demos/demo/demo1.js",
    statementMap: {
      "0": {start: {line: 5, column: 2}, end: {line: 10, column: 3}},
      "1": {start: {line: 6, column: 4}, end: {line: 6, column: 18}},
      "2": {start: {line: 9, column: 4}, end: {line: 9, column: 18}}
    },
    fnMap: { ... },
    branchMap: { ... },
    _coverageSchema: "43e27e138ebf9cfc5966b082cf9a028302ed4184",
    hash: "f84ecfeca02b9b8fa7ec45134a339b3595b73d0c"
  };
  var coverage = global[gcv] || (global[gcv] = {});
  if (coverage[path] && coverage[path].hash === hash) {
    return coverage[path];
  }
  return coverage[path] = coverageData;
}();
import moduleA from "./moduleA.js";
import moduleB from "./moduleB.js";
function fn1 (v1) {
  cov_c2l56zfse.f[0]++;
  cov_c2l56zfse.s[0]++;
  if (v1) {
    cov_c2l56zfse.b[0][0]++;
    cov_c2l56zfse.s[1]++;
    return moduleA;
  } else {
    cov_c2l56zfse.b[0][1]++;
    cov_c2l56zfse.s[2]++;
    return moduleB;
  }
}
export default{fn1};

可以发现代码中额外增加了对源码的statement,line,branch,function的计数能力(++),并将其保存在 __coverage__ 的全局变量之中,该变量记录了所有JS文件的统计数据

nyc步骤二:coverage

这一步其实是调用mocha之类的测试框架,mocha测试执行时,就会运行上面的代码,完成具体代码调用的累计计数;

至于如何完成的源码加载,分为:编译前,运行时拦截替换两种方式,开发者可以从下面的参考资料中了解;

mocha命令前增加nyc的目的就是:通过nyc调起mocha,完成两个线程的通信;mocha完毕后,将内存中的 __coverage__统计数据 传递给nyc主线程,接着记录在目录 .nyc_output 中;

nyc步骤三:report

这一步nyc将目录 .nyc_output 的原始数据转换为其它格式的展示内容,保存在目录coverage下,开发者执行下面的命令即可;

简单来说就是:它先加载项目下的配置文件(如:nyc.config.js),接着读取.nyc_output目录下的每个JSON文件,每个文件以uuid的形式命名,避免多次运行nyc引起文件名冲突,然后将文件内容合并Merge在一起,再转换成最终开发者希望看到的格式;

nyc report

目录的大概结构如下所示:

最后开发者,用浏览器打开对应的index.html即可查看到上面的页面效果;

文章总结

之所以了解这块,是因为快应用开发者面临这样需求,需要统计项目中ux与js代码的调用,所以我们接下来将在快应用工具中提供这样的测试能力出来;

参考资料

AlloyTeam:伊斯坦布尔测试覆盖率的实现原理

webpack:istanbul-instrumenter-loader

编辑于 2019-10-28

文章被以下专栏收录

    快应用联盟覆盖了国内主流的手机厂商,比如:小米,VIVO,OPPO,华为等;研发中心包括了厂商内所有伙伴的快应用研发力量;