JS代码覆盖率工具instanbuljs及其思路介绍

istanbul介绍

intanbuljs是JS语言中最流行的覆盖率采集工具。其前身是个人发起的istanbul项目。日常使用中,经常将相关的CLI工具nyc配合着流行的前端测试框架一起使用,达到在单元/集成/e2e测试的同时,输出测试覆盖率数据的效果。

当然,你也可以通过babel-plugin-istanbul配合测试框架使用

和istanbul的对比

对于此前对前端测试覆盖率稍有了解了的人来说,可能更熟悉的是旧版的istanbul。istanbuljs在功能上和istanbul没有太大的区别,但在项目组织和实现细节上有着些许不同。

  • istanbuljs使用monorepo组织整个项目,将相对独立的插桩、hook、覆盖率、sourcemap、报告等拆分成多个包独立实现和发版
  • 使用babel实现源代码的AST解析和插桩(instrument)代码的生成
  • 内置支持sourcemap
  • 报告生成上取消了对express的依赖,只在本地产出报告

介绍

对于希望借助istanbuljs实现代码覆盖率相关功能的(nyc也基于istanbuljs实现),了解其各个包工作原理将会有所帮助。下面就按功能范畴介绍各个包的实现原理。

代码插桩和hook

覆盖率产出建立在代码插桩的基础上。根据插桩时机,分为编译时插桩运行时插桩

  • 编译时插桩,即在代码转译过程中插入覆盖率采集代码,产出代码本身即拥有采集能力,譬如babel-plugin-istanbul
  • 运行时插桩,即产出代码本身不具有采集能力,在运行时通过hook的方式在使用的代码中插入覆盖率采集代码,譬如hookRequire

不同于使用babel的编译时插桩,运行时插桩需要额外的hook一步。插桩和hook分别由istanbul-lib-instrumentistanbul-lib-hook实现。

istanbul-lib-instrument

1
2
3
4
5
6
7
8
9
10
11
import { createInstrumenter } from 'istanbul-lib-instrument';
const instrumenter = createInstrumenter({
coverageVariable,
debug: options && options.debug
});
// ...
const transformed = instrumenter.instrumentSync(
code,
options.filename,
sourceMap ? sourceMap.toObject() : undefined
);

istanbul-lib-instrument利用babel实现了代码插桩。仓库暴露了3个API:

createInstrumenter

最主要的API。以入参作为配置项创建一个Instrumenter实例,配置项如下:

  • coverageVariable 覆盖率全局变量名,可选,默认__coverage__
  • preserveComments 是否保留注释,可选,默认false
  • esModules 是否插桩ES6代码,可选,默认false
  • produceSourceMap 是否为插桩前后代码生成sourcemap,可选,默认false
  • debug 是否打印详细信息,可选,默认false
  • … 更多配置参见文档介绍

通常使用时,配置项均使用默认值即可。Instrumenter实例有下面几个重要的方法

instrumentSync

同步插桩代码,支持ES6和ES5,插桩ES6代码时,需要配置项中的esModulestrue。解析代码出错时会抛出错误。方法返回插桩后的代码字符串,入参如下:

  • code 代码字符串
  • filename 文件名(包含路径)
  • inputSourceMap 用来将当前代码的覆盖率反映射到源文件中的覆盖率,需要是sourcemap格式。

在指定了inputSourceMap后,当前文件采集覆盖率时,会附带上传入的这个sourcemap,从而可以结合istanbul-lib-sourcemaps使用。

instrument

callback风格的插桩,此时抛出错误将通过回调的入参传入,而非直接抛出。插桩的代码也会在回调中传入而非直接返回。需要注意的是,回调的执行和语句的执行在同一个process tick中,而不是异步的。

函数入参即instrumentSync的第三个位置插入函数类型的callback入参。

剩余的两个方法:

  • lastFileCoverage,返回最近一次的文件覆盖率对象
  • lastSourceMap,返回最近一次的文件的sourcemap
programVisitor

programVisitor是一个将babel用于插桩的适配器函数,该函数会返回一个具有enter以及exit方法的对象,这两个方法必须应用在Program的enter和exit属性上,实现插桩效果。istanbuljs内部也是通过programVisitor实现的功能。programVisitor内部并不依赖babel的状态,因此也可以用在babel以外的环境。

该函数支持以下入参:

  • types,babel-types实例,语法节点类型
  • sourceFilePath,文件路径,可选,默认为unknown.js
  • opts,插桩配置项,可以参见文档介绍

在函数内部集中了插桩的细节实现,即对于特定类型语法树节点插入对应逻辑。

readInitialCoverage

配合InstrumenterlastFileCoverage方法使用,读取文件的初始覆盖率。

上面这些API中,最常用和最核心的即instrumentSync方法。在实现上,方法分为下面几步:

  1. 根据Instrumenter的配置项确定babel的配置项,同时,引入programVisitor作为babel的plugin,指定遍历节点的操作
  2. 使用@babel/coretransformSyncAPI,得到生成的代码
  3. 更新fileCoveragesourcemap
  4. 返回代码

istanbul-lib-hook

1
2
3
4
5
6
7
import { hookRequire } from 'istanbul-lib-hook';
hookRequire(matcher, transformer, {
verbose: true,
postLoadHook: (filename: string) => {
// ...
}
});

istanbul-lib-hook提供了下面一些API,用来hook JS中对代码的引用,其中后三个API都有对应的unhook API:

  • hookRequire,hook了require引入的代码
  • hookCreateScript,hook了vm.createScript引入的代码
  • hookRunInThisContext,hook了vm.runInThisContext引入的代码
  • hookRunInContext,hook了vm.runInContext引入的代码、

在其中最常用的是hookRequire,入参如下:

  • matcher,函数类型,接收文件的完整路径,返回bool类型,用来判断是否对文件插桩
  • transformer,函数类型,接收代码和文件路径,返回插桩后的代码,可以通过对上面提到的InstrumenterinstrumentSync封装得到
  • options,配置项
    • verbose,是否打印详细信息
    • postLoadHook,文件hook之后的回调

hookRequire借助Nodejs的Module加载机制实现,hook了后缀为.js文件的编译过程,在每次require的时候触发。详细的原理可以参见这里。唯一的不同是,在istanbuljs中,这一实现被封装在append-transform包中实现,来兼容异常情况。实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const appendTransform = require('append-transform');

function hookRequire(matcher, transformer, options) {
// ...
const fn = transformFn(matcher, transformer, options.verbose);
const extensions = options.extensions || ['.js'];

extensions.forEach(ext => {
appendTransform((code, filename) => {
// ...
const ret = fn(code, filename);
if (postLoadHook) {
postLoadHook(filename);
}
return ret.code;
}, ext);
});
// ...
}

数据维护

在代码插桩的基础上,产出的覆盖率数据会积累在全局变量上。在数据可视化和统计结果展示前,还需要将数据封装成更贴近实际场景的形态,便于进行合并、输出、摘要等操作。

这部分工作在istanbul-lib-coverage中完成。

覆盖率数据结构

下面是采集到的原始数据的结构定义,这也是istanbul-lib-coverage的基础。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
interface CoverageMapData {
[key: string]: FileCoverageData;
}
interface FileCoverageData {
path: string;
statementMap: { [key: string]: Range };
fnMap: { [key: string]: FunctionMapping };
branchMap: { [key: string]: BranchMapping };
s: { [key: string]: number };
f: { [key: string]: number };
b: { [key: string]: number[] };
}
interface Location {
line: number;
column: number;
}

interface Range {
start: Location;
end: Location;
}

interface BranchMapping {
loc: Range;
type: string;
locations: Range[];
line: number;
}

interface FunctionMapping {
name: string;
decl: Range;
loc: Range;
line: number;
}

istanbul-lib-coverage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import libCoverage from 'istanbul-lib-coverage';
const map = libCoverage.createCoverageMap(globalCoverageVar);
const summary = libCoverage.createCoverageSummary();

map.merge(otherCoverageMap);

// 获取摘要
map.files().forEach((f) => {
const fc = map.fileCoverageFor(f);
const s = fc.toSummary();
summary.merge(s);
});

console.log('Global summary', summary);

包暴露了3个API, 用来创建封装好的覆盖率数据。

createCoverageMap

传入原始数据(CoverageMapData类型)或CoverageMap类型,返回一个CoverageMap类型对象,在覆盖率数据基础上,提供了方法,包含:

  • merge,合并另一个CoverageMap对象或数据
  • filter,传入filter函数,删除部分覆盖率数据
  • toJSON,返回原始数据
  • files,返回文件列表
  • fileCoverageFor,返回特定文件的覆盖率
  • addFileCoverage,增加特定文件的覆盖率
  • getCoverageSummary,生成覆盖率摘要数据

摘要数据数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
interface CoverageSummaryData {
lines: Totals;
statements: Totals;
branches: Totals;
functions: Totals;
}
interface Totals {
total: number;
covered: number;
skipped: number;
pct: number;
}
createFileCoverage

类似createCoverageMap,通过传入原始数据或对象的方式,创建FileCoverage对象。对象有如下方法:

  • getLineCoverage,返回每一行的执行次数
  • getUncoveredLines,返回未覆盖的行序号列表
  • getBranchCoverageByLine,获取每个分支按行计算的覆盖率比例
  • toJSON,返回原始数据
  • merge,合并另一个FileCoverage或原始数据
  • computeSimpleTotals,根据输入的属性,计算覆盖率摘要
  • computeBranchTotals,根据分支计算覆盖率摘要
  • resetHits,重置所有已执行的数据
  • toSummary,生成摘要数据
createCoverageSummary

根据输入数据,创建摘要。方法较简单:

  • merge,合并一个摘要数据
  • toJSON,返回原始摘要数据
  • isEmpty,是否为空

整体来看istanbul-lib-coverage所做的工作,即按照约定好的格式,将数据组织起来,为可视化输出做了基础。

istanbul-lib-source-maps

1
2
3
4
5
6
7
8
9
10
import { createSourceMapStore } from 'istanbul-lib-source-maps';
import libCoverage from 'istanbul-lib-coverage';

const mapStore = createSourceMapStore({});

const coverageMap = libCoverage.createCoverageMap(coverageData);
const transformed = await mapStore.transformCoverage(coverageMap);
const transformedCoverage = transformed.data;

console.log(transformedCoverage);

绝大多数情况下,istanbuljs采集到的都是转译后目标文件的代码覆盖率,这个数据对于开发者来讲意义不大,需要将这里采集到的行列数据反映射到源文件的位置上。内置istanbul-lib-source-maps包用来处理这种情况的。

istanbul-lib-source-maps只负责转换代码位置和目录,并不能负责收集代码或文件的sourcemap信息,这一步交给使用者自己实现。就像我们在instrumentSync一节提到的,通过第三个入参inputSourceMap可以产出满足istanbul-lib-source-maps需要的覆盖率数据,从而借助istanbul-lib-source-maps获取到源文件的覆盖率。

这个包本身只暴露createSourceMapStore这一个API,通过传入配置的方式,初始化一个sourcemap的store。配置项如下:

  • verbose,是否打印详细信息
  • baseDir,sourcemap文件的基础目录
  • SourceStore,一个SourceStore对象
  • sourceStoreOpts,初始化SourceStore对象的参数列表

初始化好的MapStore对象有如下方法:

  • registerURL,通过dataURL注册一个sourcemap到store中
  • registerMap,通过sourcemap对象注册一个sourcemap到store中
  • getSourceMapSync,从当前store中获取指定文件的sourcemap
  • addInputSourceMapsSync,为当前覆盖率数据同步添加store中的sourcemap数据
  • sourceFinder,寻找指定文件的源文件路径
  • transformCoverage,利用当前store中的sourcemap信息,将覆盖率对应到源文件上。覆盖率信息中有inputSourceMap字段时,优先使用inputSourceMap字段里的sourcemap信息
  • dispose,清除store数据

包中的其余文件也都基于MapStore实现诸如路径转换,数据转换等工具方法。更多细节,可以参见源文件

使用时,有两种方式:

  • 先导入没有sourcemap的覆盖率信息,再逐个注册sourcemap到store中,最后执行transformCoverage
  • 直接导入有inputSourceMap信息的覆盖率数据,执行transformCoverage

可以根据实际场景选择使用方式。例如,nyc中就使用的第2种方式,利用convert-source-map采集到文件内的sourcemap信息,在代码插桩时传入。

可视化与报告

覆盖率工具的最终目的是向使用者呈现可读的数据样式,通常是以UI或文件的形式。因此,要将上一章中维护好的数据按使用者需求输出。可视化和报告由istanbul-lib-report以及istanbul-reports实现。

其中,前者定义了产出报告的抽象行为,后者实现了各种具体的报告形态。两个包结合在一起使用的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import libReport from 'istanbul-lib-report';
import reports from 'istanbul-reports';
const coverageMap;

const configWatermarks = {
statements: [50, 80],
functions: [50, 80],
branches: [50, 80],
lines: [50, 80]
};

const context = libReport.createContext({
dir: 'report/output/dir',
// 摘要的生成方式
// 可以是nested/flat/pkg 默认为'pkg'
defaultSummarizer: 'nested',
watermarks: configWatermarks,
coverageMap,
});
const report = reports.create('json', {
skipEmpty: configSkipEmpty,
skipFull: configSkipFull
});
// 同步操作
report.execute(context);

istanbul-lib-report

维护好的覆盖率数据会和具体的文件节点绑定,形成树状数据结构。遍历每个树节点就是产出报告的基本步骤,只不过不同报告格式,遍历节点的操作也不同。istanbul-lib-report中定义了基本的树状结构和遍历规则。

包暴露了3个API,

  • createContext,创建一个包含产出报告配置信息context对象
  • getDefaultWatermarks,返回默认的评级指标,默认是0到50到80,分别对应差中好
  • ReportBase,所有报告必须继承的基类

其中context和ReportBase是配合实现生成报告的。先来看简单的ReportBase,ReportBase类中简单地描述了管理摘要树的方法和定义了通用的execute方法来生成报告。具体会在istanbul-reports中提到。

再来看Context类。Context入参配置项包含:

  • dir,产出报告的目标目录
  • watermarks,评级指标,默认使用0-50-80-100
  • sourceFinder,通过文件路径返回代码的函数,默认为文件读取操作
  • coverageMap,覆盖率数据
  • defaultSummarizer,摘要树生成的风格,有flat,pkg,nested3种选择
    • flat,所有文件全部打平到1层里,挂载到最近的公共祖先上
    • pkg,所有文件向上追溯1层文件夹,挂载到最近的公共祖先上
    • nested,所有文件向上追溯,直到追溯到最近的公共祖先上

构建好的Context有一个writer属性挂载在thisthis.data上,它决定了报告的产出形式,目前这个包提供了两种产出形式:文件字符串,后者只用在产出XML数据时。其余报告基本都通过文件的形式产出,这也是istanbuljs和istanbul的一大不同。在istanbul中,html可以借助express依赖以HTML响应的形式返回。

文件的输出类定义在file-writer.js中,其中的FileWriter定义了基本的copyFilewriterForDirwriteFile操作用来递归的复制和写入文件。另外在writeFile中,根据目标是否是file,选择使用ConsoleWriter或者FileContentWriter。这两者继承自基本的ContentWriter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class ContentWriter {
colorize(str /*, clazz*/) {
return str;
}
println(str) {
this.write(`${str}\n`);
}
close() {}
}
class FileContentWriter extends ContentWriter {
constructor(fd) {
super();
this.fd = fd;
}

write(str) {
fs.writeSync(this.fd, str);
}

close() {
fs.closeSync(this.fd);
}
}
class ConsoleWriter extends ContentWriter {
write(str) {
if (capture) {
output += str;
} else {
process.stdout.write(str);
}
}

colorize(str, clazz) {
const colors = {
low: '31;1',
medium: '33;1',
high: '32;1'
};

if (supportsColor.stdout && colors[clazz]) {
return `\u001b[${colors[clazz]}m${str}\u001b[0m`;
}
return str;
}
}

字符串的输出类只有XMLWriter,位于xml-writer.js中,在其中定义了xml的开闭标签和缩进操作。

这个Context还有一些方法提供给reports使用:

  • getWriter/getXMLWriter,返回输出数据的writer
  • getSource,通过sourceFinder寻找源码
  • classForPercent,将评价转化为HTML类名
  • getVisitor,获取一个遍历的vistor对象
  • getTree,根据摘要风格返回一个用于遍历的树,

下面具体介绍vistor和tree的概念

VisitorBaseTree

Visitor类和BaseNode类定义了遍历和节点的基本操作,其中Visitor使用代理人模式,在构造时传入一个回调函数对象,当visitor对象触发特定事件时,会将当前节点和状态交给回调函数,实现遍历效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Visitor {
constructor(delegate) {
this.delegate = delegate;
}
}

['Start', 'End', 'Summary', 'SummaryEnd', 'Detail']
.map(k => `on${k}`)
.forEach(fn => {
Object.defineProperty(Visitor.prototype, fn, {
writable: true,
value(node, state) {
if (typeof this.delegate[fn] === 'function') {
this.delegate[fn](node, state);
}
}
});
});

相对应的BaseNode类定义了用于遍历的visit方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class BaseNode {
// ...
// 递归遍历
visit(visitor, state) {
// 文件夹没有详情,只有摘要
if (this.isSummary()) {
visitor.onSummary(this, state);
} else {
visitor.onDetail(this, state);
}

this.getChildren().forEach(child => {
child.visit(visitor, state);
});

if (this.isSummary()) {
visitor.onSummaryEnd(this, state);
}
}
}

最后在BaseTree中从Root开始遍历即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class BaseTree {
constructor(root) {
this.root = root;
}
getRoot() {
return this.root;
}
visit(visitor, state) {
if (!(visitor instanceof Visitor)) {
visitor = new Visitor(visitor);
}
visitor.onStart(this.getRoot(), state);
this.getRoot().visit(visitor, state);
visitor.onEnd(this.getRoot(), state);
}
}

不同的摘要风格形成的树状结构不同,它们都是ReportTree类型,而ReportTree正是继承自BaseTreeReportTree由继承自BaseNodeReportNode构建。在ReportNode上定义了和覆盖率或文件操作相关的一些方法,如getFileCoveragegetCoverageSummary等。

一个context内的visitor和tree可以通过getVisitorgetTree得到

istanbul-reports

istanbul-reports包中定义了种类繁多的导出格式,在入口文件通过指定的配置项选择使用,如上面样例中的const report = reports.create('json', {/* ... */})即使用json/lib/index.js下导出的JsonReport类。istanbul-reports所有格式都基于istanbul-lib-report中基类的定义。这里以简单的json格式为例。

其中定义了对于onStartonDetailonEnd的定义和上面BaseNode中介绍的回调函数相对应,在遍历ReportTree的各个阶段被触发,通过context的writer去输出。而writer从ReportBase中可以发现是通过context确定的,在context中默认是filewriter。而遍历是如何执行的呢?

回头看下ReportBase的实现。

1
2
3
4
5
6
7
8
9
10
11
const _summarizer = Symbol('ReportBase.#summarizer');

class ReportBase {
constructor(opts = {}) {
this[_summarizer] = opts.summarizer;
}

execute(context) {
context.getTree(this[_summarizer]).visit(this, context);
}
}

execute中传入context,即可把带有覆盖率信息的树形数据结构交给report代表的visitor遍历,逐个节点地打印出覆盖率报告。

其余类型的报告也是类似的原理。

更多