如何从零开发一个命令行自动化测试框架
近一个月在开发一个基于 NodeJs 的命令行工具,在完成阶段性的开发后,需要通过跑测试脚本来实现功能的验证。一般来讲,借助 jest 或者 mocha 来实现一些单元测试,除此之外,也可以使用一个端到端的测试,即命令行式的测试,这更符合实际使用场景。
本篇博文,我将从零开始构建一个命令行自动化测试工具,其实现的效果类似于Web端的 selenium 。
构建思路
如何构建这个自动化的测试框架?
首先需要明确几个问题:
- 是否可以支持启动命令行;
- 是否可以支持子命令的交互输入;
- 是否可以使用断言库对输出进行断言;
- 是否支持流程化测试;
- 是否可以支持自动化测试;
- 是否可以支持错误记录;
- 是否可以支持统计分析;
使用 Node 的内部模块可以解决上面的大部分疑问。
例如,使用 child_process
模块,可以启动一个子进程,通过 stdin
和 stdout
流,可以模拟与子进程的交互。
断言库可以使用 chai
,它提供 except 和 should 两种风格断言语法。
流程化测试指需要定义一个测试的工作流,测试模块可以按照这个工作流从前往后依次测试;而自动化测试,则需要测试模块能在流程化测试过程中,完成与子进程的输入输出交互,以便减少人工干预。
错误记录和统计分析是为了辅助生成测试报告,类似 jest 的覆盖率报告一样。但与 jest 覆盖率报告不同的是,命令行的测试是端到端的,是非侵入式的,所以不需要覆盖率的数据,反而需要一个端到端的测试报告。
什么是端到端的测试报告?我暂时总结了简要的两点:
- 测试时执行的命令符合预期的通过率;
- 测试命令断言失败时被捕获到的位置;
以上是一个实现的初步思路,基于上述思路,我实现了一个轻量的自动化测试框架,其中一个测试步骤如图:
当某项测试断言失败时,希望错误可以被捕获到:
实现方案
实现方案依据上述思路,大致可以分为以下两个模块的开发:
- 命令行执行器:负责创建子进程,并与子进程进行交互;
- 测试工作流:负责创建和调度命令执行器;
除了上述两个主要模块,我还补充了一个 Section 模块,用来定义测试单元。
下面依次介绍。
命令执行器
根据软件开发低耦合的思想,命令执行器主要负责命令的创建和交互;也就是这个模块只负责与自己所创建的命令进行交互。
考虑到后续该模块需要被测试流调度,那么作为一个 class 类来定义会是一个比较合理的方案。
定义如下:
function Cmder(cmd = '', args = [], options = {}) {
this.cmd = cmd;
this.args = args
this.options = options;
this.execInstance = null;
this.chunkOut = '';
this.chunkIn = '';
this.failedNotes = [];
this.showStdout = true;
this._prelk = `PS ${process.cwd()}> `;
}
这个模块被定义为 Cmder ,它是一个执行器。
cmd
、args
和 opts
分别是这个命令的执行参数,它们将通过 child_process
的 spaw 来创建。
execInstance
是一个示例,当命令被创建后,将会被赋值到该变量中,使得示例生效。
chunkOut
和 chunkIn
用来记录当前交互阶段的控制台输入和输出。
begin
以上万事俱备,下来从 begin 方法开始执行命令:
/**
* 开始执行命令
*
* @returns
*/
Cmder.prototype.begin = function () {
if (!this.execInstance) {
const opts = {
shell: true,
...this.options,
};
this.execInstance = spawn(this.cmd, this.args, opts);
}
const stdcmdOut = this._prelk + helper.cmdStringify(this.cmd, this.args).bold() + '\n';
processOut(this.showStdout, stdcmdOut.cyan());
this.execInstance.stdout.on('data', chunk => {
this.chunkOut += chunk;
processOut(this.showStdout, chunk);
});
this.execInstance.stderr.on('data', errdata => {
processOut(this.showStdout, errdata.toString());
this.execInstance.stderr.on('end', () => {
process.exit(0);
});
});
return this;
};
begin
方法在执行器实例创建完成后调用,如同其名称一样,一个命令的测试也应该从 begin 开始。
在这个方法中,execInstance
实例的 stdout
被重定向到当前 process 的 stdout
,以便可以将子进程命令行的输入显示在当前控制台中。
同时,也需要记录实例的输出,以便为断言提供参数参考。
begin
方法返回的是当前实例,以便后面的方法可以实现链式调用。
wait
wait
方法的存在时必须的,因为你不能保证当前命令执行以后会立刻得到返回结果,所以适当的等待是必须的。
/**
* 延时等待
*
* @param {number} ms 等待时间,毫秒
* @returns
*/
Cmder.prototype.wait = async function (ms) {
await helper.delay(ms);
return this;
};
wait
方法返回一个 Promise ,所以后续的方法将不能链式调用,除非先使用 await
。
waitForData
与 wait
类似,waitForData
方法会停下来等待,不过与 wait
不同的是,waitForData
的等待会更具有目的性,看下面的代码:
/**
* 等待直到有数据回显
*
* @param {number} timeout 超时时间,单位:毫秒
*/
Cmder.prototype.waitForData = async function (timeout = 3000) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (!this.chunkOut) {
await helper.delay();
continue;
}
break;
}
return this;
};
可以看到,在这个方法中,一旦控制台有数据返回,等待就会停止。在某些回显数据不多的情况下,waitForData
方法比 wait
方法更适用。
assert
assert
方法是一个主角,也非常重要,它的目的是将当前的输出作为参数,交给用户去断言,不过实现却异常简单:
/**
* 对 stdout 的输出结果断言
*
* @param {function} fn 测试函数
* @returns
*/
Cmder.prototype.assert = function (fn) {
if (!helper.isFunction(fn)) {
throw `Parameter 'fn' must be a function`.red();
}
try {
fn(this.chunkOut);
}
catch (err) {
if (this.showStdout) {
printer.red(`Assertion detected an error: `, err.message.trim());
}
// 记录错误
this.failedNotes.push({
cmd: this.cmd,
args: this.args,
stdout: this.chunkOut,
stdin: this.chunkIn,
failed: err.message.trim()
});
};
return this;
};
assert
方法要求其参数必须是一个 Function
类型,以便可以把 chunkOut
以回调参数的形式传递出去。
在这个方法内,会对外部断言进行异常捕获,如果捕获到异常,则表示断言失败,即当前命令的输出不符合预期,那么这个错误会被记录,以便后续统计分析。
keep
这个方法的主要是为了不间断执行。一般而言,一个 assert
断言表示对输出的一次判断,keep
在语义上则表示保持当前的状态,继续执行下去。
keep
方法内部只做一件事,就是清除上一次断言的输入输出,以便下次断言不会被干扰。
/**
* 保持当前状态,in/out 清零
*/
Cmder.prototype.keep = function () {
this.chunkOut = '';
this.chunkIn = '';
return this;
};
writeIn
这是一个很重要的方法。这个方法提供向子进程交互输入的能力,原理则是通过向 execInstance
实例的 stdin 流写入数据。
writeIn
默认是写入后回车的,所以不需要单独写换行。
/**
* 向 child 进程的 stdin 写入数据
*
* @param {string} data 写入的数据
* @returns
*/
Cmder.prototype.writeIn = function (data) {
this.chunkIn = data;
const input = `${this.chunkIn}\n`;
if (this.execInstance.stdin.writable) {
processOut(this.showStdout, input);
this.execInstance.stdin.write(input);
}
return this;
};
可以看到,当向实例写入成功后,其写入内容也将被打印在当前控制台中,以便实现真实控制台输入的效果。
writeKey
与 writeIn
不同,writeKey
表示向子进程写入一个键,也就是模拟键盘的单次写入。当子进程的是一个交互式命令的时候,这便非常有用,可见下方的示例:
上面的命令需要通过键盘的方向键来交互式选择,这时使用 writeIn
方法就不合适了,所以需要 writeKey
方法来模拟这种交互。
writeKey
的实现:
/**
* 模拟键盘按键输入
*
* @param {number} key 键盘输入
*/
Cmder.prototype.writeKey = function (key) {
stdinWrite(this.execInstance.stdin, Buffer.from(key));
return this;
};
stdinWrite
是 execInstance
实例 stdin 写入流的工具方法:
/**
* 向给定的 stdin 流写入数据
*
* @param {object} stdobj stdin对象
* @param {string|buffer} data 写入的数据
*/
function stdinWrite(stdobj, data) {
if (stdobj.writable) {
stdobj.write(data);
}
}
enter
这个方法主要是模拟用户输入回车键,一般是与 writeKey
方法配合使用的。
其实现也非常简单:
/**
* 模拟用户输入回车键
*
* @returns
*/
Cmder.prototype.enter = function () {
stdinWrite(this.execInstance.stdin, '\n');
return this;
}
close
当一个命令行进程需要被终止时,调用此方法。
/**
* 关闭当前子进程
*/
Cmder.prototype.close = function () {
try {
if (this.execInstance !== null) {
this.execInstance.kill();
}
}
catch (err) {
printer.red(`Child process termination failed:`, err.message.trim());
}
}
使用示例
以上定义了一个执行器需要具备的最少方法,那么使用链式调用来测试一个命令是否符合预期输出应该怎么使用呢?下面是一个使用示例:
const WaitTime = 1000;
const cmdline = new Cmder('test-cli', ['-i', 'init']);
(await cmdline.begin().wait(WaitTime)).assert(
out => {
expect(out).to.include('pass').include('sass').include('iaas');
}
);
//
(await cmdline.keep().writeKey(KeyBoard.Down).enter().wait(WaitTime)).assert(
out => {
expect(out).to.include('sass');
}
);
//
(await cmdline.keep().writeKey(KeyBoard.Down).enter().wait(WaitTime)).assert(
out => {
expect(out).to.include(`{"plattype":"sass","vmCounts":"200"}`);
}
);
借助工作流批量测试的输出示例:
TestFlow 工作流
虽然上面的 Cmder
命令执行器提供了命令执行和交互能力,但想要实现流程化测试还是不够的,下面来介绍 TestFlow 的实现。
TestFlow
也是一个类,需要被实例化,每一个 TestFlow
示例都可以拥有多个命令执行器。
/**
* 测试工作流
*
* @param {object} opts 测试属性
*/
function TestFlow(opts = { stdout: true }) {
this.opts = opts;
this.flow = [];
this.results = {
failed: []
};
this._print = function (type, ...msgs) {
if (!this.opts.stdout) return;
printer[type](...msgs);
}
}
test
test
方法用来定义一个测试项,这是一个子项,以便表示测试某一项指定的功能。
/**
* 塞入测试
*
* @param {string} desc 测试项描述
* @param {object} cmdbody 测试命令
* @param {function} callback 测试方法
*/
TestFlow.prototype.test = function (desc = '', cmdbody, callback) {
if (!desc) {
throw `The testing description cannot be empty`;
}
if (!helper.isFunction(callback)) {
throw `Parameter 'callback' must be a function`;
}
this.flow.push({ desc, cmdbody, callback });
}
需要知道的是,test
方法所定义的测试项不会立刻执行,而是被塞入到测试队列中,当 TestFlow
工作流启动的时候,才会从队列中取出测试项依次执行测试。
start
start
方法用于启动测试。
/**
* 启动命令测试
*/
TestFlow.prototype.start = async function () {
for (let i = 0, len = this.flow.length; i < len; i++) {
const { desc, cmdbody, callback } = this.flow[i];
const cmder = new Cmder(cmdbody.cmd, cmdbody.args, cmdbody.opts);
cmder.display(this.opts.stdout);
this._print('yellow', emoji.get('label'), `[Test.{i + 1}]`, `{desc}`.bold());
this._print('cyan', helper.strRepeat('-', 40));
await callback(cmder).catch(err => {
printer.red(err);
process.exit(0);
});
cmder.close();
const errorNotes = cmder.getFailed();
if (errorNotes.length) {
this.results.failed.push({
desc,
notes: errorNotes
});
this._print('yellow', emoji.get('unamused'), `Failed`);
}
else {
this._print('green', emoji.get('sunglasses'), `Done`);
}
this._print('cyan', `${helper.strRepeat('-', 40)}\n`);
}
}
start
方法是被 async
修饰的,因为需要保证其内部的 callback 方法可以以同步的方式执行。
analyse
analyse
提供了简单的分析能力。
/**
* 分析并打印测试结果
*/
TestFlow.prototype.analyse = function () {
const passCount = this.flow.length - this.results.failed.length;
if (!this.results.failed.length) {
printer.green(emoji.get('sparkles'), `{passCount} /{this.flow.length} Passed,`, `Pass rate is 100%`);
return;
}
const passRate = (1 - this.results.failed.length / this.flow.length) * 100;
printer.yellow(emoji.get('hankey'), `{passCount} /{this.flow.length} Passed,`, `Pass rate is {passRate.toFixed(2)}%`);
printer.yellow(`{helper.strRepeat('=', 40)}`);
// 打印错误记录
this.results.failed.forEach((el, secIdx) => {
const errNotes = el.notes;
if (!errNotes.length) {
return;
}
errNotes.forEach((note, itIdx) => {
printer.ln();
printer.yellow(` Test.{secIdx + 1} Assert.{itIdx + 1} `.black().bgYellow(), el.desc);
printer.cyan('Command:', helper.cmdStringify(note.cmd, note.args));
printer.red(note.failed);
});
});
}
上文有其执行结果的示例:
analyse
会简单的分析通过率,如果遇到不通过的断言,会先将其记录,在工作流执行完成以后再统一打印出来。
Section
section
是章节,表示一个测试单元。先看 section
的实现:
/**
* 定义测试章节
*
* @param {object} opts section属性
* @param {function} fn 回调函数
* @returns
*/
function section(opts = {}, fn) {
return new Promise(async resolve => {
if (!opts || !opts.title) {
printer.red(`Section test must have a title`);
resolve();
return;
}
printer.ln();
printer.cyan(emoji.get('coffee'), `#### Section: ${opts.title}\n`.bold());
const testflow = new TestFlow({ stdout: opts.stdout });
await fn(testflow);
// 开始测试
await testflow.start();
// 分析结果
if (opts.analysis) {
testflow.analyse();
}
printer.ln();
printer.cyan(emoji.get('tomato'), `All complete`);
resolve();
});
}
非常清晰了。
section
内部会创建一个 TestFlow
,然后在其回调中执行 TestFlow 的填充函数,然后依次启动测试,打印执行结果。
使用示例
上面介绍了整个框架的实现逻辑,下面看一下如果使用。
首先定义一个简单的 nodejs 脚本:
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question("What`s your name ? ", function (answer) {
console.log("name is " + answer);
rl.close();
});
rl.on("close", function () {
process.exit(0);
});
这个脚本在执行时会等待用户输入一个 answer
,然后把这个 answer
打印出来。
假设我们要测试上述脚本,那么自动化测试的代码应该是这样的:
const sectionInfo = {
title: '[Testing] examples/cmdline/ask.js',
stdout: true,
analysis: true,
};
const cmdbody = cmdline('node', ['examples/cmdline/ask.js']);
section(sectionInfo, function (testflow) {
testflow.test('It should return Sean if input Sean', cmdbody, async cmdline => {
const inpName = 'Sean';
//
(await cmdline.begin().waitForData()).assert(
out => {
expect(out).to.include('What`s your name ?');
}
);
//
(await cmdline.keep().writeIn(inpName).waitForData()).assert(
out => {
expect(out).to.include(`name is ${inpName}`);
}
);
});
});
测试结果:
总结
本次开发实现的命令行自动化测试框架基本实现了所需要的功能,特性包括:
- 模块化,每一个模块都可以单独拿出来使用;
- 断言外置,用户可以选择任何自己喜欢的断言库;
- 轻量级,非常轻,外部依赖非常少。
另外,为了美观我也专门使用了 emoji
,可以看到截图中使用了比较有意思的 emoji
,但这不是全部,还有一些彩蛋隐藏在代码中。
说一下不足。虽然框架实现了,但是并未对功能进行深挖,主要还是由于当前的使用场景,后续如果出现其他使用场景,我将继续改善框架的功能。
本项目的 Git 仓:Lesst – 一个轻量的命令行自动化测试框架 。