web前端好帮手 - Jest单元测试工具
Posted QQ音乐技术团队
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了web前端好帮手 - Jest单元测试工具相关的知识,希望对你有一定的参考价值。
本文介绍如何使用Jest覆盖Web前端单元测试、如何统计测试覆盖率,Jest对比Mocha等内容。
Jest是什么?
Jest是一个令人愉快的 javascript 测试框架,专注于简洁明快。
正如官方介绍所说,Jest是一款开箱即用的测试框架,其中包含了Expect断言接口、Mock接口、Snapshot快照、测试覆盖率统计等等全套测试功能。
为什么不推荐Mocha?
不支持原生并行测试
断言库要另外安装
测试覆盖率统计功能要另外安装
原生输入的测试报告可读性很差,格式化也要另外安装
不支持snapshot,要另外安装第三方插件
Mocha使用过程中要安装大量第三方模块安装维护,这个过程繁琐并且容易出问题。以至于我每次想写Mocha单元测试时,都要花半天去重读他的文档,这个过程让我逐渐地变得“害怕”写单元测试。
而现在只需要运行npm install -D jest
一键安装Jest,便可以快速接入单元测试编写中。
Jest基础使用
项目接入Jest
安装Jest和Jest类型文件,类型文件可以让代码编辑器(如Webstorm)提供Jest相关接口的参数提示:
npm install -D jest @types/jest
在项目目录下创建jest.config.js
,配置参考官网。
在packages.json配置命令行接口:
{
"scripts": {
"test": "jest",
"test-debug": "node --inspect-brk node_modules/jest/bin/jest.js --runInBand"
}}
其中npm run test-debug path/to/xx.test.js
接口是用在chrome://inspect上进行断点调试的,后面调试章节会具体介绍。
执行npm run jest
命令后就可以跑起项目单元测试了。
一个简单的测试
假设项目中common/url.js
文件有两个parse(url:string)``getParameter(url:string)
方法需要覆盖单元测试:
const url = require("./common/url.js");
describe("url.parse", () => {
test("解析一般url", () => { const uri = url.parse("http://kg.qq.com/a/b.html?c=1&d=%2F");
expect(uri.protocol).toBe("http:");
expect(uri.hostname).toBe("kg.qq.com"); // ...
});
test("解析带hash的url", () => {...});
test("解析url片段", () => {...});
});
describe("url.getParameter", () => {
test("从指定url中获取查询参数", () => {
expect(url.getParameter("test", "?test=hash-test")).toBe("hash-test"); // ...
});
test("从浏览器地址中获取查询参数", () => {...});
test("当url中参数为空时", () => {...});
test("必须decodeURIComponent", () => {...});
});
能看到,describe()
方法是用来分组(划分作用域)的,第一个参数是分组的名字,每个分组下又包含多个test()
来对每个功能点进行详细的测试。基于以上划分,测试逻辑和范围就很清晰了:
url.parse方法支持:
解析一般url
解析带hash的url
解析url片段
url.getParameter方法支持:
从指定url中获取查询参数
当url中参数为空时
获取url参数返回值经过decode
Webstorm测试界面能看到清晰的分组:
合理的describe()
分组和按功能细分test()
测试对日后维护起到很关键的作用。
断言库常用接口
Jest内置Expect断言库,下面列举几个常用的断言方法就足以应付正常测试场景。
expect.toBe方法用在全等于判断的场景,类似JS的===
全等符号:
expect(1).toBe(1); // 测试通过expect({}).toBe({}); // 报错,因为{} !== {}
expect.toStrictEqual,深度遍历对比两个对象的结构是否全相等:
expect({}).toStrictEqual({}); // 通过expect({
person: {
name: "shanelv"
}
}).toStrictEqual({
person: {
name: "shanelv"
}
}); // 通过
expect.toThrow方法用于测试“错误抛出”:
// 假设urlParse函数对参数校验非法报错function fetchUserInfo(uid) { if (!uid) { throw(new Error("require uid!"));
} // ...}// 正确写法test('必要参数uid漏传报错', () => {
expect(() => {
fetchUserInfo()
}).toThrow();
});// 错误写法test('必要参数uid漏传报错', () => {
expect(fetchUserInfo()).toThrow();
});
注意测试错误抛出时,要在测试逻辑外加一层函数包裹,Jest才能捕获到错误。否则像第二种“错误写法”,只会造成JS报错,中断测试运行。
异步处理和超时处理
前端代码异步逻辑太常见了,比如文件操作、请求、定时器等。Jest支持callback和Promise两种场景的异步测试。
首先类似原生NodeJS接口的callback场景,如文件读写:
const fs = require("fs");
test("测试callback读写接口", (done) => {
fs.writeFileSync("./test.txt", "123456");
fs.readFile("./test.txt", (err, data) => {
expect(data.toString()).toBe("123456");
done();
});
});
如果是promise异步逻辑,推荐用async/await的写法测试:
const fs = require("fs");const util = require("util");const writeFile = util.promisify(fs.writeFile);const readFile = util.promisify(fs.readFile);
test("测试promise读写接口", async () => {
await writeFile("./test.txt", "333"); let data = await readFile("./test.txt");
expect(data.toString()).toBe("333");
});
注意,Jest检测到异步测试时(比如使用了done或者函数返回promise),Jest会等待测试完成,默认等待时间是5秒,如果异步操作时长超过,我们需要通过jest.setTimeout
设置等待时长。
我们先来看个超时的例子,将超时时间设置为1秒,但休眠2秒钟,最终休眠还未结束,Jest就中断了测试,并提示超时异常:
function sleep(time) { return new Promise(resolve => {
setTimeout(resolve, time);
});
}// 该测试会报错:Async callback was not invoked within the 1000ms timeout specified by jest.setTimeout.test("超时", async () => {
jest.setTimeout(1000);
await sleep(2000);
expect(1).toBe(1);
});
我们将上面的例子超时设置为3秒,该测试就能顺利通过:
function sleep(time) { return new Promise(resolve => {
setTimeout(resolve, time);
});
}
test("增加Jest的超时时间", async () => {
jest.setTimeout(3000); // <-- 修改3秒钟
await sleep(2000);
expect(1).toBe(1);
});
钩子和作用域
测试时难免有些重复的逻辑,比如我们测试读写文件时需要准备个临时文件,或者比如下面我们使用afterEach
钩子,在每个测试完成后重置全局变量:
global.platform = {};function setGlobalPlatform(key, value) {
global.platform[key] = value;
}
describe("platform", () => { // afterEach在每个测试完成后触发回调
afterEach(() => {
global.platform = {}; console.log("reset platform!");
});
test("设置平台信息", () => {
setGlobalPlatform("ios", true);
expect(global.platform).toStrictEqual({
ios: true
});
});
test("设置平台信息为空值", () => {
setGlobalPlatform("web");
expect(global.platform).toStrictEqual({
web: global.undefined
});
});
});
通过日志能看到,总共两个测试用例,也触发了两次reset platform
逻辑。
Jest还有beforeEach
,beforeAll
,afterAll
等钩子。
Jest钩子只对所在分组下的测试生效,比如:
// 在文件全局作用域下,对该文件中所有测试用例生效afterEach(() => {...});
describe("group-A", () => { // 在group-A作用域下,对group-A以及group-B的测试用例生效
beforeEach(() => {})
describe("group-B", () => { // 在group-B作用域下,仅对group-B下测试用例生效
beforeEach(() => {})
});
});
以上Jest的基础使用介绍,足够应付大部分的场景,下面将针对Jest特性、具体使用心得进行介绍。
合理使用Snapshot
Jest snapshot(快照)原本是用来测试React 虚拟vdom结构的,利用expect(value).toMatchSnapshot([快照名称])
将复杂的vdome结构缓存到__snapshots__
目录下,之后每次测试都会把运行结果和快照内容进行对比差异,无差异则证明测试通过。
当然其他复杂的结构也可以用快照进行测试,比如文件内容、html、AST、请求内容等:
expect(generateAst("./test.jce")).toMatchSnapshot("test.jce文件的AST结构");
Jest提供快速更新快照功能,npm场景下,我们用下面的命令来更新快照:
npm run jest -- --updateSnapshot
# 或者
npm run jest -- -u
这个命令会把本次测试的实际结果更新到快照缓存文件中。
更新快照功能的坏处就是它操作太简单了,简单到让人麻痹,让人懒惰,让人容易忽略快照更新前后的差异对比,将错误的测试结果作为正确快照提交上库。
所以这里推荐,第一,尽量让快照简洁可读,方便后续维护时更新快照差异可review。第二,内容少的数据尽量用.toStrictEqual(...)
来覆盖,不要用快照。
行内快照怎么用?
和普通快照生成文件不同,行内快照会将快照内容直接打印到测试代码中:
// 运行前:expect({ name: "shanelv" }).toMatchInlineSnapshot();// 运行Jest工具进行测试后,生成的行内快照:expect({ name: "shanelv" }).toMatchInlineSnapshot(` Object { "name": "shanelv",
}
`)
但不推荐使用行内快照进行覆盖测试,因为--updateSnapshot
也会更新行内快照的内容,上面已经提到过这里的风险。
正确的使用姿势应该是,我们用.toMatchInlineSnapshot()
生成行内快照后,再改成.toStrictEqual()
方法。
// 运行前:expect(value).toMatchInlineSnapshot();// 运行Jest工具进行测试后,生成的行内快照:expect(value).toMatchInlineSnapshot(` Object { "name": "shanelv",
}
`);// 将行内快照结果改成toStrictEqual方法!!expect(value).toStrictEqual({ "name": "shanelv",
});
这里改成.toStrictEqual()
方法的原因有二,第一,用行内快照的场景意味着快照内容短,同样适合.toStrictEqual()
方法来维护;第二,将自动更新改为手工更新,增加维护成本,降低错误测试被提交的风险。
另外,要注意系统路径的差异,可能会造成Mac上编写的测试在Windows上却运行失败:
// window的路径,在Mac上会报错expect(value).toMatchInlineSnapshot(` Object { "filePath": "f:\\code\\kg\\test.js",
}
`);// 改成toStrictEqual时,记得把路径信息改过来expect(value).toStrictEqual({ "filePath": path.resolve(__dirname, "./test.js"),
});
什么情况不适用快照?
明确的功能点测试不要用快照,比如下面我们明确要测试setName方法是否能成功设置name属性时,这种情况不应该用快照:
test("setName方法改变name属性“, () => {
let person = new Person({
name: "lxj"
job: "web"
});
person.setName("shanelv");
// 不要用快照
// expect(person).toMatchSnapshot("用户")
// 对具体功能进行测试
expect(person.name).toBe("shanelv")
});
这里我们不需要使用快照记录person实例的其他属性,只需要测试name属性,所以明确的测试点用明确的代码去覆盖,这种场景不要用快照。
其次内容少的数据不要快照,用.toStrictEqual()
,上面反复提到过了。
快照命名是个好习惯
.toMatchSnapshot()
默认按顺序来命名快照,在实际测试过程中,这样的命名不可读,也让人很难推测出具体是哪句测试代码出问题,造成维护困难。
另外同一个测试下包含多个快照时,由于默认强依赖顺序命名,此时我们改变.toMatchSnapshot()
代码的顺序也会造成快照对比报错。
所以推荐大家用.toMatchSnapshot([快照名称])
给快照设置命名,在差异对比就能一眼看出是哪句测试代码出问题了,也不会有维护的问题。
React组件如何覆盖测试?
首先安装react-test-renderer
库,该库支持将React组件渲染为纯JS对象:
npm install -D react-test-renderer
举个简单的例子:
const renderer = require("react-test-renderer");
test("测试React组件渲染", () => { let renderInstance = renderer.create( <div>
hello Jest </div>
);
let nodeJson = renderInstance.toJSON();
expect(nodeJson.type).toBe("div");
expect(nodeJson.children[0]).toBe("hello Jest");
});
注意,如果redux状态组件测试时,要先初始化store和触发redux的事件后,再渲染React组件:
test("init", () => { let store = initStore(combineReducers(reducer)); /**
* 先处理store状态,再进行render
*/
store.dispatch({
type: "xxx"
}); let renderInstance = renderer.create( <Provider store={store}>
<MyCom />
</Provider>
);
/**
* React渲染后,再改变store状态不会重新渲染
*/
//store.dispatch(
// type: "xxx"
//);
let nodeJson = renderInstance.toJSON();
// ...
});
这是因为react-test-renderer
渲染和服务端渲染类似,渲染只会执行一次,即使渲染过程中触发数据状态变动,也不会再次进行渲染,所以我们一开始要先处理store状态,再渲染React组件。
测试覆盖率统计
Jest自带测试覆盖率功能,在jest.config.js
配置文件中开启即可:
// jest.config.jsmodule.export = { // ...
collectCoverage: true,
};
开启测试覆盖后,我们执行Jest测试完成就会在项目根目录生成一个coverage
目录,用浏览器打开其中的index.html文件查看测试覆盖报告。
指定文件统计覆盖率
如果我们需要对项目某几个文件进行测试覆盖率统计,排除其他文件。
比如全民K歌前端这边,我们希望逐步的覆盖业务公共代码的测试,并且要求经过测试的文件覆盖率100%,日后新增代码功能时,已测试文件的覆盖率不能下降(即要求新增功能同时新增对应的测试),我们可以这样设置jest.config.js
配置:
/**
* 以下文件已覆盖测试,改动以下代码要同时加上测试,避免测试覆盖率降低
*/let coverTestFiles = [ "library/client-side/cookie.js", "library/client-side/url.js", "library/h5-side/components/lazy.js", // ...];module.export = { // ...
collectCoverage: true, // 指定覆盖文件
collectCoverageFrom: coverTestFiles, // 要求覆盖文件的覆盖率100%
coverageThreshold: coverTestFiles.reduce((obj, file) => {
obj[file] = {
statements: 100,
branches: 100
}; return obj;
}, {}),
};
上面覆盖的文件如果覆盖率低于100%,Jest就会报错,从而中断代码提交或仓库CI合入。
如何“行内“跳过测试覆盖
特殊情况下,我们需要跳过文件中某几句代码的测试覆盖率统计:
/* istanbul ignore else: 跳过else分支的覆盖统计 */if (isNaN(value)) { // ...} else { // ...}/* istanbul ignore if: 跳过if分支的覆盖统计 */if (isNaN(value)) { // ...}
具体看istanbul文档介绍
注意,一般来说,无法覆盖的情况都是因为功能代码编写方式的问题,尽量尝试改进功能代码的编写方式来满足测试需求,避免跳过测试覆盖统计。
Webstorm —— Jest最好的调试工具
Webstorm调试Jest测试非常便利,事实上,上文中测试截图都是在Webstorm上运行的结果,在运行、调试两个方面,Webstorm体验都比node-inspect要更友好。
Webstorm支持测试断言期望结果和实际结果的对比,并弹窗展示完整的结果:
Webstorm支持断点调试Jest,在测试代码左侧打断点,点击debug按钮后,进入调试模式,支持查看变量状态、临时脚本执行等等功能,和chrome调试相差无几,再也不用担心chrome://inspect
没有中断断点,端口占用,卡顿、占内存等问题了:
Jest并发实例注意事项
当初Jest推出的亮点之一就是运用并发优势大大加快了测试运行速度。Jest默认情况下是开启并发的,我们不需要另外配置启用就能享受测试的高速便利。这里要介绍的是Jest并发时的两点注意事项。
首先,由于Jest启动多个进程,并发地跑测试,我们使用node-inspect的方式去跑断点调试时,chrome://inspect页面上断点不会被中断,导致我们无法断点调试。此时我们要在Jest命令后面加个--runInBand
参数(在上述“项目接入”章节下也有展示):
{
"scripts": {
"test-debug": "node --inspect-brk node_modules/jest/bin/jest.js --runInBand"
}}
--runInBand
参数让Jest在同一个进程下运行测试,方便我们断点调试。
当然如果用Webstorm调试Jest就无需担心这种并发的情况,WebStorm默认走单进程执行Jest。
第二点,由于Jest测试都是并发运行的,有些外部资源处理要注意隔离,比如文件处理、数据库、本地缓存、请求之类的。下面例子中就是两个测试用例对一个文件进行测试:
function bigContent() { let arr = []; for (let i = 0; i < 1000000; i++) {
arr.push(String(Math.random()));
} return arr.join(" ");
}// test-a.test.jstest("测试文件内容-A", async () => { let content = bigContent(); // let fileName = "./test.txt"; // 危险!!
let fileName = "./test-a.txt"; // 名称隔离
await writeFile(fileName, content); let data = await readFile(fileName);
expect(data.toString()).toBe(content);
});// test-a.test.jstest("测试文件内容-B", async () => { let content = bigContent(); // let fileName = "./test.txt"; // 危险!!
let fileName = "./test-b.txt"; // 名称隔离
await writeFile(fileName, content); let data = await readFile(fileName);
expect(data.toString()).toBe(content);
});
其他方面
Jest Mock很关键也很常用,大家可以参考下官方文档,了解下面的场景并实际运用到项目:
mock函数
捕获运行情况
定义函数实现
mock模块
自动mock模块
自定义模块
单元测试之于开发
开发掌握单元测试,犹鱼之有水。我们大可把重复的测试操作交给自动化测试逻辑来负责,减少手动操作的时间,有种说法也是这般道理:先写测试,后写代码。说白了就是,先规划好实际使用的场景,再用代码去实现他。
而相反的想一步写一步代码,可能容易出现api参数反复修改、功能和实际情况不匹配、边界情况考虑不周等来回返工的情况。
甚至可以说,在单元测试覆盖良好/完全的项目中,我们可以把”Code Review“的侧重点转移到单元测试覆盖上,即只要保证单元测试覆盖良好,功能代码多个空格少个空格、你爱用switch-case我爱用if-else、代码可读性差到媲美压缩级别代码等等都已无关紧要。
单元测试之于开发就是这般的重要。
以上是关于web前端好帮手 - Jest单元测试工具的主要内容,如果未能解决你的问题,请参考以下文章