前端工程化-单元测试篇
在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。
程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
—-摘自维基百科
通俗的讲,在前端领域,单元测试就是 测试的单个模块,可以是一个函数,一个组件。
单元测试的优点如下:
- 单元测试是一种验证行为
- 单元测试是一种设计行为
- 单元测试是一种编写文档行为
- 单元测试是一种回归测试行为
目前来说,单元测试框架很多,本文将以 jest 为例进行探讨。
一. Jest 初体验
jest 是什么
jest 是一款优雅,简洁的 js 测试框架,他支持 babel, ts, node, react, angular, vue 等各种框架。
他具备以下优点:
- 安全快速
- 完整的代码覆盖,只需要一行命令
- 轻松模拟
- 友好的错误提示
- 强大的生态
- 完备的文档
环境准备
- 初始化一个项目:
npm init
- 安装 jest
yarn add jest -D
hello world
- 添加需要测试的文件
touch t.js
内容如下:
function sum(a, b) {
return a + b;
}
module.exports = sum;
- 新建测试文件
touch t.test.js
测试脚本如下:
const sum = require("./index");
test("adds 1 + 2 to equal 3", () => {
expect(sum(1, 2)).toBe(3);
});
- 配置 package.json
{
...,
"scripts": {
"test": "jest"
}
}
- 执行 npm run test
二. Jest 匹配器
在上述例子中, toBe 就是一个匹配器,也是最简单的
toBe
toBe 是判断精确相等,即 x === y
toEuqal
和 toBe 类似,也是判断相等,但他判断的是对象
toBeNull
结果至匹配 null
toBeUndefined
结果只匹配 undefined
toBeDefined
与 toBeUndefined 相反
toBeTruthy
匹配任何 if 语句为真,实际上就是期望结果是 true
toBeFalsy
匹配任何 if 语句为假,实际上就是期望结果是 false
toBeGreaterThan
匹配数字时使用,期望大于,即 result > xx
toBeGreaterThanOrEqual
匹配数字时使用,期望大于等于,即 result >= xx
toBeLessThan
匹配数字时使用,期望小于,即 result < xx
toBeLessThanOrEqual
匹配数字时使用,期望小于等于,即 result <= xx
toBeCloseTo
小数点精度问题匹配,有个经典的问题: 0.1 + 0.2 !== 0.3 的,但我们期望等于,需要使用 toBeCloseTo
toMatch
匹配字符串时使用,希望字符串包含另一个字符串。
使用示例:
expect("abc").toMatch("a"); // ok
toContain
数组操作,和 indexOf 一样,数组是否包含 xx。
使用示例:
expect([1, 2, 3]).toContain(1); // ok
更多
其他可以参考官方文档说明: jestjs.io/zh-Hans/doc…
三. Jest 异步代码测试
测试代码,需要注意的是: jest 测试是同步执行的,如果直接写异步任务,会在异步之前就执行了测试期望。这个时候,我们需要手动执行 done。
1. 异步函数
const axios = require("axios");
function fetchData() {
return axios("http://localhost:3001/");
}
module.exports = fetchData;
2. 测试异步函数
const fetchData = require("./t");
test("fetch Data", (done) => {
fetchData().then((data) => {
expect(data.data).toBe("ok"); // ok
done();
});
});
四. Jest 全局钩子
如果,我们需要在 执行测试之前 做些事件,或者执行之后,做些工作。 如果,我们需要在 每个 test case 之前或之后,做些事件
那么,我们可以使用:
beforeEach
在每个 test case 之前,执行
beforeEach(() => console.log("我是在每个test case 之前 执行"));
afterEach
在每个 test case 之后,执行
afterEach(() => console.log("我是在每个test case 之后 执行"));
beforeAll
在所有 case 之前执行, 只执行一次。
beforeAll(() => console.log("只执行一次,在所有test case之前执行"));
afterAll
在所有 case 之后执行, 只执行一次。
afterAll(() => console.log("只执行一次,在所有test case之后执行"));
五. mock 函数
当我们需要模拟 callback 函数调用时,需要使用模拟函数。
demo
需要被测试的代码:
function sum(a, b, callback) {
return callback(a + b);
}
module.exports = sum;
测试代码:
const sum = require("./t");
test("call", () => {
const mockCallback = jest.fn();
sum(1, 2, mockCallback);
sum(3, 4, mockCallback);
expect(mockCallback.mock.calls[0][0]).toBe(3);
expect(mockCallback.mock.calls[1][0]).toBe(7);
});
其实,mock 函数的主要使用了 jest.fn 方法,每个这样的方法,都有 mock 这个属性。
mockCallback.mock 属性,其数据结构如下:
{
calls: [ [ 3 ], [ 7 ] ],
instances: [ undefined, undefined ],
invocationCallOrder: [ 1, 2 ],
results: [
{ type: 'return', value: undefined },
{ type: 'return', value: undefined }
]
}
六. React + Jest
create / destroy
每次测试,都需要 render 到一个 dom 上,这就需要每次测试前,先创建一个 div。结束之后,再删除。
import { unmountComponentAtNode } from "react-dom";
let container = null;
beforeAll(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterAll(() => {
unmountComponentAtNode(container);
container.remove();
container = null;
});
act
它确保在进行任何断言之前,模块单元相关的所有更新都已处理并应用于 DOM
示例如下:
act(() => {
// 渲染组件
});
// 进行断言
render 单元测试
需要被测试的组件:
import React from "react";
function Main() {
return <div>a</div>;
}
export default Main;
单元测试代码:
import React from "react";
import { unmountComponentAtNode, render } from "react-dom";
import { act } from "react-dom/test-utils";
import Main from "./t";
let container = null;
beforeAll(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterAll(() => {
unmountComponentAtNode(container);
container.remove();
container = null;
});
test("test", () => {
act(() => {
render(<Main />, container);
});
expect(container.textContent).toBe("a");
});
事件
DOM 事件的测试,可以对结果做断言,使用 jest.fn 模拟函数。
react 组件代码:
import React, { useState } from "react";
function Home(props) {
const [state, setState] = useState(true);
const handleClick = () => {
setState(!state);
props.onChange(!state);
};
return (
<div>
<button id="btn" onClick={() => handleClick()}>
{state ? "a" : "b"}
</button>
</div>
);
}
export default Home;
测试代码:
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import Home from "./index";
let container = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
unmountComponentAtNode(container);
container.remove();
container = null;
});
it("点击时更新值", () => {
const onChange = jest.fn();
act(() => {
render(<Home onChange={onChange} />, container);
});
const button = document.getElementById("btn");
expect(button.innerHTML).toBe("a");
act(() => {
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(button.innerHTML).toBe("b");
});
快照测试
jest 会保存上一次的快照,当我们的组件发生变化时,jest 会给出 diff 提示。
原始组件
import React from "react";
function Home(props) {
return <div>测试</div>;
}
export default Home;
测试代码:
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";
import pretty from "pretty";
import Home from "./index";
let container = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
unmountComponentAtNode(container);
container.remove();
container = null;
});
it("点击时更新值", () => {
act(() => {
render(<Home />, container);
});
expect(pretty(container.innerHTML)).toMatchInlineSnapshot(
`"<div>测试</div>"`
);
});
运行如下:
当我们改变原始组件代码时(将测试文案修改为测试 1),再次运行结果如下:
七. report
运行 jest 测试之后,项目中会出现一个 coverage 文件夹,里面有个 index.html 文件,打开之后,就能看到具体的测试报告了。 如下:
八. 总结
jest 是 react 推崇的单元测试运行器,react 框架本身也使用 jest,社区生态支持完善。
配合 react 测试库,那真的是太香了~~