前端代码覆盖率实时统计方案探索
背景
随着业务增长,代码逐渐复杂,前端有些时候不能通过自测保证代码质量,而测试同学从用户角度进行端到端的case测试,也有可能存在测试case不够全面或重复覆盖的情况。而测试覆盖质量只能通过测试case评审来保证,没有量化的、直观的客观数据。
然而前端场景和后端不同,UI测试因为业务形态原因,变更会更频繁,编写和维护单测成本比较高。因此目前且短期内前端工程还是以传统的用户角度的端到端测试为主。另外,在SSR项目中,前端开发也会书写API层,这部分代码也需要测试覆盖到。目前前端生态还没有一个能同时覆盖UI层和API层的覆盖率统计框架,需要借助已有的工具实现这点。
解决问题
提升开发自测质量和提测质量,避免case覆盖不全的问题,同时有直观的统计指标衡量。
预期效果
分3个阶段:
第一阶段:基本可用版本,能够采集UI层和API层的代码覆盖率,覆盖率采集过程对前端开发透明,无需开发业务以外的开发成本。能够打开浏览器页面直观查看各个代码覆盖率。
第二阶段:丰富统计数据,在第一阶段基础上,能够记录不同分支甚至不同版本历史的覆盖率,能够借助gitlab只查看增量文件的覆盖率。
第三阶段:打通QA平台,在第二阶段基础上,接入QA已有平台或自建平台,在第三方页面查看数据;形成一套覆盖率采集方案。
使用场景
本地、特性分支上使用
技术选型
覆盖率采集建立在代码插桩基础上。前端生态里比较成熟的工具是istanbuljs,该库的0.x.x版本位于istanbul库。关于该库的科普介绍,可以阅读ruanyifeng的科普文。
istanbul提供两种插桩方式:编译时和运行时。
- 编译时
- nyc命令行的instrument子命令可以完成手动插桩
- babel-plugin-istanbul可以在使用babel的前端工程里,在编译阶段植入插桩代码。TypeScript项目可以使用@istanbuljs/nyc-config-typescript插件
- 运行时,需要借助istanbul-middleware中间件的帮助
im.hookLoader
,适用于服务端文件的动态插桩,方法利用istanbul-lib-hook中的hookRequire
方法,hook被require引入的js文件,返回插桩后的js文件。因此需要在业务代码require前引入im.createClientHandler
,用于客户端js文件的动态插桩,它会把指定根路径下的js文件请求拦截,返回插桩后的代码。
在后台项目中,服务端代码使用.ts
书写,本地使用ts-node
启动,boe和线上使用tsc
编译后的js文件启动。前端代码使用.ts
书写,本地使用webpack + babel预编译成js。结合这个场景看,在node侧使用hookLoader
,在UI测使用babel-plugin-istanbul插件更合适。
总体来讲,工作分两步:代码插桩和可视化。
代码插桩
规划里一切功能的基础和本质都来自代码插桩。
client端
client端可以直接使用目前仍良好维护的babel-plugin-istanbul插件。在.babelrc
中引入相关配置:
1 | { |
仅在test环境下开启该插件。可以用cross-env这样的工具设置NODE_ENV=test
1 | { |
插件还有include
,exclude
,useInlineSourceMaps
这样的配置项。
1 | { |
插桩后的业务代码如下所示:
1 | var cov_ac7rkuoyv = function () { |
可以看到针对每个文件,插桩后的代码新建了一个函数,用于更新这个文件的覆盖率信息。在业务代码执行时,会不断更新对应的计数器。前端代码run起来后,可以通过window.__coverage
直接得到当前项目的覆盖率情况。
覆盖率计算原理
拿到代码字符串后,使用AST解析库解析出语法树,在对应树节点插入覆盖率语句,之后将语法树转成插桩后的代码。这个过程在0.x.x版本的实现可以参加这里。
0.x.x版本的instanbul使用esprima和escodegen。在最新的istanbuljs的istanbul-lib-instrument中使用babel相关的包完成解析、生成等功能,但插桩思路不变。
node端
node端使用istanbul-middleware中间件的hookLoader
完成动态插桩。该中间件为instanbul作者所写,所依赖的istanbul版本是目前已经不再维护的0.x.x版本,同时中间件也是基于express所写,年代较老。中间件的设计和我们的场景有些距离,因此需要在其基础上进行改造。
在预期场景下,我们主要使用im.hookLoader
和im.createHandler
两个方法。
im.hookLoader
im.hookLoader
只是在instanbul hook的基础上做的简单封装。看文档可以发现:
1 | function hookRequire(matcher, transformer, options) { |
hook require是利用CommonJS的模块加载规范实现的。这里介绍下模块加载流程相关的背景。
模块加载流程
require和module来自nodejs的Modules模块。无需显式引入(原因下面会提)即可使用里面的module
, require
, exports
的关键字。在require一个包时,分为了解析、加载、封装、求值、缓存几步。
- 解析一步交给
module.require(modulename)
方法实现,该方法调用静态方法Module._load
加载模块。 - 加载时首先调用
Module._resolveFilename
解析路径,解析过程大致分为粗筛和精确定位 - 粗筛,原生模块则直接返回模块名,否则从当前目录逐步向上寻找node_modules目录下的模块文件夹
- 精确定位,首先在上面的目录下寻找有无没有拓展名的同名文件,再寻找以
js
、json
、node
拓展名结尾的文件;再寻找同名目录下package.json
中main
字段指定的路径,最后寻找同名目录下的index.js - 得到路径后,先试图从
Module._cache
中寻找有无模块缓存,若没有,则新建模块对象并缓存,之后调用module.load()
方法加载该模块 - node默认只能load以
js
,json
,node
结尾的文件,除此之外均视为.js
文件
1 | Module.prototype.load = function(filename) { |
Module._extensions
键值对默认只包含对上述三种文件的处理。其中json和node文件较简单
- json,读取文件 =>
JSON.parse
=> 注入到module.exports
上 - node,调用
process.dlopen
加载 - js,调用
module._compile()
处理文件内容
module._compile
执行js文件编译,编译前调用Module.wrap
方法将模块封装在函数内,这也是module
,require
,exports
,__filename
,__dirname
可以直接在模块内使用,且模块间的module
,require
,exports
不相互干扰的原因。
1 | Module.wrap = function(script) { |
- wrap后,调用
vm.runInThisContext
将字符串转为可执行的js函数。最后一句执行封装的函数,注入当前module的相关信息到模块中
1 | Module.prototype._compile = function(content, filename) { |
实际上,可以在REPL中输入require.extensions
看到nodejs支持的3种文件拓展。而hookRequire所做的就是hook了加载.js
的步骤,在交由module._compile
处理前,前进行了插桩处理。
注:
ts-node
能够import.ts
文件也是因为在module._extendsion
中添加了.ts
的处理方法
综上,在本地测试时,由于通过ts-node
启动,im.hookLoader
只会hook.js
文件(0.x.x版本),会导致没有服务端代码覆盖率,上线前tsc后,就可以顺利注入,从global.__coverage__
中拿到覆盖率数据。
数据采集
服务端覆盖率数据采集借助上述的im.hookLoader
就已完成。前端页面的代码覆盖率需要自行周期性上报。im.createHandler
提供的API可以接收前端覆盖率,该方法创建的路由回调会调用utils.mergeFileCoverage
最终将增量覆盖率数据累加在global.__coverage__
中。
可视化
可视化需要借助istanbul(0.x版本)本身提供的相关API,又或者直接更方便地使用im.createHandler
。其方便封装了一些istanbul的API,实现的简单的可视化功能,更多介绍可以直接参考istanbul-middleware使用文档。
im.createHandler
1 | function createHandler(opts) { |
从上面的使用上看,im.createHandler
的使用是和express绑定的,并不适配koa的风格,要想使用在非express服务端场景里,或者增加额外功能,就需要重构这个方法。不过,由于一些API的使用从0.x.x版本迁移到新的monorepo的版本并不平滑,且新版本API暂无文档。所以尽管instanbul已经不再更新,对istanbul-middleware中间件的重构还只能保持对旧版本istanbul的依赖。
重构设计
为了兼容koa环境,从istanbul-middleware库fork新版本istanbul-koa-middleware,使用ts重写。去掉无用的代码,重点需要改造的就是createKoaHandler
方法。
为减少istanbul-koa-middleware使用方的依赖,考虑使用类似istanbul-middleware的形式,在createKoaHandler
内部定义koa应用,使用方只需引入koa-mount和istanbul-koa-middleware即可在任意路由上挂载覆盖率可视化相关子路由。
除此之外:
- 使用koa-static替代
express.static
- 使用koa-mount替代
app.use('/some/path', someMiddleware)
- 使用koa-router替代
app.get
和app.post
- 定义
WebFramework
接口,掩盖对回调中res
使用方式的不同
1 | interface WebFramework { |
这样,重构后的istanbul-koa-middleware就可以帮我们实现最简单的覆盖数据可视化了。后续的新增功能,也都建立在对createHandler
函数的改造上。
已知问题
在上面工作完成后,第一阶段目标基本已经实现。
但是,还有很多可以优化的地方,这也是后续阶段需要解决的问题:
- 服务端代码如果没有正确设置source-map,覆盖率展示的文件是tsc之后的js文件,可读性差
- 需要区分环境,在特定环境下才对代码文件插桩
- 本地环境下可以访问到原始的ts文件,上传测试环境后,由于不会打包上传源文件,覆盖率报告将看不了原始ts文件的覆盖详情
- 覆盖率报告功能太简单,不能以分支、版本、仓库等空间维度或以历史信息、趋势等时间维度查看
,也没有diff内容覆盖率功能 - 出现了一些意义不大的文件,需要剔除