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测试界面能看到清晰的分组:

web前端好帮手 - Jest单元测试工具

合理的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);
});

web前端好帮手 - Jest单元测试工具

我们将上面的例子超时设置为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);
});

web前端好帮手 - Jest单元测试工具

钩子和作用域

测试时难免有些重复的逻辑,比如我们测试读写文件时需要准备个临时文件,或者比如下面我们使用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
});
});
});

web前端好帮手 - Jest单元测试工具

通过日志能看到,总共两个测试用例,也触发了两次reset platform逻辑。

Jest还有beforeEachbeforeAll,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__目录下,之后每次测试都会把运行结果和快照内容进行对比差异,无差异则证明测试通过。

web前端好帮手 - Jest单元测试工具

当然其他复杂的结构也可以用快照进行测试,比如文件内容、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(),上面反复提到过了。

快照命名是个好习惯

web前端好帮手 - Jest单元测试工具

.toMatchSnapshot()默认按顺序来命名快照,在实际测试过程中,这样的命名不可读,也让人很难推测出具体是哪句测试代码出问题,造成维护困难。

另外同一个测试下包含多个快照时,由于默认强依赖顺序命名,此时我们改变.toMatchSnapshot()代码的顺序也会造成快照对比报错。

web前端好帮手 - Jest单元测试工具

所以推荐大家用.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");
});

web前端好帮手 - 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;
}, {}),
};

web前端好帮手 - Jest单元测试工具

上面覆盖的文件如果覆盖率低于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支持测试断言期望结果和实际结果的对比,并弹窗展示完整的结果:

web前端好帮手 - Jest单元测试工具

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单元测试工具的主要内容,如果未能解决你的问题,请参考以下文章

前端自动化测试框架 Jest 极简教程

前端开发工具集:单元测试(jest)

Jest学习

Jest进行前端单元测试

基于 Jest + Enzyme 的 React 单元测试

如何测试我的数据是不是更改? (Vue JS、Jest、单元测试)