聊聊Nodejs的单元测试,要怎么测试涉及子线程逻辑的代码?
最近在写自己的开源项目:VuePressAdmin,毕竟是把自己丑陋的代码暴露给全世界大佬了,总想着把代码质量能够提高一些,测试覆盖率就是重要的一环。看着90%+的代码覆盖率,是不是感觉这项目很稳定很靠谱!(误)
项目后端采用的是Egg框架,Egg为我们封装了一套方便的测试套件,主要是:
power-assert(增强测试结果的错误提示,用了就知道多爽)
Istanbul(计算代码覆盖率)
对我目前的代码规模和复杂度来说,大部分测试都是接口粒度的测试,通过模拟输入参数,检查输出参数是否正确,数据库中是否更新了正确的值。显然,这是涉及到数据库的测试。涉及到数据库,那我们就需要处理数据库初始化逻辑。
# 利用Egg灵活配置单测环境
通过指定app/config/config.unittest.js
的值,可以替换默认配置里在单元测试中需要更改的配置项,比如数据库访问信息:
exports.sequelize = {
dialect: 'sqlite',
storage: ':memory:',
};
我这里用的是sqlite,直接用了内存数据库,速度快,而且全部测试完成后自动释放,省心。
然后,因为每个测试文件中基本都需要初始化数据库,我们把数据库初始化过程抽提到单独的函数丢到test/util/Init.js
:
const { app } = require('egg-mock/bootstrap');
module.exports = async () => {
await app.model.sync({ force: true });
await app.model.User.create({
username: 'admin',
password: 'admin',
role: 'admin',
avatar: 'https://doge.shadowfish0.top/%E7%94%B7%E5%A4%B4%E5%83%8F.png',
});
await app.model.Config.create({ key: 'hasInit', value: 0 });
};
我这里就用到sequelize提供的sync({force:true})
直接强制初始化整个数据库,然后再插入一些初始化数据。
# Egg实现登录态mock
我们在测试需要登录态才能调用的接口时,需要首先mock这个登录态,如果你用的是session/cookie方案,可以很方便地用Egg提供的接口app.mockSession
实现,抽提函数得:
module.exports = {
mockAdminUserSession(app) {
app.mockSession({
userId: 1,
role: 'admin',
});
},
mockGeneralUsersSession(app) {
app.mockSession({
userId: 99,
role: 'general',
});
},
};
然后编写测试代码,比如测试PATCH /api/config
接口:
describe('test/app/controller/config.test.js', () => {
before(async () => {
await require('../../util/init')();
});
beforeEach(() => {
mockAdminUserSession(app);
});
afterEach(async () => {
await require('../../util/init')();
});
describe('PATCH /api/config', () => {
it('should success when change exist key', async () => {
const result = await app
.httpRequest()
.patch('/api/config')
.set('content-type', 'application/json')
.send({
hasInit: true,
});
assert(result.statusCode === 200);
const config = await app.model.Config.findOne({
where: {
key: 'hasInit',
},
});
assert(config.value === '1');
});
})
我们调用mockAdminUserSession
之后,通过app.httpRequest()
发送的请求就自动会带上session信息了。
可以看到,这里检查了返回值的HTTP状态值,也查了数据库,检查更改是不是正确执行了。
# 挑战:mock child_process.fork()
接口层面的测试还是比较简单的,开发中难到我的是涉及shell执行、子线程执行、文件操作为一体的逻辑测试。
VuePressAdmin提供一个接口,允许用户调用后,从github指定库clone代码下来,并对其执行npm install
。要测试这个接口,要怎么搞?如果还按接口粒度来测试,那测试中涉及的可能错误就太多了,也不容易包含到所有条件分支。因此,我决定逐层mock进行测试。
首先,编写接口粒度的测试,mock掉开启子线程执行shell的步骤。但是,怎么mock?
VuePressAdmin中实现shell在子线程中异步执行,是通过child_process
的fork
方法,这个方法允许我开启一个子线程去执行另一个JS文件:
const forked = fork(
`app/shell/${shellTaskFilename}.js`,
['--DO-RUN--', taskId, this.app.config.vuepress.path],
{
silent: true,
}
);
实际的shell命令基于ShellJS编写如下:
'use strict';
if (process.argv[2] === '--DO-RUN--') shell(process.argv[2], process.argv[3]);
function shell(taskId, vuepressPath) {
const shell = require('shelljs');
shell.config.fatal = true;
try {
shell.echo(
`准备执行shell命令“使用模板VuePressTemplate-recoX初始化VuePress”。taskId: ${taskId}, vuepressPath: ${vuepressPath}`
);
shell.config.verbose = true;
shell.exec(
`git clone --progress https://github.com/shadowfish07/VuePressTemplate-recoX.git --depth=1 ${vuepressPath}`
);
shell.cd(vuepressPath);
shell.exec('git remote rm origin ');
shell.exec('npm install --registry=https://registry.npmmirror.com');
} catch (error) {
process.send({ taskId, msg: error.toString() });
}
}
module.exports = shell;
由于我需要在主线程记录子线程执行的数据,需要监听子线程返回的数据:
forked.on('message',(msg)=>{...})
forked.stdout.on('end',(msg)=>{...})
forked.stderr.on('data',(msg)=>{...})
...
那我们显然不能直接把fork方法整个mock掉,不然会导致下面这些依赖fork产生的流的方法都出现问题,而且都无法正常测试它们的逻辑。我们想要的,仅仅是让fork()
不实际执行耗时、复杂的shell操作而已,其他监听逻辑是应当被正常测试到的。
这时候,我们需要构建一个假的”子线程“,利用EventEmitter
:
'use strict';
const EventEmitter = require('events');
class FakeChildProcess extends EventEmitter {
constructor() {
super();
// 记录所有回调
this.stdoutCallbacks = {
data: [],
end: [],
};
this.stderrCallbacks = {
data: [],
};
// EventEmitter只提供了on,没有提供stdout/stderr相关的逻辑,
// 我们自己来简单实现一个发布/订阅逻辑
this.stdout = {
on: (event, callback) => {
this.stdoutCallbacks[event].push(callback);
},
};
this.stderr = {
on: (event, callback) => {
this.stderrCallbacks[event].push(callback);
},
};
}
// 提供给测试代码调用的模拟接口,调用时模拟子线程向主线程发送不同类型的消息
emitMessage(message) {
this.emit('message', message);
}
emitStdout(event, data) {
this.stdoutCallbacks[event].forEach((callback) => {
callback(data);
});
}
emitStderr(event, data) {
this.stderrCallbacks[event].forEach((callback) => {
callback(data);
});
}
}
module.exports = FakeChildProcess;
接着,我们可以用sinon用这个类的实例mock掉fork
方法:
let fakeChildProcess;
beforeEach(() => {
fakeChildProcess = new FakeChildProcess();
sinon.mock(childProcess).expects('fork').returns(fakeChildProcess);
});
afterEach(() => {
sinon.restore();
});
然后mock接口调用,预期的行为应当是,接口成功返回,同时子线程也被开启,于是我们就可以模拟子线程向主线程发送一些数据,触发我们的监听逻辑:
const result = await app
.httpRequest()
.post('/api/config/init')
.set('content-type', 'application/json')
.send({
siteName,
gitPlatform,
vuePressTemplate,
});
// 模拟发送了'end'事件
fakeChildProcess.emitStdout('end','has end!');
已经感觉相当不错了,不过还不够。实际子线程执行时,应当是以流的形式向主线程传递数据,主线程也应当考虑流传输数据时可能需要的一些特殊处理。因此,我们还需要搞一个假的可读流:
'use strict';
const Readable = require('stream').Readable;
class FakeReadableStream extends Readable {
/**
* 用于测试的可读流
* @param texts {string[]} 测试的文本数组
*/
constructor(texts = []) {
super();
this.texts = texts;
}
_read() {
if (this.texts.length === 0) {
this.push(null);
return;
}
this.texts.forEach((text) => {
this.push(text + '\n');
});
this.push(null);
}
}
module.exports = FakeReadableStream;
通过继承Readable
并提供_read()
就可以造一个自己的可读流。简单来说,我们的可读流会从_read()
获取对外的输出数据,this.push(null)
就会发出end
事件。于是我们可以继续细化:
const fakeReadableStream = new FakeReadableStream([
'first line',
'second line',
]);
// 模拟子进程产生stdout
fakeReadableStream.on('data', (data) => {
fakeChildProcess.emitStdout('data', data);
});
fakeReadableStream.on('end', () => {
fakeChildProcess.emitStdout('end');
});
fakeChildProcess.emitStdout('data',data);
会执行两次,之后会执行fakeChildProcess.emitStdout('end')
这就是接口粒度测试的基本职责,接着,我们继续对刚才mock掉的东西进行测试。
# 对shell文件进行测试
最后,我们需要对shell进行测试。要测试的shell有两个涉及网络的操作,由于国内的特殊网络环境,有时候会因为网络问题错误地抛错,非常麻烦,而且比较耗时。
还有一个问题就是shell文件函数的导出。如果不导出函数,测试代码没办法引用并执行它。shell文件其实本来是这样:
'use strict';
const shell = require('shelljs');
shell.config.fatal = true;
const taskId = process.argv[2];
const vuepressPath = process.argv[3];
try {
shell.echo(
`准备执行shell命令“使用模板VuePressTemplate-recoX初始化VuePress”。taskId: ${taskId}`
`准备执行shell命令“使用模板VuePressTemplate-recoX初始化VuePress”。taskId: ${taskId}, vuepressPath: ${vuepressPath}`
);
shell.config.verbose = true;
shell.exec(
'git clone --progress https://github.com/shadowfish07/VuePressTemplate-recoX.git vuepress'
`git clone --progress https://github.com/shadowfish07/VuePressTemplate-recoX.git ${vuepressPath}`
);
shell.cd('vuepress');
shell.cd(vuepressPath);
shell.exec('git remote rm origin ');
shell.exec('npm install --registry=https://registry.npmmirror.com');
} catch (error) {
process.send({ taskId, msg: error.toString() });
}
然后把taskId
、vuepressPath
抽提成参数,把整个逻辑抽提成了函数导出:
'use strict';
shell(process.argv[2], process.argv[3]);
function shell(taskId, vuepressPath) {
const shell = require('shelljs');
shell.config.fatal = true;
try {
shell.echo(
`准备执行shell命令“使用模板VuePressTemplate-recoX初始化VuePress”。taskId: ${taskId}, vuepressPath: ${vuepressPath}`
);
shell.config.verbose = true;
shell.exec(
`git clone --progress https://github.com/shadowfish07/VuePressTemplate-recoX.git --depth=1 ${vuepressPath}`
);
shell.cd(vuepressPath);
shell.exec('git remote rm origin ');
shell.exec('npm install --registry=https://registry.npmmirror.com');
} catch (error) {
process.send({ taskId, msg: error.toString() });
}
}
module.exports = shell;
结果这样搞,每次require的时候都会直接执行shell()
,这肯定不对,因此我加了一个简单的条件判断:
if (process.argv[2] === '--DO-RUN--') shell(process.argv[2], process.argv[3]);
需要传递参数才会运行shell()
方法,否则就仅导出。对应的调用方法如下:
const forked = fork(
`app/shell/${shellTaskFilename}.js`,
['--DO-RUN--', taskId, this.app.config.vuepress.path],
{
silent: true,
}
);
不知道这样是不是最佳实践~
# 疑惑:关于proxyquire
这里还遇到了一些小坑,由于npm install
需要很长时间,而且由于产生了大量的细碎文件,删除的时候很占时间,因此我想着把最后一个shell.exec('npm install --registry=https://registry.npmmirror.com');
mock掉不执行。
尝试用sinon进行mock时,看到官方文档对mock其他库时的说明,尝试用proxyquiremock ShellJS,发现一个奇怪的现象:
(给sinon提了issue #2455,不过因为这是配合使用产生的问题,应该不是sinon本身的问题,因此直接被close掉了,官方建议去stackoverflow求助)
背景知识
sinon的withArgs
函数可以指定只在参数符合时才执行stub,详细可看:stubs文档
测试代码:
describe('test', () => {
const s = sinon
.stub()
.withArgs('123')
.callsFake(() => {
console.log('in');
});
const testShell = proxyquire('../../../app/shell/test', {
shelljs: {
echo: s,
},
});
testShell();
});
app/shell/test.js:
'use strict';
function shell() {
const shell = require('shelljs');
shell.config.verbose = true;
shell.config.fatal = true;
shell.echo('123');
shell.echo('456');
}
module.exports = shell;
输出:
in
in
这说明withArgs
没有产生预期效果,但是如果我不用proxyquire
:
const shelljs = require('shelljs');
describe('test', () => {
sinon
.stub(shelljs, 'echo')
.withArgs('123')
.callsFake(() => {
console.log('in');
});
shell.echo('123');
shell.echo('456');
});
输出:
in
withArgs
又正常工作了...
摸不着头脑,我就直接用另一种办法代替了withArgs()
:
const s = sinon.stub().callsFake((args) => {
if (args !== 'npm install --registry=https://registry.npmmirror.com') {
shell.exec(args);
}
});
callsFake()
可以正确获取到每一次打桩的入参,于是我直接通过这个入参的判断实现了我的逻辑:只跳过npm install
步骤。
不过,我又测试了一下,似乎,不使用proxyquire
,直接用最本能的方法:
describe('test', () => {
const shell = require('shelljs');
const testShell = require('../test');
sinon
.stub(shell, 'echo')
.withArgs('123')
.callsFake(() => {
console.log('in');
});
testShell();
});
这样是生效的...
那我到底为什么一开始要用proxyquire
?为啥sinon官方推荐使用这种方式mock外部依赖?
我阅读了这篇长文,作者指出,虽然const shell = require('shelljs');
这种方式可以生效,但这不是正确的做法,这只是一个只对导出的对象生效的hack方法。如果我们要stubfs
模块中的一个属性,这种方法就没用了:
const readFileSync = fs.readFileSync;
import {readFileSync} from 'fs';
sinon.stub(fs, 'readFileSync');
// ^ 无效的stub ^
作为对比,这种方式是有效的,但是不推荐:
var fs = require('fs');
sinon.stub(fs, 'readFileSync');
fs.readFileSync('/etc/pwd');
Is it clear what's happening here?
sinon.stub(x,y)
is justx[y]=Z
– it's an override, a hack applicable only to the exported objects. A way to change something from inside.This is a wrong way, a dead end. Sinon itself has a better way documented (listen, kid, to what adults are saying), but still many of you are using
sinon
to mock. Using sinon to mock dependencies is just not right. Just impossible, as long as it has no power upon module internals.
为啥不推荐呢?sinon.stub(fs, 'readFileSync');
直接把fs
这个对象整个改变了,如果你要测试的方法里多处使用了它,甚至单元测试引擎用了它,可能会出现意料之外的stub。
Additionally, doing something like
sinon.stub(fs, 'readFileSync');
is changingfs
for all module consumers, not only for the currenttest
or the currentsubjects under test
. For example that's killing avajs test runner ☠️.
不过,我寻思,对于我这种简单清晰的测试情况,直接这样stub应该没太大的问题,毕竟一轮测试中对被stub的对象的使用是清晰的,结束测试后也会清除所有stubs。
# 结语
以上就是目前我这几天写测试代码的收获。总的来说,写测试代码的时间比写业务代码要多的多:)可能是因为不太熟练,边学边写。同时,最后涉及到外部依赖mock的问题对我来说是一个比较大的挑战,感觉自己对CommonJS模块还是不太了解,后续有空需要深入学一下JS模块化知识。目前,我只是一个无情的export/import(require)机器:)