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

本文介绍如何使用Jest覆盖Web前端单元测试、如何统计测试覆盖率,Jest对比Mocha等内容。

Jest是什么?
web前端好帮手 - 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还有 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 目录下,之后每次测试都会把运行结果和快照内容进行对比差异,无差异则证明测试通过
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() ,上面反复提到过了。

快照命名是个好习惯

.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(


hello Jest

);

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(


    );

/**

  • 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单元测试工具
    其他方面
    Jest Mock很关键也很常用,大家可以参考下官方文档,了解下面的场景并实际运用到项目:

mock函数

捕获运行情况

定义函数实现

mock模块

自动mock模块

自定义模块

单元测试之于开发
开发掌握单元测试,犹鱼之有水。我们大可把重复的测试操作交给自动化测试逻辑来负责,减少手动操作的时间,有种说法也是这般道理: 先写测试,后写代码 。说白了就是,先规划好实际使用的场景,再用代码去实现他。

而相反的想一步写一步代码,可能容易出现api参数反复修改、功能和实际情况不匹配、边界情况考虑不周等来回返工的情况。

甚至可以说,在单元测试覆盖良好/完全的项目中,我们可以把”Code Review“的侧重点转移到单元测试覆盖上,即只要保证单元测试覆盖良好,功能代码多个空格少个空格、你爱用switch-case我爱用if-else、代码可读性差到媲美压缩级别代码等等都已无关紧要。

单元测试之于开发就是这般的重要。
我目前是在职前端开发,如果你现在也想学习前端开发技术, 在入门学习前端的过程当中有遇见任何关于学习方法,学习路线,学习效率等方面的问题, 你都可以申请加入我的前端学习群:1017810018里面聚集了一些正在自学前端的初学者裙文件里面也有我做前端技术这段时间整理的一些前端学习手册,前端面试题, 前端开发工具,PDF文档书籍教程,需要的话都可以自行来获取下载。