前端代码覆盖率实时统计改进方案
背景
旧的覆盖率方案实现了覆盖率采集的可行性探索。但是旧方案中也存在着侵入较多,适用场景有局限性,istanbul版本老旧,sourcemap支持不足等问题需要优化。有关覆盖率采集、维护、呈现、上报需要一个更通用和更具可拓展性的方案。
因此,在对整个覆盖率要解决的问题和目标进行抽象后,可以得到以下的实现方案。
整体结构
新的覆盖率方案可以分为3层:
- 采集层,这一层主要解决对代码进行插桩(instrument)的过程,只有插桩过的代码才能在执行的过程中不断积累覆盖率数据,从而通过端到端测试得到代码的覆盖率情况
- 数据层,采集到的覆盖率数据有上报的需求,浏览器端数据只有上报到服务端才能查看和操作,如果接入第三方平台,就更需要有专门数据处理和上报,这一层正是完成这部分工作
- 视图层,方案要自带视图化能力,给开发者更直观的反馈,同时要有可交互性,满足开发者的日常需求。这一层要能比较方便地绑定到流行的服务端环境中,减少侵入成本
整体实现上,基于新版本istanbuljs。下面分层介绍各层实现中需要进行的工作。
采集层
代码插桩实现中,拆分为下面几点:
babel preset
对于使用babel转码的工程来说,babel-plugin-istanbul直接提供了一个可以插桩代码的插件。这里要做的只是区分线上和其他环境,以及预定义preset,减少用户babel配置成本。
babel preset的书写可以参考babel preset一节,我们只需将原先.babelrc
或babel.config.js
中的配置项用require
包裹即可。
1 | module.exports = function () { |
相对应的,接入方需要额外在scm_build.sh
脚本中额外传入boe环境变量来区分boe和线上环境:
1 | if [ $BUILD_TYPE = "offline" ]; then |
同时,默认情况下,istanbul只会对后缀为['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx']
的文件进行处理,为了处理.vue
文件,需要显式给babel-plugin-istanbul
文件传入extension
字段。
1 | module.exports = function () { |
hookRequire
动态插桩
有些场景下并不使用babel进行转码,这里以最典型的TypeScript为例,.ts
文件可以用tsc
编译。tsc
编译后的js代码只有插桩后,才可能采集到的覆盖率数据,因此接入方需要在所有业务代码的import之前调用hook,保证所有的服务端js代码在被执行前都完成了插桩。
实现上,新版本istanbuljs整个是个monorepo,原来istanbul各模块的功能拆分到了packages的各子项目中。其中和hook相关的位于istanbul-lib-hook。这个库虽然并没有API和文档。但还好,和老版本的hook.js区别不大。使用暴露出来的hookRequire方法即可,使用方式也与之前无异。
1 | // old |
hookRequire
方法接受3个参数:
matcher
,来自用户输入,用来判断当前文件是否要被hooktransformer
,最关键的代码转换函数,读入代码,返回转换后的代码,插桩的转换由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处理的流程如下:
- 在hookRequire的transformer中拿到代码字符串
- 通过convert-source-map的
fromSource
方法拿到sourcemap对象 - 在调用istanbul-lib-instrument的
instrumentSync
方法时,传入该对象- instrumenter内部的visitor.js调用source-coverage.js的
inputSourceMap
方法将文件的sourcemap传入 - source-coverage.js将sourcemap存入覆盖率数据中
- instrumenter内部的visitor.js调用source-coverage.js的
- 使用istanbul-lib-source-maps中的
transformCoverage
方法转换覆盖率数据即可得到原始的覆盖率
1 | import { createInstrumenter } from 'istanbul-lib-instrument'; |
数据层
在覆盖率数据维护上,主要有下面几点工作:
sourcemap处理
在代码插桩附带上sourcemap信息后,可以参考istanbul-lib-source-maps的测试用例,用transformCoverage
方法转换得到原始的覆盖率,即上一个同名小节中的第4步。
1 | async function getOriginalCoverage() { |
实现时,还需要注意对一些sourcemap结构体中的sourceRoot
进行特殊处理,避免反映射后得到嵌套的错误路径。
覆盖率数据维护
在一个测试覆盖率的周期下,需要merge每次产生的新的覆盖率。同时,要能通过原始的覆盖率数据生成摘要,方便UI展示。
istanbul-lib-coverage的CoverageMap
类型自带的merge
方法,可以merge新的覆盖率数据到源CoverageMap
中。
1 | export function mergeClientCoverage(clientCoverage: CoverageMap) { |
基准覆盖数据维护
有些js代码在未访问到业务代码时就会被执行到,比如import
和export
语句。这些覆盖率如果被清空,就再也无法通过端到端测试找回来,所以需要对于这部分覆盖数据专门维护,这里称作基准覆盖数据。
实现上,在hook完成后,取得hook文件的覆盖率数据,即可得到。这里需要异步执行,保证能够取到。
1 | hook(matcher, transformer, { |
每次reset操作,实现上并非简单的清空数据,而是将覆盖率数据置为基准覆盖数据。
1 | // express |
数据上报
数据上报只需实现定期上报,在上报时通过使用方预先定义好的中间件,便于使用方做预处理的工作。同时,提供一个关闭方法,可以在适当时机停止上报,形如:
1 | class CoverageCollector { |
视图层
视图化和可操作性上,有下面几点工作:
路由提供
中间件对于开发者的受益,体现在能够通过浏览器访问得到视图化和可操作性的覆盖率数据。在功能上,中间件提供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 | const libReport = require('istanbul-lib-report'); |
在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框架的服务端等无法支持