关于测试用例编写的的入门技巧和 api 解释,网上有很多文章都有解析,本文不再赘述
我会着重讲一些前端测试的进阶技巧,以及通过大量 demo 代码展示如何对较为复杂的命令行工具进行集成测试
测试用例
优势
代码质量保障 & 增加信任
放眼整个 github,一个成熟的工具库必须具备
- 完善的测试用例(jest/mocha...)
- 友好的文档系统(官网/demo)
- 类型声明文件 d.ts
- 持续集成环境(git action/circleci...)
没有上述这些要素,用户很难接受你的产品。因为这代表作为使用方可能需要给工具踩坑,很显然他们肯定不愿意看到这种情况发生
作为测试用例,最重要的一点就是提升代码质量,使得他人有信心使用你开发的工具
(由信心产生的信任关系,对软件工程师来说至关重要)
此外,测试用例可以直接视为现成的测试环境,在编写测试用例时,会逐渐弥补在需求分析环节未曾想到的 case
重构的保障
当代码存在大版本的更新,拥有完善的测试用例能够在重构时起到至关重要的作用
可以选择黑盒测试的测试用例设计方法,只关心输入和输出,也不关心具体内部做了什么
对重构而言,如果最终向用户暴露的 api 没有改变,那么测试用例几乎也不需要任何改动
因此如果代码具有完善的测试用例,就能很大程度增强重构的信心,无需关心由于代码改动导致原有功能无法使用的问题
增加代码阅读性
对于想要了解项目源码的开发者,阅读测试用例是一个高效的办法
测试用例能非常直观的展现出工具的功能,以及各种 case 情况下的行为
或者说测试用例是给软件开发者看的“文档”
缺点
凡事都有两面,说了优势,接着聊下缺点,帮助大家更好的判断项目是否应该编写测试用例
没有时间
开发者普遍不写测试用例,最常见的一点就是太过于繁琐
我写测试用例的时间,代码早就写完了,你说测试?交给 QA 吧,那是他们的职责
这种情况其实完全可以理解,平时开发时间都来不及,怎么可能腾出时间写测试用例?
所以我们需要对项目的类型做区分
对于 UI 修改频繁、生命周期短的项目,例如官网首页,活动页,个人不推荐编写测试用例
因为它们普遍具有时效性,页面的频繁变动会导致测试用例频繁变动,另外这类项目普遍配备 QA 资源,一定程度上能够保证项目质量(自测还是必要的)
反之,对于底层工具库、组件库、个人项目,由于功能变化少,并且没有 QA 资源,如果已经存在一定的用户规模,推荐补充测试用例
不会写
编写测试用例需要学习测试框架的语法,因此需要一定学习成本(没有时间学也是导致不会写的诱因)
好在市面上主流的测试框架大同小异,整体思想趋于一致,同时本身 breaking change 也不多。普通开发者可以一周上手,二周进阶。学习之后能够在任何前端项目中使用(learning once, write everywhere)
与 Vue 和 React 的学习成本相比,再结合前面的优势来看,是不是一个非常划算的交易呢?
说完优缺点,接着分享一下对一个复杂命令行工具编写测试用例时,遇到的坑以及解决方案和思考
对命令行工具进行集成测试
我采取集成测试的方式进行测试,集成测试与单元测试的区别在于,前者更广,后者粒度更细,集成测试也可以由多个单元测试组合而成
在编写测试用例前,需要先模拟出用户使用命令行工具的方式
命令行运行
一开始我理解是,尽可能模拟用户原始的输入。因此我的思路是直接在测试用例中运行命令行工具
// index.spec.js
const cli = (argv = "", cwd = monorepoPath) => new Promise((resolve, reject) => {
const subprocess = execa.command(`node cli.js ${argv}`, { cwd })
subprocess.stdout.pipe(process.stdout)
subprocess.stderr.pipe(process.stderr)
Promise.resolve(subprocess).then(resolve)
})
test('main', async() => {
await cli(`custom`,mockDir)
expect(...)
})
复制代码
在子进程运行命令行工具,然后将子进程的输出打印到父进程中,最后判断打印结果是否符合预期
优点:更加符合用户使用命令行的方式
缺点:需要调试测试用例时,由于依赖子进程,导致 debugger 开启时性能极差,测试用例运行经常超时,甚至吞掉错误或者输出一些与测试用例本身无关的系统报错
函数运行
先初始化命令行工具,再暴露一个名为 bootstrap
的启动函数,为命令行工具动态添加 sub command
// cli/index.ts
const { Command } = require("inquirer")
const program = new Command()
const bootstrap = (argv = process.argv, cwd = process.cwd()) => {
// ...
program.parse(argv)
}
export { main }
复制代码
// index.spec.js
const { bootstrap } = require('cli/index.ts')
test('index', async () => {
await bootstrap(['node','index.js','custom'], mockDir)
expect(...)
})
复制代码
优点:不依赖子进程,直接在当前进程运行测试用例,debugger 也没有问题,终于解决了性能瓶颈的问题
缺点:所有测试用例共享同一个 program,代码存在副作用,测试用例单独使用没有问题,但多个测试用例之间会互相干扰
工厂函数运行
吸取上次的教训,直接暴露一个生成命令行工具的工厂函数
// cli/index.ts
const createProgram = async (argv = process.argv) => {
const program = new Command()
// ...
return program
}
export { createProgram }
复制代码
// index.spec.js
const { createProgram } = require('cli/index.ts')
test('index', async () => {
await createProgram(['node','index.js','custom'])
expect(...)
}
复制代码
这样每次运行测试用例,创建的都是独立的 program ,使得测试用例之间彼此隔离
解决了命令行工具的初始化后,接着来看一些测试用例的 case
测试 help
当需要测试帮助命令( --help、-h ),或者测试参数验证的功能时,由于 commander.js 会将提示文案作为错误日志输出到进程,并调用 process.exit
退出进程
这会使得测试用例提前退出,所以需要重写这个行为
commander.js 内部提供了重写的函数 exitOverride
,使用后会抛出一个 js 错误替代原先进程的退出
// 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();
});
复制代码
重写后,如果需要对帮助的文案进行测试,还需要使用 configureOutput
,这点官方文档有提到
修改测试用例如下
// index.spec.js
test('help option', () => {
const program = createProgram(['node', entryPath, '-h'])
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:
init|i [options] [template] 拉取模板初始化项目
`)
},
})
expect(() => {
program.parse(['node', entryPath, '-h'])
}).toThrow('(outputHelp)');
})
复制代码
异步测试用例
此外命令行工具可能会有异步回调,所以测试用例需要支持异步的 case
好在 Jest 对异步测试用例也是开箱即用,以帮助命令为例
// index.spec.js
+ test('help option', async () => {
+ const program = await createProgram(['node', entryPath, '-h'])
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:
init|i [options] [template] 拉取模板初始化项目
`)
},
})
+ try {
+ // program.action 存在异步回调时,需要用 parseAsync 异步解析
+ await program.parseAsync(['node', entryPath, '-h'])
+ } catch (e) {
+ // 根据 e.code,区分是帮助命令自身的报错,还是其他代码错误
+ if (e.code) {
+ expect(e.code).toBe('commander.helpDisplayed')
+ } else {
+ throw e
+ }
+ }
})
复制代码
对于异步测试用例,推荐设置超时时间,防止因为代码编写错误,导致进程休眠
Jest 默认超时时间为 5000ms,也可以通过配置文件/测试用例重写
jest.setTimeout(10000); // 10 second
test('will fail', () => {
expect(true).toBe(false);
});
复制代码
// jest.config.js
module.exports = {
testEnvironment: 'node',
testTimeout: 10000
}
复制代码
除了超时时间,添加断言的次数也是保证异步测试用例成功的一点
expect.assertions
可以指定某个测试用例触发断言的次数,这对测试异常捕获的场景很有帮助
超时和预期次数不符都会让测试用例失败(否则若代码执行成功,会提前退出测试用例,导致没有执行异常捕获的代码)
// index.spec.js
+ jest.setTimeout(10000);
test('help option', async () => {
+ // 期望触发两次断言,这样触发次数不正确/超时都会导致测试用例失败
+ expect.assertions(2)
const program = await createProgram(['node', entryPath, '-h'])
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:
init|i [options] [template] 拉取模板初始化项目
`)
},
})
try {
// program.action 存在异步回调时,需要用 parseAsync 异步解析
await program.parseAsync(['node', entryPath, '-h'])
} catch (e) {
// 根据 e.code,区分是帮助命令自身报错,还是其他代码错误
if (e.code) {
expect(e.code).toBe('commander.helpDisplayed')
} else {
throw e
}
}
})
复制代码
环境隔离
这一点也是前面的小结
确保编写测试用例时,彼此互相独立,互不影响,没有副作用,拥有幂等性
-
每次创建新的 commander 实例
-
没有单例模式
-
文件系统隔离
其他技巧
mock 工作目录
jest.spyOn(process, 'cwd').mockImplementation(() => mockPath))
复制代码
也可以作为 createProgram
工厂函数的参数传入
// cli/index.ts
const createProgram = async (
argv = process.argv,
cwd = process.cwd()
) => {
// 之后代码使用 cwd 变量替代 process.cwd
}
// index.spec.js
test('mock current work directory', () => {
// jest.spyOn(process, 'cwd').mockImplementation(() => mockPath))
createProgram(argv, mockPath)
});
复制代码
mock 日志
const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation()
//...
expect(stderrSpy).toBeCalledWith('something error')
// no error log
复制代码
jest.spyOn
+ mockImplementation
可以实现代理 + mock 函数
mockImplementation
不传任何参数,会静默处理 mock 后的函数,使测试用例更干净
例如测试命令行的异常处理,可以在完成断言的同时,不展示错误日志(也可以用前面提到的 program.configureOutput 重写错误日志)
test('unknown command', async () => {
jest.spyOn(process.stderr, 'write').mockImplementation()
try {
const program = await createTestProgram(['unknown'])
program.exitOverride()
// program.exitOverride().configureOutput({
// writeErr: str => {},
// })
await program.boot()
} catch (e) {
expect(e.message).toEqual("error: unknown command 'unknown'")
}
})
复制代码
使用前:
使用后:
mock 文件系统
文件系统是一个含有副作用的操作,由于文件可能被删除/修改,所以不能保证每次运行测试用例都是一个干净的环境
为了保证测试用例互相独立,需要模拟一个真实的文件系统,可以用 memory-fs。它支持在内存中操作文件,实现对真实文件的互相隔离
npm i memfs -D
复制代码
项目根目录新建 __mock__ 文件夹
添加 __mock__/fs.js 文件,导出 memfs 模块
const { fs } = require('memfs')
module.exports = fs
复制代码
Jest 默认将 __mocks__ 文件夹下的文件视为可以被模拟的模块
然后在测试用例中手动运行 jest.mock(fs)
,将测试用例中对 fs 的操作代理到 __mocks__/fs.js 下
此时运行测试用例时,运行 fs 模块实际指向了 memfs,最终实现在没有修改源代码的情况下,完成了对 fs 模块的代理,解决了文件系统副作用的问题
mock 命令行交互
受到 vue-cli 的启发,在测试用例中,模拟用户输入变得非常简单,且代码侵入性为 0
- 创建 __mock__/inquirer.js,重写 prompt 模块,添加断言语句
const prompt = prompts => {
const answers = {}
let skipped = 0
prompts.forEach((prompt, index) => {
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[index - skipped]
if (a.message) {
const message = typeof prompt.message === 'function'
? prompt.message(answers)
: prompt.message
expect(message).toMatch(a.message)
}
const choices = typeof prompt.choices === 'function'
? prompt.choices(answers)
: prompt.choices
if (a.choices) {
expect(choices.length).toBe(a.choices.length)
a.choices.forEach((c, i) => {
const expected = a.choices[i]
if (expected) {
expect(choices[i].name).toMatch(expected)
}
})
}
if (a.choose != null) {
expect(prompt.type === 'list' || prompt.type === 'rawList').toBe(true)
setValue(choices[a.choose].value)
}
})
expect(prompts.length).toBe(pendingAssertions.length + skipped)
pendingAssertions = null
return Promise.resolve(answers)
}
复制代码
// 创建断言条件
const expectPrompts = assertions => {
pendingAssertions = assertions
}
复制代码
- 在运行测试用例前,通过
expectPrompts
模拟用户输入(创建断言的条件)
// 代理 inquirer 到 __mocks__/inquirer.js
// __mocks__ 文件夹下若是非内建的 npm 模块,则会自动 mock,因此此处可以省略
// jest.mock('inquirer')
const { expectPrompts } = require('inquirer')
test('migrate command', async () => {
expectPrompts([
{
message: '选择 cli 启动项目',
choices:[ 'project1', 'project2', 'project3', 'sub-root' ],
choose:1,
},
])
const program = await createProgram()
// 代码里执行 inquirer.prompt 指向 __mocks__/inquirer.js 并消费数据
await program.parseAsync([ 'node', entryPath, 'migrate' ])
})
复制代码
- 当代码运行 inquirer.prompt 时,代理并跳转到自定义 prompt,自定义 prompt 会根据之前
expectPrompts
预先设置好的期望问题和期望答案依次进行匹配(消费数据) - 最后代理的 prompt 会返回真实 prompt 相同的 answers 对象,使代码继续运行
测试函数参数
Jest 内置的 toHaveBeenCalled 方法可以测试调用函数时的入参
function drinkAll(callback, flavour) {
if (flavour !== 'octopus') {
callback(flavour);
}
}
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();
});
});
复制代码
但官网的示例只适用于函数(drinkAll)可以被暴露出来的场景
对于命令行工具,很多函数都被集成在入口代码里,单独导出函数可能会导致缺失一些必要的上下文
对于集成测试来说,可以使用前面提到的 mock 功能,代理 console.log/console.debug,再对代理后的函数进行断言
// cli/index.ts
const createProgram = async (argv = process.argv) => {
const program = new Command()
const context = {
program,
a:1,
b:2
}
// jest 运行时会将 NODE_ENV 设置为 test
if(process.env.NODE_ENV === 'test'){
console.debug(context)
}
return program
}
export { createProgram }
复制代码
// index.spec.js
const { createProgram } = require('cli/index.ts')
test('index', async () => {
const debugSpy = jest.spyOn(console, 'debug').mockImplementation()
await createProgram(['node','index.js','custom'])
expect(debugSpy).toHaveBeenCalledWith({
// 断言 program 的数据结构
program,
a:1,
b:2
})
}
复制代码
生命周期钩子
Jest 提供以下几个钩子,可以在每次、全部测试用例之前、后调用,一定程度减少代码量
- beforeAll
- beforeEach
- afterEach
- afterAll
例如通过 beforeEach
钩子,在每个测试用例运行前 mock 代码
结束后,通过 jest.retoreAllMock
还原
describe('index', () => {
let debugSpy
beforeEach(() => {
debugSpy = jest.spyOn(console, 'debug').mockImplementation()
})
afterEach(jest.restoreAllMocks)
test('test1', async () => {
console.debug(123)
expect(debugSpy).toHaveBeenCalledWith(123)
})
})
复制代码
Typescript 支持
将测试用例增加 Typescript 支持,可以获得更强的类型提示,并且允许在运行测试用例前,对代码类型进行检查
- 添加 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 资源比较少的产品,例如命令行工具,工具库
对命令行工具进行集成测试,需要保证测试用例互相隔离,互不影响,保证幂等性
暴露一个创建 commander 实例的工厂函数,每次运行测试用例时创建一个全新的实例
jest 内置的模拟 npm、内建模块能力是一个比较推荐测试方式,对代码侵入性较小
参考资料
blog:
What is the best way to unit test a commander cli?
stackoverflow:
stackoverflow.com/questions/5…
github: