覆盖率检测是用来判断单测完整性的,jest 和 karma 都提供了这种功能:
覆盖率就是执行过的代码占总代码的比例,比如执行了多少行(Line),执行了多少个分支(Branch),执行了多少个函数(Function),执行了多少条语句(Statement)。
用它比上总的数量就是覆盖率,分为行覆盖率、分支覆盖率、函数覆盖率、语句覆盖率等。
看起来是不是很神奇,执行完一遍就能知道覆盖到了哪些代码,其实实现原理比较简单,相信看完这篇文章,你会有“就这?”的感觉。
原理探究
jest 和 karama 都是基于 istanbul 做的覆盖率检测,我们来探究下 istanbul 的实现原理。
测试代码如下:
我们执行 istanbul 的 instrument 命令:
- npx istanbul instrument ./test.js -o ./out.js
instrument 是指函数插桩,也就是透明的给函数添加一些代码。
为什么要插桩呢?看完生成的代码你就明白了。
我们来格式化一下,把变量名替换下。
这就是转换后的代码,在每一个 statement,每一个 function、每一个 branch 都做了计数,分别是 s、f、b 属性。
上面还有一段代码:
初始化了全局变量 AAA,记录了这些信息:
- path:路径
- s:statement 数
- b:branch 数
- f:function 数
- fnMap:function 的开始结束位置信息
- statementMap:statement 的开始结束位置信息
- branchMap:branch 的开始结束位置信息
看到这里我们大概就能搞懂覆盖率的原理了,就是对每个 statement、function、branch 都插入一段计数代码,记录在一个全局对象中。
为了不和别的全局变量冲突,这个对象的名字是随机生成的,比如 __cov_5ZoEXQ_Hbo27uXArxdm2oA,这里为了简化改为了 AAA。
我们搞明白了覆盖率就是靠插入计数代码,那怎么做的插桩呢?
函数插桩
函数插桩是基于 AST,找到 statement、function、branch 的 AST,在前面插入插桩代码的 AST。
istanbul 确实也是这么做的。
下面是 istanbul 的源码(只看红线标出的位置就行):
就是通过 esprima(js parser)来把代码 parse 成 AST,然后对 AST 进行插桩。
插桩代码分为两部分,一部分是初始化全局对象的代码,一部分是每个分支、语句、函数的计数代码。
我们分别来看下:
初始化全局对象的代码插桩
istanbul 初始化了全局的 coverState 对象用于统计:
做插桩的时候会记录信息到这个 coverState 中:
最后把 coverState 变成字符串加入到代码里:
那具体的分支、语句、函数的 AST 是怎么插桩的?
分支、语句、函数的插桩
对不同 AST 的插桩,就是遍历过程中根据类型做不同的处理:
然后,具体的插桩就是在前面插入一段 AST:
statement 插桩:
function 插桩:
看到这里,我们就知道了函数插桩的实现原理,就是遍历 AST,在不同的位置插入计数代码的 AST 就可以了。
但是有的同学可能会说了,平时我也没手动生成插桩后的代码啊?用 jest --coverage 跑测试用例自动就做了计数,然后给出覆盖率数据了。
istanbul 是怎么做到透明的插桩的呢?
require hook 实现透明无感知的函数插桩
看过之前一篇 require hook 的魔术那篇文章的小伙伴知道,nodejs 的模块加载是分为 load、extension['.js']、compile 这几步的。
我们只需要重写 extension['.js'] 这一步,就能做到透明的代码转换。
istanbul 也是这么做的:
它就是通过修改了 extension['.js'] 方法,在这里面做了函数插桩,之后执行的代码就是转换过后的了,开发者根本感知不到。
总结
jest 和 karma 都基于 istanbul 实现了覆盖率检测。覆盖率统计的原理就是函数插桩,基于 AST 在代码的 statement、function、branch 处插入计数代码,同时通过 require hook 实现了透明的转换。这样代码一执行就能拿到统计数据,自然就可以算出覆盖率了。
看完之后,是不是觉得:
覆盖率检测的实现,就这?