前端代码覆盖率实时统计改进方案

背景

旧的覆盖率方案实现了覆盖率采集的可行性探索。但是旧方案中也存在着侵入较多,适用场景有局限性,istanbul版本老旧,sourcemap支持不足等问题需要优化。有关覆盖率采集、维护、呈现、上报需要一个更通用和更具可拓展性的方案。

因此,在对整个覆盖率要解决的问题和目标进行抽象后,可以得到以下的实现方案。

整体结构

整体结构

新的覆盖率方案可以分为3层:

  • 采集层,这一层主要解决对代码进行插桩(instrument)的过程,只有插桩过的代码才能在执行的过程中不断积累覆盖率数据,从而通过端到端测试得到代码的覆盖率情况
  • 数据层,采集到的覆盖率数据有上报的需求,浏览器端数据只有上报到服务端才能查看和操作,如果接入第三方平台,就更需要有专门数据处理和上报,这一层正是完成这部分工作
  • 视图层,方案要自带视图化能力,给开发者更直观的反馈,同时要有可交互性,满足开发者的日常需求。这一层要能比较方便地绑定到流行的服务端环境中,减少侵入成本

整体实现上,基于新版本istanbuljs。下面分层介绍各层实现中需要进行的工作。

采集层

代码插桩实现中,拆分为下面几点:

babel preset

对于使用babel转码的工程来说,babel-plugin-istanbul直接提供了一个可以插桩代码的插件。这里要做的只是区分线上和其他环境,以及预定义preset,减少用户babel配置成本。

babel preset的书写可以参考babel preset一节,我们只需将原先.babelrcbabel.config.js中的配置项用require包裹即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = function () {
return {
env: {
development: {
plugins: [
[require('babel-plugin-istanbul')]
]
},
boe: {
plugins: [
[require('babel-plugin-istanbul')]
]
}
}
};
};

相对应的,接入方需要额外在scm_build.sh脚本中额外传入boe环境变量来区分boe和线上环境:

1
2
3
4
5
if [ $BUILD_TYPE = "offline" ]; then
BABEL_ENV=boe yarn build
else
yarn build
fi

同时,默认情况下,istanbul只会对后缀为['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx']的文件进行处理,为了处理.vue文件,需要显式给babel-plugin-istanbul文件传入extension字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = function () {
return {
env: {
development: {
plugins: [
[require('babel-plugin-istanbul'), { extension: ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.vue'] }]
]
},
boe: {
plugins: [
[require('babel-plugin-istanbul'), { extension: ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.vue'] }]
]
}
}
};
};

hookRequire动态插桩

有些场景下并不使用babel进行转码,这里以最典型的TypeScript为例,.ts文件可以用tsc编译。tsc编译后的js代码只有插桩后,才可能采集到的覆盖率数据,因此接入方需要在所有业务代码的import之前调用hook,保证所有的服务端js代码在被执行前都完成了插桩。

实现上,新版本istanbuljs整个是个monorepo,原来istanbul各模块的功能拆分到了packages的各子项目中。其中和hook相关的位于istanbul-lib-hook。这个库虽然并没有API和文档。但还好,和老版本的hook.js区别不大。使用暴露出来的hookRequire方法即可,使用方式也与之前无异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// old
import * as istanbul from 'istanbul';
const { hook } = istanbul;

instrumenter = new Instrumenter(options);
const transformer = instrumenter.instrumentSync.bind(instrumenter);
hook.hookRequire(matcher, transformer, {
postLoadHook: (file: any) => {
// ...
}
});

// new
import { hookRequire } from 'istanbul-lib-hook';
import { createInstrumenter } from 'istanbul-lib-instrument';

instrumenter = createInstrumenter(options);
const transformer = instrumenter.instrumentSync.bind(instrumenter);
hookRequire(matcher, transformer, {
postLoadHook: (file: any) => {
// ...
}
});

hookRequire方法接受3个参数:

  • matcher,来自用户输入,用来判断当前文件是否要被hook
  • transformer,最关键的代码转换函数,读入代码,返回转换后的代码,插桩的转换由istanbul-lib-instrument提供(原先的instrumenter.js),理论上也能支持其他的转换函数。这里使用instrumentSync这个同步转换。
  • options,支持verbose和postLoadHook两个选项
    • verbose,boolean,是否打印详细日志
    • postLoadHook,function,成功回调,函数会传入完整的文件名

sourcemap处理

由于istanbuljs插桩的是编译后的js代码,需要借助sourcemap才能找到源文件的覆盖情况。编译后文件的覆盖情况对开发者没有借鉴意义。使用hookRequire动态插桩时,还是需要自己处理sourcemap。

老版本istanbul对sourcemap并不支持,需要借助remap-istanbul才能实现。新版本的istanbuljs有一个独立的包istanbul-lib-source-maps支持这方面的功能。遗憾的是,再次没有文档介绍如何使用。

通过查看测试样例和阅读源码可以发现,istanbul-lib-instrument中的instrumentSync支持第三个sourcemap参数,如果传入,则会在采集文件的覆盖率数据中附加该文件的sourcemap信息到inputSourceMap字段。而istanbul-lib-source-maps这个包可以对覆盖率数据中带有inputSourceMap字段的数据进行反映射,得到源文件覆盖情况。最终达到我们的目的。

源文件的sourcemap信息需要自己采集,这里借鉴的nyc中使用的方案:convert-source-map。这个npm包可以读取文件末尾的sourcemap字符串并转换为sourcemap对象。所以整体上,实现sourcemap处理的流程如下:

  1. 在hookRequire的transformer中拿到代码字符串
  2. 通过convert-source-map的fromSource方法拿到sourcemap对象
  3. 在调用istanbul-lib-instrument的instrumentSync方法时,传入该对象
    1. instrumenter内部的visitor.js调用source-coverage.js的inputSourceMap方法将文件的sourcemap传入
    2. source-coverage.js将sourcemap存入覆盖率数据中
  4. 使用istanbul-lib-source-maps中的transformCoverage方法转换覆盖率数据即可得到原始的覆盖率
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createInstrumenter } from 'istanbul-lib-instrument';
import convert from 'convert-source-map';

instrumenter = createInstrumenter({
// ...
});
// Step 1
const transformer: Transformer = (code, options) => {
// Step 2
const sourceMap = convert.fromSource(code);
// Step 3
const transformed = instrumenter.instrumentSync(
code,
options.filename,
sourceMap ? sourceMap.toObject() : undefined
);
return transformed;
};

hook(matcher, transformer, {
//...
});

数据层

在覆盖率数据维护上,主要有下面几点工作:

sourcemap处理

在代码插桩附带上sourcemap信息后,可以参考istanbul-lib-source-maps的测试用例,用transformCoverage方法转换得到原始的覆盖率,即上一个同名小节中的第4步。

1
2
3
4
5
6
7
8
9
10
11
12
async function getOriginalCoverage() {
// 采集到的覆盖率
const currCoverage = this.getCoverage();

// ...
const coverageMap = createCoverageMap(currCoverage);
const mapStore = createSourceMapStore({});
// Step 4
const transformed = await mapStore.transformCoverage(coverageMap);
// 对应到源文件的覆盖率
return transformed.data;
}

实现时,还需要注意对一些sourcemap结构体中的sourceRoot进行特殊处理,避免反映射后得到嵌套的错误路径。

覆盖率数据维护

在一个测试覆盖率的周期下,需要merge每次产生的新的覆盖率。同时,要能通过原始的覆盖率数据生成摘要,方便UI展示。

istanbul-lib-coverage的CoverageMap类型自带的merge方法,可以merge新的覆盖率数据到源CoverageMap中。

1
2
3
4
5
6
7
8
export function mergeClientCoverage(clientCoverage: CoverageMap) {
if (!clientCoverage) {
return;
}
const currCoverage = getCoverageObject();
const coverageMap = createCoverageMap(currCoverage)
coverageMap.merge(clientCoverage)
}

基准覆盖数据维护

有些js代码在未访问到业务代码时就会被执行到,比如importexport语句。这些覆盖率如果被清空,就再也无法通过端到端测试找回来,所以需要对于这部分覆盖数据专门维护,这里称作基准覆盖数据

实现上,在hook完成后,取得hook文件的覆盖率数据,即可得到。这里需要异步执行,保证能够取到。

1
2
3
4
5
6
7
hook(matcher, transformer, {
// ...
postLoadHook: (filename: string) => {
// ...
matcher(filename) && setTimeout(() => Coverage.saveBaselineCoverage(filename));
}
});

每次reset操作,实现上并非简单的清空数据,而是将覆盖率数据置为基准覆盖数据。

1
2
3
4
5
// express
app.post('/reset', (req, res) => {
Coverage.restoreBaselineCoverage();
res.json({ code: 0 });
});

数据上报

数据上报只需实现定期上报,在上报时通过使用方预先定义好的中间件,便于使用方做预处理的工作。同时,提供一个关闭方法,可以在适当时机停止上报,形如:

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
class CoverageCollector {
// ...
use(middleware: Middleware) {
this.middlewares.push(middleware);
}

// ...
init() {
if (!this.intervalFlag) {
this.intervalFlag = setInterval(() => this.send(), this.interval);
}
}

// ...
close() {
if (this.intervalFlag) {
clearInterval(this.intervalFlag);
this.intervalFlag = null;
}
}

private async send() {
// ...
const coverageData = this.middlewares.reduce((data, middle) => middle(data), coverage);
// ...
try {
await axios.post(this.url, coverageData);
} catch (err) {
this.log('Send coverage data failed: ', err);
}
}
}

视图层

视图化和可操作性上,有下面几点工作:

路由提供

中间件对于开发者的受益,体现在能够通过浏览器访问得到视图化和可操作性的覆盖率数据。在功能上,中间件提供5个路由:

  • /,GET,提供整个项目的覆盖率摘要页面
  • /show,GET,访问文件或文件夹时,提供文件覆盖率详情或文件夹覆盖率摘要
  • /reset,POST,重置当前项目的覆盖率,开始一次新的采集周期
  • /object,GET,返回覆盖率的原始数据的JSON格式
  • /client,POST,用来接收来自浏览器端的覆盖率数据,merge到整体的覆盖率数据中

实现上,直接提供预定义好的app或express应用,npm包暴露一个attachHandler API,使用方直接挂载即可拥有上述功能。类似下面所示。

1
app.use(mount('/coverage', attachHandler.koa()));

由于绑定一定发生在服务端环境,可以的话,需要顺便区分线上和其他环境。

报告html页面生成

开发者应该能在每个测试周期内(即请求/reset前),通过访问特定路由得到渲染好的html页面。istanbuljs通过istanbul-lib-report和istanbul-reports两个包相互配合实现导出report的功能。其中:

  • istanbul-lib-report:重点负责构建文件层级结构和生成摘要数据,定义遍历每个节点时行为的几个抽象类
  • istanbul-reports:重点实现各种各样导出格式下内容的生成(只考虑了生成静态文件),通过实现istanbul-lib-report中ReportBase基类,完成遍历中内容的生成

下面是README.md给出的示例

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
const libReport = require('istanbul-lib-report');
const reports = require('istanbul-reports');

// coverageMap, for instance, obtained from istanbul-lib-coverage
const coverageMap;

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

// create a context for report generation
const context = libReport.createContext({
dir: 'report/output/dir',
// The summarizer to default to (may be overridden by some reports)
// values can be nested/flat/pkg. Defaults to 'pkg'
defaultSummarizer: 'nested',
watermarks: configWatermarks,
coverageMap,
})

// create an instance of the relevant report class, passing the
// report name e.g. json/html/html-spa/text
const report = reports.create('json', {
skipEmpty: configSkipEmpty,
skipFull: configSkipFull
})

// call execute to synchronously create and write the report to disk
report.execute(context)

在istanbul-reports/lib/html中主要定义了html相关内容的生成,但是其中对于fs模块的使用,限制了该npm包在server侧的应用。经过询问作者得到,istanbuljs并没有想法支持动态生成html响应。另外,istanbul-lib-report和istanbul-reports关联较多,新开发一个report类型用于生成html响应成本较大。

综上,使用旧的方案,即旧版本istanbul生成html响应比较合适。

新方案

上述工作完成后,最终可以实现一个新的覆盖率方案,基本解决了上面提到的侵入较多,适用场景有局限性,istanbul版本老旧,sourcemap支持不足等问题。

已知问题

上面的改进方案解决了方案接入上的困难,和实现上的一些纰漏,满足了开发者自测时的需求。但同时也有以下不足:

  • 对于更复杂的数据呈现和与业务流程的整合,即旧方案已知问题的第4点:“不能以分支、版本、仓库等空间维度或以历史信息、趋势等时间维度查看覆盖率报告,也没有diff内容覆盖率功能”,还需要更多努力。
  • 对于一些特殊场景,如没有使用babel转码的纯前端,不使用express或koa框架的服务端等无法支持

参考