Closed azl397985856 closed 4 years ago
认领
istanbul 是一款用来计算 JS 代码的测试覆盖率的工具, 是前端测试覆盖率工具中非常流行,评价最好的的之一。 它可以用来计算行覆盖率,方法覆盖率以及分支覆盖率等。 本文我们来看下 istanbul 是如何来计算这些指标的。
对于 nodejs 而言, istanbul 其实是重写了 nodejs 的模块加载器,然后在模块加载前对代码进行修改,模块加载器是什么? 它具体是怎么修改的呢?我们继续往下看。
看 nodejs 加载器之前,我们来看下什么是模块,知道的可以直接跳过这一节。
nodejs 中每一个文件就是一个单独的模块,模块内部可以引入其他模块,也可以将自己的内容进行导出。
我们使用关键字 require 引入别的模块,require 内部的机制, 以及 require 的缓存require.cache
等等这里就不深究了。
其实模块并不是黑魔法,nodejs 模块代码被执行的之后,其实是被包裹了一层去执行的,类似这样:
(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});
require.extensions
是一个废弃的 API, 而 istanbul 正是用了这个 API,
require.extensions
的功能其实就是去处理不同的文件扩展。 比如 sjs 后缀被当然 js 文件来看待我们就可以这么写,
require.extensions['.sjs'] = require.extensions['.js'];
. 再比如我想处理vue
, 文件或者jsx
文件也可以基于此进行扩展。
更多关于 node 模块的介绍,请参考nodejs 官方文档
istanbul 中的 instrument
对于如下 JS 代码:
function add(number1 = 0, number2 = 0) {
if (typeof number1 !== 'number') return 0;
if (typeof number2 !== 'number') return 0;
return number1 + number2;
}
我们先使用 istanbul 的 instruct 来处理上面的代码,让我们来测试一下:
var istanbul = require('istanbul');
var instrumenter = new istanbul.Instrumenter();
var generatedCode = instrumenter.instrumentSync(`function add(number1 = 0, number2 = 0) {
if (typeof number1 !== 'number') return 0;
if (typeof number2 !== 'number') return 0;
return number1 + number2
}`);
console.log(generatedCode);
当你执行上面的脚本的时候控制台会输出如下代码:
我测试的 istanbul 版本是 0.4.5 , node 版本 v8.9.3
var __cov_twf0CJaWcG8rV6dmHmHPGQ = Function('return this')();
if (!__cov_twf0CJaWcG8rV6dmHmHPGQ.__coverage__) {
__cov_twf0CJaWcG8rV6dmHmHPGQ.__coverage__ = {};
}
__cov_twf0CJaWcG8rV6dmHmHPGQ = __cov_twf0CJaWcG8rV6dmHmHPGQ.__coverage__;
if (!__cov_twf0CJaWcG8rV6dmHmHPGQ['1567251907601.js']) {
__cov_twf0CJaWcG8rV6dmHmHPGQ['1567251907601.js'] = {
path: '1567251907601.js',
s: { '1': 1, '2': 0, '3': 0, '4': 0, '5': 0, '6': 0 },
b: { '1': [0, 0], '2': [0, 0] },
f: { '1': 0 },
fnMap: {
'1': {
name: 'add',
line: 1,
loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 39 } },
},
},
statementMap: {
'1': { start: { line: 1, column: 0 }, end: { line: 5, column: 1 } },
'2': { start: { line: 2, column: 4 }, end: { line: 2, column: 46 } },
'3': { start: { line: 2, column: 37 }, end: { line: 2, column: 46 } },
'4': { start: { line: 3, column: 4 }, end: { line: 3, column: 46 } },
'5': { start: { line: 3, column: 37 }, end: { line: 3, column: 46 } },
'6': { start: { line: 4, column: 4 }, end: { line: 4, column: 28 } },
},
branchMap: {
'1': {
line: 2,
type: 'if',
locations: [
{ start: { line: 2, column: 4 }, end: { line: 2, column: 4 } },
{ start: { line: 2, column: 4 }, end: { line: 2, column: 4 } },
],
},
'2': {
line: 3,
type: 'if',
locations: [
{ start: { line: 3, column: 4 }, end: { line: 3, column: 4 } },
{ start: { line: 3, column: 4 }, end: { line: 3, column: 4 } },
],
},
},
};
}
__cov_twf0CJaWcG8rV6dmHmHPGQ = __cov_twf0CJaWcG8rV6dmHmHPGQ['1567251907601.js'];
function add(number1 = 0, number2 = 0) {
__cov_twf0CJaWcG8rV6dmHmHPGQ.f['1']++;
__cov_twf0CJaWcG8rV6dmHmHPGQ.s['2']++;
if (typeof number1 !== 'number') {
__cov_twf0CJaWcG8rV6dmHmHPGQ.b['1'][0]++;
__cov_twf0CJaWcG8rV6dmHmHPGQ.s['3']++;
return 0;
} else {
__cov_twf0CJaWcG8rV6dmHmHPGQ.b['1'][1]++;
}
__cov_twf0CJaWcG8rV6dmHmHPGQ.s['4']++;
if (typeof number2 !== 'number') {
__cov_twf0CJaWcG8rV6dmHmHPGQ.b['2'][0]++;
__cov_twf0CJaWcG8rV6dmHmHPGQ.s['5']++;
return 0;
} else {
__cov_twf0CJaWcG8rV6dmHmHPGQ.b['2'][1]++;
}
__cov_twf0CJaWcG8rV6dmHmHPGQ.s['6']++;
return number1 + number2;
}
可以看出我们的代码被插入了很多__cov_twf0CJaWcG8rV6dmHmHPGQ
, 这个就是用来做统计的,
然后实际代码执行的时候会往全局变量更新值,我们就可以根据这些统计数据去计算覆盖率了。
具体的实现感兴趣的可以去看下 istanbul 源码
其实上面我们讲模块的时候,已经提到了模块加载器,它是 nodejs 用来加载模块的。
值得一提的是 istanbul 对模块加载器进行了重写,不同于 node 默认的 readFile + compile, istanbul 的 js 加载器的过程是 readFile + instrument + compile,代码中的 fn/transformer 就是 intrument 函数。
核心的代码:
function hookRequire(matcher, transformer, options) {
options = options || {};
var extensions,
fn = transformFn(matcher, transformer, options.verbose),
postLoadHook =
options.postLoadHook && typeof options.postLoadHook === 'function'
? options.postLoadHook
: null;
extensions = options.extensions || ['.js'];
extensions.forEach(function(ext) {
if (!(ext in originalLoaders)) {
originalLoaders[ext] = Module._extensions[ext] || Module._extensions['.js'];
}
Module._extensions[ext] = function(module, filename) {
// 这里先读文件,然后fn处理,最后调用module._compile
var ret = fn(fs.readFileSync(filename, 'utf8'), filename);
if (ret.changed) {
// 对于一些文件可以不处理,如果不处理,就用原来的模块加载器
module._compile(ret.code, filename);
} else {
originalLoaders[ext](module, filename);
}
if (postLoadHook) {
postLoadHook(filename);
}
};
});
}
通过这样的修改,这个过程就变得透明了,你还是和以前一样引入模块就好了,整个过程用户无感知, 如果大家在做技术升级和推广,能做到这种程度相比是非常完美的。这部分的源码在这里
本篇文章像大家介绍了istanbul是如何计算单元测试的覆盖率的,我们先是复习了模块的知识, 然后通过一个例子来说明istanbul是对我们的源码进行了一些处理,在我们的代码无数位置添加计数器, 然后代码执行到计数器+1,通过这样的方式我们就可以收集到统计数据,进而计算单元测试覆盖了。
另外我们知道了istanbul 通过修改nodejs的模块加载器,从而实现了对用户透明的效果。
最近我重新整理了下自己的公众号,并且我还给他换了一个名字《脑洞前端》,它是一个帮助你打开大前端新世界大门的钥匙🔑,在这里你可以听到新奇的观点,看到一些技术尝新,还会收到系统性总结和思考。
我会尽量通过图的形式来阐述一些概念和逻辑,帮助大家快速理解,图解前端是我的目标。
之后我的文章同步到微信公众号 脑洞前端 ,您可以关注获取最新的文章,或者和我进行交流。
我们平时用单元测试的时候非常关心测试的覆盖率,如果很低,那其实还不如不做。
单元测试覆盖率的种类有很多,包括行覆盖率,分支覆盖率,方法覆盖率等,那么这些覆盖率是如何计算的呢?原理是什么?