本文着重讲一些前端测试的进阶技巧,以及通过大量 demo 代码展示如何对较为复杂的命令行工具进行集成测试
之前发过一篇同名文章,但是认为做工太粗糙,本文为重新思考,排版后发布的版本
为什么需要测试用例
优点
1. 代码质量保障 & 增加信任
放眼整个 github,一个成熟的工具库必须具备
- 完善的测试用例(jest/mocha...)
- 友好的文档系统(官网/demo)
- 类型声明文件 d.ts
- 持续集成环境(git action/circleci...)
没有上述这些要素,用户在使用时候可能会遇到各种 BUG,很显然他们肯定不愿意看到这种情况发生,最终导致用户很难接受你的产品
测试用例最重要的一点就是提升代码质量,使得他人有信心使用你开发的工具(由信心产生的信任关系,对软件工程师来说是至关重要的)
此外,测试用例可以直接视为现成的调试环境,在编写测试用例时,会逐渐弥补在需求分析环节未曾想到的 case
2. 重构的保障
代码需要大版本的更新时,拥有完善的测试用例能够在重构时起到至关重要的作用
选择黑盒测试的测试用例设计方法,只关心输入和输出,无需关心测试用例内部做了什么
对重构而言,如果最终向用户暴露的 api 没有改变,那么几乎也不需要任何改动,直接复用之前的测试用例
因此如果代码具有完善的测试用例,就能很大程度增强重构的信心,无需关心由于代码改动导致原有功能无法使用的问题
3. 增加代码阅读性
对于想要了解项目源码的开发者,阅读测试用例是一个高效的办法
测试用例能非常直观的展现出工具的功能,以及各种 case 情况下的行为
换一句话说,测试用例是给软件开发者看的“文档”
// Vuejs test cases
// https://github.com/vuejs/core/blob/main/packages/reactivity/__tests__/computed.spec.ts#L24
it('should compute lazily', () => {
const value = reactive<{ foo?: number }>({})
const getter = jest.fn(() => value.foo)
const cValue = computed(getter)
// lazy
expect(getter).not.toHaveBeenCalled()
expect(cValue.value).toBe(undefined)
expect(getter).toHaveBeenCalledTimes(1)
// should not compute again
cValue.value
expect(getter).toHaveBeenCalledTimes(1)
// should not compute until needed
value.foo = 1
expect(getter).toHaveBeenCalledTimes(1)
// now it should compute
expect(cValue.value).toBe(1)
expect(getter).toHaveBeenCalledTimes(2)
// should not compute again
cValue.value
expect(getter).toHaveBeenCalledTimes(2)
})
缺点
凡事都有两面,说了优势,接着聊下缺点,帮助大家更好的判断项目是否应该编写测试用例
没有时间
开发者普遍不写测试用例,最常见的一点就是太过于繁琐
我写测试用例的时间,代码早就写完了,你说测试?交给 QA 吧,那是他们的职责
这种情况其实完全可以理解,平时开发时间都来不及,怎么可能腾出时间写测试用例?
所以需要根据项目的类型,判断编写测试用例的价值
对于 UI 修改频繁、生命周期短的项目,例如官网,活动页,个人不推荐编写测试用例
因为它们普遍具有时效性,页面结构的频繁变动会直接导致测试用例的频繁变动,另外这类项目普遍配备 QA 资源,一定程度上能够保证项目质量(自测还是必要的)
反之,对于工具库、组件库,由于功能变化少,并且一般没有 QA 资源,如果已经存在一定的用户规模,推荐补充测试用例
不会写
编写测试用例需要学习测试框架的语法,因此需要一定学习成本(没有时间学也是导致不会写的诱因)
好在市面上主流的测试框架大同小异,整体思想趋于一致,同时本身 breaking change 也不多。普通开发者可以一周上手,二周进阶。学习之后能够在任何前端项目中使用(learning once, write everywhere)
与 Vue 和 React 的学习成本相比,再结合前面的优势来看,是不是一个非常划算的交易呢?
说完优缺点,接着分享一下对一个复杂命令行工具编写测试用例的一些经验
对命令行工具进行集成测试
我采取集成测试的方式进行测试,集成测试与单元测试的区别在于,前者更广,后者粒度更细,集成测试也可以由多个单元测试组合而成
既然是命令行工具,首先需要思考的是,模拟出用户使用命令行的行为
命令行运行
一开始我对测试用例的理解是,尽可能模拟用户原始的输入。因此我的思路是直接在测试用例中运行命令行工具
// cli.js
const { program } = require("commander")
program.command('custom').action(() => {
// do something for test
})
program.parse(argv)
// index.spec.js
const execa = require("execa")
const cli = (argv = "") => new Promise((resolve, reject) => {
const subprocess = execa.command(`node ./cli.js ${argv}`)
subprocess.stdout.pipe(process.stdout)
subprocess.stderr.pipe(process.stderr)
Promise.resolve(subprocess).then(resolve)
})
test('main', async() => {
await cli(`custom`)
expect('something')
})
在子进程运行命令行工具,然后将子进程的输出打印到父进程中,最后判断打印结果是否符合预期
优点:更加符合用户使用命令行的方式
缺点:需要调试测试用例时,由于依赖子进程,导致 debugger 开启时性能极差,测试用例运行经常超时,甚至吞掉错误或者输出一些与测试用例本身无关的系统报错
函数运行
上个方案卡顿过于严重,被迫思考其他的解决方案
由于我采用 commander 实现 nodejs 的命令行工具,所以测试用例本质上只要让命令背后的 action 执行就可以了
commander 文档中提到,调用 parse 方法传入命令行参数,就可以触发 aciton 的回调
因此我们暴露一个名为 bootstrap 的启动函数,接收命令行参数并传入 parse 中
// cli.js
const { program } = require("commander")
const bootstrap = (argv = process.argv) => {
program.command('custom').action(() => {
// do something for test
})
program.parse(argv)
return program
}
export { bootstrap }
// index.spec.js
const { bootstrap } = require('./cli.js')
test('main', () => {
const program = bootstrap(['node', './cli.js', 'custom'])
expect(program.commands.map(i => i._name)).toEqual(['custom']) // pass
expect('something')
})
test('main2', () => {
const program = bootstrap(['node', './cli.js', 'custom'])
expect(program.commands.map(i => i._name)).toEqual(['custom']) // fail, received ['custom', 'custom']
expect('something')
})
优点:不依赖子进程,直接在当前进程运行测试用例,debugger 也没有问题,成功解决了性能瓶颈
缺点:代码存在副作用,所有测试用例共享同一个 program 实例,测试用例单独使用没有问题,但多个测试用例之间可能会互相干扰
工厂函数运行
吸取了上次的教训,直接暴露一个生成命令行工具的工厂函数
// cli.js
const { Command } = require("commander")
const createProgram = () => {
const program = new Command()
program.command('custom').action(() => {
// do something for test
})
return program
}
export { createProgram }
// index.spec.js
const { createProgram } = require('./cli.js')
test('main', () => {
const program = createProgram()
program.parse(['node', './cli.js', 'custom'])
expect(program.commands.map(i => i._name)).toEqual(['custom']) // pass
expect('something')
}
test('main2', () => {
const program = createProgram()
program.parse(['node', './cli.js', 'custom'])
expect(program.commands.map(i => i._name)).toEqual(['custom']) // pass
expect('something')
}
这样每次运行测试用例,创建的都是独立的 program ,使得测试用例之间彼此隔离
解决了命令行工具的初始化后,接着来看几个针对命令行的特殊测试用例 case
测试帮助命令
当需要测试帮助命令( --help、-h ),或者对命令行参数的验证功能做测试时,由于 commander 会将提示文案作为错误日志输出到进程,并调用 process.exit 退出当前进程
这会使得测试用例提前退出,因此需要重写这个行为
commander 内部提供了重写的函数 exitOverride,使用后会抛出一个 js 错误替代原先进程的退出
// https://github.com/tj/commander.js/blob/master/tests/command.help.test.js
test('when specify --help then exit', () => {
// Optional. Suppress normal output to keep test output clean.
const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => { });
const program = new commander.Command();
program.exitOverride();
expect(() => {
program.parse(['node', 'test', '--help']);
}).toThrow('(outputHelp)');
writeSpy.mockClear();
});
重写退出的行为后,若要验证帮助命令的文案,还需要使用 commander 提供的 configureOutput
接着修改测试用例
// index.spec.js
const { createProgram } = require('./cli.js')
test('help option', () => {
const program = createProgram()
// overwrite exit
program.exitOverride().configureOutput({
writeOut(str) {
// / assert the message of the help command
expect(str).toEqual(`Usage: index [options] [command]
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
custom
`)
},
})
expect(() => {
program.parse(['node', './cli.js', '-h'])
}).toThrow('(outputHelp)'); // assert the behavior of the help command
})
测试异步用例
命令行工具可能存在异步的回调,测试用例也需要支持异步的 case
好在 Jest 对异步测试用例开箱即用,还是以帮助命令为例
// index.spec.js
+ test('help option', async () => {
const program = createProgram()
program.exitOverride().configureOutput({
writeOut(str) {
expect(str).toEqual(`Usage: index [options] [command]
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
custom
`)
},
})
+ try {
+ // use 'parseAsync' for async callback hook
+ await program.parseAsync(['node', './cli.js', '-h'])
+ } catch (e) {
+ // According to code
+ // distinguish whether it is an error of the help command itself or other code errors
+ if (e.code) {
+ expect(e.code).toBe('commander.helpDisplayed')
+ } else {
+ throw e
+ }
+ }
})
对于异步测试用例,推荐设置超时时间,防止因为代码编写错误,导致一直等待测试结果
Jest 默认超时时间为 5000ms,也可以通过配置文件/测试用例重写
jest.setTimeout(10000); // 10 second
test('main', async () => {
await sleep(2000) // fail, timeout
expect('something')
});
// jest.config.js
module.exports = {
testTimeout: 10000
}
除了超时时间,添加断言的次数也是保证异步测试用例成功的一点
expect.assertions 可以指定单个测试用例触发断言的次数,这对测试异常捕获的场景很有帮助
超时和预期次数不符都会让测试用例失败
// index.spec.js
+ jest.setTimeout(10000); // set timeout
test('help option', async () => {
+ // Expect the assertion to fire twice,
+ // So an incorrect number of triggers/timeouts will cause the test case to fail
+ expect.assertions(2)
const program = createProgram()
program.exitOverride().configureOutput({
writeOut(str) {
+ // first assertion
expect(str).toEqual(`Usage: index [options] [command]
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
custom
`)
},
})
try {
// use 'parseAsync' for async callback hook
await program.parseAsync(['node', './cli.js', '-h'])
} catch (e) {
if (e.code) {
+ // second assertion
expect(e.code).toBe('commander.helpDisplayed')
} else {
throw e
}
}
})
测试运行中的变量
普通的测试场景里,验证变量的值可以借助运行导出函数的返回值来验证
// index.js
exports.drinkAll = function drinkAll(callback, flavour) {
if (flavour !== 'octopus') {
callback(flavour);
}
}
// index.spec.js
const { drinkAll } = require("./index.js")
describe('drinkAll', () => {
test('drinks something lemon-flavoured', () => {
const drink = jest.fn();
drinkAll(drink, 'lemon');
expect(drink).toHaveBeenCalled();
});
test('does not drink something octopus-flavoured', () => {
const drink = jest.fn();
drinkAll(drink, 'octopus');
expect(drink).not.toHaveBeenCalled();
});
});
但命令行工具可能会依赖上下文的信息(arguments, options),不太适合将内部的各个函数拆解并导出,那么如何测试运行期间变量的值?
我使用了 debug + jest.doMock + toHaveBeenCalled
// cli.js
const debug = require("debug")('cli')
const { Command } = require("commander");
const createProgram = () => {
const program = new Command()
program.command('custom <arg1>').action((arg1) => {
debug(arg1)
// ...
})
return program
}
export { createProgram }
// index.spec.js
test('main', () => {
const f = jest.fn();
// mock debug module
jest.doMock("debug", () => () => f);
// require createProgram after debug have been mocked
const createProgram = require("./index");
const program = createProgram();
program.parse(["node", "cli.js", "custom", "foo"]);
expect(f).toHaveBeenCalledWith("foo"); // pass
}
-
使用
debug模块打印需要被验证的参数(具有一定代码侵入性,但 debug 模块也可以用于日志的记录) -
测试用例运行时通过
jest.doMock劫持 debug 模块,使得 debug 执行时返回 jest.fn -
用
toHaveBeenCalled对 jest.fn 进行入参的验证
为什么使用
jest.doMock而不是jest.mock?
jest.mock在运行期间会声明提升,导致无法使用外部的变量 f github.com/facebook/je…
模拟命令行交互
带有命令行交互的命令行工具是非常常见的场景
受 vue-cli 的启发,在测试用例中,模拟用户输入变得非常简单,且没有任何的代码侵入性
- 创建 __mock__/inquirer.js,劫持并代理 prompt 模块,在重新实现的 prompt 函数中添加 Jest 的断言语句
// __mocks__/inquirer.js
// inspired by vue-cli
// https://gist.github.com/yyx990803/f61f347b6892078c40a9e8e77b9bd984
let pendingAssertions
// create data
exports.expectPrompts = assertions => {
pendingAssertions = assertions
}
exports.prompt = prompts => {
// throw an error if there is no data
if (!pendingAssertions) {
throw new Error(`inquirer was mocked and used without pending assertions: ${prompts}`)
}
const answers = {}
let skipped = 0
prompts.forEach((prompt, i) => {
if (prompt.when && !prompt.when(answers)) {
skipped++
return
}
const setValue = val => {
if (prompt.validate) {
const res = prompt.validate(val)
if (res !== true) {
throw new Error(`validation failed for prompt: ${prompt}`)
}
}
answers[prompt.name] = prompt.filter
? prompt.filter(val)
: val
}
const a = pendingAssertions[i - skipped]
if (a.message) {
const message = typeof prompt.message === 'function'
? prompt.message(answers)
: prompt.message
// consume data
expect(message).toContain(a.message)
}
if (a.choices) {
// consume data
expect(prompt.choices.length).toBe(a.choices.length)
a.choices.forEach((c, i) => {
const expected = a.choices[i]
if (expected) {
expect(prompt.choices[i].name).toContain(expected)
}
})
}
if (a.input != null) {
// consume data
expect(prompt.type).toBe('input')
setValue(a.input)
}
})
// consume data
expect(prompts.length).toBe(pendingAssertions.length + skipped)
pendingAssertions = null
return Promise.resolve(answers)
}
- 在运行测试用例前,通过 expectPrompts 模拟用户遇到的问题以及答案(创建断言的条件)
// If we want to mock Node's core modules (e.g.: fs or path),
// then explicitly calling e.g. jest.mock('path') is required
// else it is not required
// jest.mock('inquirer')
const { expectPrompts } = require('inquirer')
const { createProgram } = require('./cli.js')
test('migrate command', () => {
// create user input data
expectPrompts([
{
message: 'select project',
choices: [ 'project1', 'project2', 'project3', 'sub-root' ],
choose: 1,
},
])
const program = createProgram()
// when inquirer.prompt is triggerd
// it will consumes user input data from __mocks__/inquirer.js
program.parse(['node', './cli.js', 'custom'])
})
- 当代码运行 inquirer.prompt 时,代理并跳转到 __mock__/inquirer.js 自定义的 prompt,prompt 会根据先前
expectPrompts创建好的问题和答案依次进行匹配(消费数据) - 最后代理的 prompt 会返回与真实 prompt 相同的 answers 对象,使最终的行为趋于一致
小结
确保编写测试用例时,彼此互相独立,互不影响,没有副作用,拥有幂等性
可以从以下角度出发
-
每次运行测试用例,创建新的 commander 实例
-
允许单个测试用例使用单例模式,不允许多个测试用例使用同一个单例
-
文件系统隔离
其他测试技巧
模拟工作目录
jest.spyOn(process, 'cwd').mockImplementation(() => mockPath))
jest.spyOn 跟踪对 process.cwd 的调用, jest.mockImplementation 重写 process.cwd 的行为,最终达到模拟工作目录的目的
// index.spec.js
const { createProgram } = require('./cli.js')
test('mock current work directory', () => {
// when process.cwd() have been called
// return mockPath
const cwdSpy = jest.spyOn(process, 'cwd').mockImplementation(() => mockPath))
const program = createProgram()
program.parse(['node', './cli.js', 'custom'])
expect(cwdSpy).toHaveBeenCalledTimes(2) // assets process.cwd() to have been called twice
});
不依赖 Jest api 的话,也可以将模拟工作目录作为 createProgram 工厂函数的参数传入
// cli.js
const { Command } = require("commander");
const createProgram = (cwd = process.cwd()) => {
const program = new Command()
// use cwd instead of process.cwd
program.command('custom').action(() => console.log(cwd))
}
// index.spec.js
const { createProgram } = require('./cli.js')
test('mock current work directory', () => {
const program = createProgram(mockPath)
program.parse(['node', './cli.js', 'custom'])
expect('something')
});
模拟文件系统
文件的读写也是含有副作用的操作,由于由于命令行工具可能涉及到文件修改,所以不能保证每次运行测试用例都是一个干净的环境。为了保证测试用例互相独立,需要模拟一个真实的文件系统。
这里选择 memory-fs,它可以将真实文件系统的操作,转为在内存中操作虚拟文件
项目根目录新建 __mocks__ 文件夹
在 __mocks__ 文件夹下添加 fs.js,导出 memfs 模块
// __mocks__/fs.js
const { fs } = require('memfs')
module.exports = fs
Jest 默认将 __mocks__ 文件夹下的文件视为可以被模拟的模块
在测试用例中运行 jest.mock(fs),就可以劫持 fs 模块并代理到 __mocks__/fs.js 下
通过 将 fs 代理为 memfs,解决了文件系统副作用的问题
静默错误日志
// index.spec.js
const { createProgram } = require('./cli.js')
test('mock current work directory', () => {
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation()
const program = createProgram()
program.parse(['node', './cli.js', 'custom'])
// silence when error happened
expect(stderrSpy).toBeCalledWith('something error')
});
jest.mockImplementation 不传任何参数,会静默处理 mock 后的函数
实现运行测试期间不输出错误日志的功能,使得测试用例运行时更干净
不输出错误日志并非吞掉错误,依然可以用 try/catch 验证错误的场景
// index.spec.js
const { createProgram } = require('./cli.js')
test('unknown command', () => {
// overwrite standard error
jest.spyOn(process.stderr, 'write').mockImplementation()
try {
const program = createProgram()
program.parse(['node', './cli.js', 'unknown'])
} catch (e) {
expect(e.message).toEqual("error: unknown command 'unknown'")
}
})
也可以用前面提到的 program.configureOutput 重写错误日志
// index.spec.js
const { createProgram } = require('./cli.js')
test('unknown command', () => {
jest.spyOn(process.stderr, 'write').mockImplementation()
try {
const program = createProgram()
// overwrite exit
program.exitOverride().configureOutput({
// overwrite commander error
writeErr: str => {},
})
program.parse(['node', './cli.js', 'unknown'])
} catch (e) {
expect(e.message).toEqual("error: unknown command 'unknown'")
}
})
使用前:
使用后:
匹配部分字段
如果测试的对象里有很多属性,无法一一对其枚举验证,该怎么解决?
Jest 内置了部分匹配的能力,使用 expect.objectContaining 可以创建一个部分匹配的对象
Jest 只会匹配该对象中声明的属性,对于未声明的属性,统一视为通过
另外,在expect.objectContaining创建的对象中,允许使用 expect.any 验证对象属性的类型,适用于测试属性的类型固定,但不清楚具体属性值的场景
与debug结合对运行时的变量进行测试
// cli.js
const debug = require("debug")('cli')
const { Command } = require("commander");
const createProgram = () => {
const program = new Command()
program.command('custom').action((arg1) => {
const obj = {
a: arg1,
b: Date.now(),
c: "l don't care",
d: "l don't care",
// ...
}
debug(obj)
// ...
})
return program
}
export { createProgram }
// index.spec.js
test('main', () => {
const f = jest.fn();
// mock debug module
jest.doMock("debug", () => () => f);
// require createProgram after debug have been mocked
const { createProgram } = require("./cli.js");
const program = createProgram();
program.parse(["node", "cli.js", "custom", "foo"]);
expect(f).toHaveBeenCalledWith(
expect.objectContaining({
a: 'foo',
// I don't know what b is
// but it must be number
b: expect.any(Number),
})
); // pass
}
除了expect.objectContaining,还有其他与部分匹配相关 API
-
expect.not.objectContaining:创建取反对象
-
expect.anything:匹配除了 null/undefined 的任何值
-
expect.arrayContaining:匹配数组部分元素
-
expect.stringMatching:匹配符合正则的 string 类型
-
expect.closeTo:匹配某个数字范围
生命周期钩子
Jest 提供以下几个钩子
- beforeAll
- beforeEach
- afterEach
- afterAll
生命周期钩子会在每次/全部测试用例之前/后触发,通过在钩子中添加公共代码,能够一定程度减少代码量
例如通过 beforeEach 钩子,在每个测试用例运行前 mock 代码,结束后,通过 jest.retoreAllMock 还原
// index.spec.js
describe('main', () => {
// mock debug in every test case
beforeEach(() => jest.doMock("debug", () => () => f))
// remove mock for debug
afterEach(jest.restoreAllMocks)
test('main', () => {
const { createProgram } = require("./cli.js");
const program = createProgram();
program.parse(["node", "cli.js", "custom", "foo"]);
expect(f).toHaveBeenCalledWith("foo"); // pass
})
test('main2', () => {
const { createProgram } = require("./cli.js");
const program = createProgram();
program.parse(["node", "cli.js", "custom", "bar"]);
expect(f).toHaveBeenCalledWith("bar"); // pass
})
})
Typescript 支持
为测试用例增加 Typescript 支持,可以获得更强的类型提示,并允许在运行测试用例前,对代码类型进行前置检查
截至目前,jest@29 对运行原生 ESM 模块的支持还是实验性
不用转译工具测试 ESM 模块参考文档 kulshekhar.github.io/ts-jest/doc…
- 添加
ts-jest,typescript,@types/jest类型声明文件
npm i ts-jest typescript @types/jest -D
- 添加
tsconfig.json文件,并将之前安装的 @types/jest 添加到声明文件列表
{
"compilerOptions": {
"types": [ "jest" ],
}
}
- 修改测试用例文件名后缀 index.spec.js - > index.spec.ts,并将 commonJS 引入修改为 ESM
测试覆盖率
可视化的方式展现测试用例运行、未运行的代码、行数
在测试命令后添加 coverage 参数
jest --coverage
运行后生成 coverage 的文件夹,包含测试覆盖率的报告
另外测试覆盖率可以与 CI/CD 平台集成,每次发布工具后,同时生成测试覆盖率报告,并上传至 CDN
达到每次工具发版时,统计测试覆盖率的增长趋势
总结
编写测试用例是一个前期投入时间比较高(学习测试用例语法),后期收益也很高(持续保障代码质量,提高重构信心)的方式
适用于改动比较少,QA 资源比较少的产品,例如命令行工具,工具库
写测试用例的一个小技巧是,参考对应工具的 github 上的测试用例,往往官方的测试用例更齐全
对命令行工具进行集成测试,需要保证测试用例互相隔离,互不影响,保证幂等性
暴露一个创建 commander 实例的工厂函数,每次运行测试用例时创建一个全新的实例
使用 Jest 内置的 api,例如 jest.spyOn , mockImplementation, jest.doMock,对 npm 模块或者内置函数进行代理,对代码侵入性较小
参考资料
博客:
What is the best way to unit test a commander cli?
stackoverflow:
stackoverflow.com/questions/5…
github: