Taobao FED

多进程下的测试覆盖率

多进程下的测试覆盖率

单元测试在 Node.js 项目开发中的重要性就不言而喻了,项目一旦稍微大起来了就经常出现拆东墙补西墙的情况。这边修复了一个 bug,那边又不知道什么时候产生了一个新的 bug,越到后面没有经过完整的测试都不敢随便发布。

代码覆盖率

测试的时候,我们常常关心,是否所有代码都测试到了。这个指标就叫做“代码覆盖率”(code coverage),它有四个测量维度。

  • 行覆盖率(line coverage):是否每一行都执行了?
  • 函数覆盖率(function coverage):是否每个函数都调用了?
  • 分支覆盖率(branch coverage):是否每个 if 代码块都执行了?
  • 语句覆盖率(statement coverage):是否每个语句都执行了?

目前在 Node.js 开发中比较流行的测试覆盖率工具是 Istanbul。

Yet another JS code coverage tool that computes statement, line, function and branch coverage with module loader hooks to transparently add coverage when running tests. Supports all JS coverage use cases including unit tests, server side functional tests and browser tests. Built for scale.

Istanbul 不但可以统计到整个项目的代码覆盖率,还会生成一份漂亮的覆盖率报告,准确的标记出哪些代码没有被覆盖到。

平常我们写的 JS 测试用例大部分都是单进程的场景,下面我们来看一个多进程项目的测试情况又是怎么样的呢?

多进程 demo

先写一个简单的 demo,使用 Mocha 做单元测试,Istanbul 生成测试覆盖率。
下面是整个项目的目录结构。

1
2
3
4
5
6
7
8
.istanbul-cluster-demo
|____.gitignore
|____lib
| |____master.js
| |____worker.js
|____package.json
|____test
| |____index.test.js

master.js

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
'use strict';

const path = require('path');
const childProcess = require('child_process');

let rid = 0;
const service = {};
const requestQueue = new Map();

module.exports = function (ready) {
const worker = childProcess.fork(path.join(__dirname,'./worker'));

function send() {
rid++;
let args = [].slice.call(arguments);
const method = args.slice(0,1)[0];
const callback = args.slice(-1)[0];

const req = {
rid: rid,
method:method,
args:args.slice(1,-1)
};

requestQueue.set(rid,Object.assign({
callback: callback
}, req));

worker.send(req);
}

worker.on('message', function(message){
if (message.action === 'register') {
message.methods.forEach((method) => {
service[method] = send.bind(null, method);
});
ready(service);
} else {
const req = requestQueue.get(message.rid);
const callback = req.callback;
if (message.success) {
callback(null, message.data);
} else {
callback(new Error(message.error));
}
requestQueue.delete(message.rid);
}
});
}

worker.js

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
'use strict';

const service = {
add() {
const args = [].slice.call(arguments);
return args.slice().reduce(function(a,b) {
return a+b;
});
},

time() {
const args = [].slice.call(arguments);
return new Promise((resolve, reject)=> {
setTimeout( ()=> {
const ret = args.slice().reduce(function(a,b) {
return a*b;
});
resolve(ret);
}, 1000);
});
}
}

if (process.send) {
process.send({
action:'register',
methods: Object.keys(service)
});
}

process.on('message', function(message) {
let ret = { success: false, rid: message.rid };
const method = message.method;
if (service[method]) {
try {
const result = service[method].apply(service, message.args);
ret.success = true;
if(typeof result.then === 'function') {
return result.then((data)=> {
ret.data = data;
process.send(ret);
}).catch((err)=>{
ret.success = false;
ret.error = err.message;
process.send(err);
})
}
ret.data = result;
} catch (err) {
ret.error = err.message;
}
}
process.send(ret);
});

上面的 demo 实现了一个简单的进程间 rpc 功能,master 进程提供接口,worker 进程实现具体的逻辑,并通过进程间通信给 master 调用。

worker 进程 向 master 进程注册了 add 和 time 方法,分别提供相加和相乘的服务。

测试用例

我们接着使用 Mocha 写一个脚本测试下这个功能。

index.test.js

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
'use strict';
const master = require('../lib/master');
const assert = require('assert');

describe('test/index.test.js', function() {
let service;
before(function(done) {
master(function(_service){
service = _service;
done();
});
});

it('add should work', function(done) {
service.add(1,2,3,4,5, function(err, result) {
assert(result === 1+2+3+4+5);
done();
});
});

it('time should work', function(done) {
service.time(1,2,3,4,5, function(err, result) {
assert(result === 1*2*3*4*5);
done();
});
});
});

运行 node --harmony node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha -- test/**/*.test.js 输出

测试结果

所有的测试用例都已经跑通并统计出各种覆盖率, 再看看生成的覆盖率报告
覆盖率报告,子进程没有被统计到

发现并没有 worker.js 的覆盖率数据,所以上面输出的覆盖率是不完整的。

分析原因

为什么测试的结果中会没有 worker.js 的覆盖率数据呢,稍微想一下其实很简单,master.js 之所以有覆盖率数据因为它通过 Istanbul 启动执行的,代码运行之前 Istanbul 会对 master.js 进行 instrument。下面是一段代码被 instrument 前后的情况。

before instrument

1
function test() { return "Node.js"; }

after instrument

1
2
3
4
5
6
7
8
var __cov_lgAhQ3cOIwE1WdZw07U4cQ = (Function('return this'))();
if (!__cov_lgAhQ3cOIwE1WdZw07U4cQ.__coverage__) { __cov_lgAhQ3cOIwE1WdZw07U4cQ.__coverage__ = {}; }
__cov_lgAhQ3cOIwE1WdZw07U4cQ = __cov_lgAhQ3cOIwE1WdZw07U4cQ.__coverage__;
if (!(__cov_lgAhQ3cOIwE1WdZw07U4cQ['demo.js'])) {
__cov_lgAhQ3cOIwE1WdZw07U4cQ['demo.js'] = {"path":"demo.js","s":{"1":1,"2":0},"b":{},"f":{"1":0},"fnMap":{"1":{"name":"test","line":1,"loc":{"start":{"line":1,"column":0},"end":{"line":1,"column":16}}}},"statementMap":{"1":{"start":{"line":1,"column":0},"end":{"line":1,"column":37}},"2":{"start":{"line":1,"column":18},"end":{"line":1,"column":35}}},"branchMap":{}};
}
__cov_lgAhQ3cOIwE1WdZw07U4cQ = __cov_lgAhQ3cOIwE1WdZw07U4cQ['demo.js'];
function test(){__cov_lgAhQ3cOIwE1WdZw07U4cQ.f['1']++;__cov_lgAhQ3cOIwE1WdZw07U4cQ.s['2']++;return'Node.js';}

可以看出 instrument 后的代码每一行是否被执行都可以监测到,而 worker.js 是 master.js 通过调用 childProcess.fork, 在一个很干净的 Node.js 环境中执行, 执行的代码没有被 instrument,执行情况自然无法被 Istanbul 检测到。

解决方案

所以要获取 worker.js 的覆盖率,必须在执行 worker.js 代码之前先注入 Istanbul,那很自然的就会想到 hack 掉 childProcess.fork。

1
2
3
4
5
6
7
8
9
const childProcess = require('child_process');
const fork = childProcess.fork;
const path = require('path');

childProcess.fork = function(modulePath, args, options) {
const execPath = path.resolve(__dirname,'../node_modules/.bin/istanbul');
args = ['cover', '--report', 'none', '--print', 'none', '--include-pid',modulePath+'.js'];
return fork.apply(childProcess,[execPath, args, options]);
}

虽然这样处理后 master.js 和 worker.js 的覆盖率都有了,但由于它们是在不同的进程中产生的,Istanbul 不会自动将 2 个文件的覆盖率数据合并处理,所以我们可以先产生覆盖率数据,再根据覆盖率数据生成报告。由于涉及到多个进程,启动 Istanbul 时需要加上 include-pid 参数,这样每个进程生成的 coverage.json 文件就会带上进程 pid,否则 子进程的 coverage.json 会覆盖掉 主进程的。

运行 istanbul report --root ./coverage text-summary json lcov 便会自动对生成的 coverage-pid.json 文件合并处理,产生最终的覆盖率数据以及覆盖率报告。

最后将这 2 条命令集成到 Node.js 项目的 package.json 文件中。

1
2
3
4
5
"scripts": {
"test":"npm run cov && npm run report",
"report":"node --harmony node_modules/.bin/istanbul report --root ./coverage text-summary json lcov",
"cov": "node --harmony node_modules/.bin/istanbul cover --report none --print none --include-pid ./node_modules/mocha/bin/_mocha -- 'test/**/*.test.js'"
}

执行 npm test
主进程和子进程中的所有代码覆盖率都被统计到
可以看到主进程和子进程中的所有代码覆盖率都被统计到了。