一、单元测试是什么?
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证 - 百科
二、收益有哪些?
- 快速反馈异常,问题及早暴露
- 大版本平稳重构升级,降低测试、全量回归成本
- CI/CD 的基本盘,保障工程质量,在 MR、PUSH 等阶段自动触发
- 提升代码质量
- 优秀的单元测试用例也是最佳的代码使用文档,方便新同学上手
三、选择什么单元测试框架?
方案选用为 jest + testing-library + node-canvas,原因让我们来看下面对比情况
常见组件库单测方案
| 组件库 | 单元测试方案 |
|---|---|
| antd | Jest + testing-library |
| arco | Jest + testing-library |
| semi | Jest + enzyme |
| okee | Jest + enzyme |
单元测试框架对比
| 测试框架 | 断言 | Mock | 异步 | 快照 | 测试报告 | star |
|---|---|---|---|---|---|---|
| Jest ✅ | 默认支持 | 默认支持 | 友好 | 默认支持 | 默认支持 | 41k |
| Mocha | 不支持(可配置) | 不支持(可配置) | 友好 | 不支持(可配置) | 不支持(可配置) | 21.8k |
| jasmine | 默认支持 | 默认支持 | 不友好 | 默认支持 | 默认支持 | 15.6k |
单元测试 React 渲染器框架对比
| React Testing Library ✅ | Enzyme | |
|---|---|---|
| React 组件渲染 | @testing-library/jest-dom:用于 dom、样式类型等元素的选取(支持) | enzyme-adapter-react:对 React 的适配器(支持) |
| 异步渲染等待 | 提供 waitfor (支持) | 需要通过递归轮训自行实现(不支持) |
| event 模拟 | @testing-library/user-event:用于单测场景下事件的模拟(支持) | 不需要额外安装 (支持) |
| hook 测试 | testing-library/react-hooks(支持) | 必须通过运行 React 组件的形式来测试(半支持) |
| canvas 模拟 | 额外安装 Jest-canvas-mock / node-canvas | 额外安装 Jest-canvas-mock / node-canvas |
| 新版 React 兼容性 | 来自 Facebook 团队,更新及时 ✅ | 迭代速度慢,从 React17 开始是使用者自行实现,且存在问题 ,目前官方只有一个维护者 ❌ |
| 运行速度(Button 组件) | 21s | 23s |
| start | 17.9k | 20k |
| Npm 近一年下载量 |
四、如何编写测试用例?
遵循 FIRST 原则
F ——Fast:快速
简洁、单一、快速运行
I ——Isolated:隔离
能在任何时间,以任何顺序,运行任何一个测试
// 错误示范 ❌
const obj = {};
test("str", () => {
obj.str = "1";
});
test("str", () => {
expect(obj.str).toEqual("1");
});
R ——Repeatable:可重复
单元测试需要保持运行稳定,每次运行都需要得到同样的结果
// 错误示范 ❌
test("random", () => {
expect(Math.random() > 0.5).toBeTruthy();
});
S ——Self-verifying:自我验证
自动化,测试结果是简单的 true/false,程序能直接判断,不需要人工介入
T ——Timely:及时
最有效的方式是在开发功能前就规划好单元测试的 case,也就是 TDD 测试驱动开发
Jest 与 Testing-library 的使用
这里介绍部分常见和特殊用法,完整 api 可看 Jest 官方文档、Testing-library 官方文档
单元测试描述
标准单元测试的构成
describe("被测试的对象", () => {
it("该对象的什么功能", () => {
expect(实际得到的结果).toEqual(期望的结果);
});
});
组件单元测试例子
import { render, screen } from "@testing-library/react";
describe("Button", () => {
it("Button props text", async () => {
render(<Button text="按钮文案" />);
expect(screen.getBytext("按钮文案")).toBeVisible();
});
});
常见场景例子
1. toMatchSnapshot 节点快照
例子
import { render, screen } from '@testing-library/react';
describe('Button', () => {
it('Button render', async () => {
const {container} =render(<Button text="按钮文案" />);
expect(container).toMatchSnapshot();
});
});
快照结果
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Button Button render`] = `
<div>
<div>
<button>
按钮文案
</button>
</div>
</div>
`;
2. 断言节点是否存在、显示/隐藏
-
toBeVisible 断言节点是否显示
-
toBeInTheDocument 断言节点是否被销毁
import { render, screen } from "@testing-library/react";
describe("Drawer", () => {
it("Drawer content toBeShow", () => {
render(
<Drawer show={true}>
<p>抽屉内容</p>
</Drawer>
);
expect(screen.getBytext("抽屉内容")).toBeVisible();
});
it("Drawer content toBeHide", () => {
render(
<Drawer show={false}>
<p>抽屉内容</p>
</Drawer>
);
expect(screen.getBytext("抽屉内容")).not.toBeVisible();
});
it("Drawer content tobeDestroy", () => {
render(
<Drawer hideToDestroy={true} show={false}>
<p>抽屉内容</p>
</Drawer>
);
expect(screen.getBytext("抽屉内容")).toBeInTheDocument();
});
it("Drawer content not tobeDestroy", () => {
render(
<Drawer hideToDestroy={true} show={false}>
<p>抽屉内容</p>
</Drawer>
);
expect(screen.getBytext("抽屉内容")).not.toBeInTheDocument();
});
});
3. 断言组件事件是否触发,以及回调函数值是否符合预期
- toBeCalledTimes(n) 断言函数触发过 n 次
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
describe("RadioGroup", () => {
it("RadioGroup onValueChange", async () => {
const onValueChange = jest.fn((val) => val);
render(
<RadioGroup onValueChange={onValueChange}>
<RadioGroup.Radio value="fill">fill</RadioGroup.Radio>
<RadioGroup.Radio value="border">border</RadioGroup.Radio>
</RadioGroup>
);
await userEvent.click(screen.getBytext("border"));
// 断言 onValueChange 被调用过一次
expect(onValueChange).toBeCalledTimes(1);
// 第一次函数调用的返回值是 border
expect(onValueChange.mock.results[0].value).toBe("border");
});
});
4. 要键盘热键触发的场景
- userEvent.type(targetDom, text ) 方法接受两个参数:要输入文本的 DOM 元素和要输入的文本
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
describe("Input", () => {
it("Input events input", async () => {
const onValueChange = jest.fn((val) => val);
const testText = "测试输入";
render(<Input onValueChange={onValueChange}></Input>);
await userEvent.type(screen.getByRole("textbox"), testText);
expect(eventsInput).toBeCalledTimes(4);
const val = eventsInput.mock.results;
expect(val?.[0].value.value).toEqual(testText.substring(0, 1));
expect(val?.[1].value.value).toEqual(testText.substring(0, 2));
expect(val?.[2].value.value).toEqual(testText.substring(0, 3));
expect(val?.[3].value.value).toEqual(testText.substring(0, 4));
});
});
- userEvent.keyword 方法可以模拟一系列键盘事件,包括 Tab、Enter、Backspace、Delete 等
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
describe("Drawer", () => {
it("Drawer content toBeShow", () => {
render(
<Drawer show={true}>
<p>抽屉内容</p>
</Drawer>
);
await userEvent.keyboard("{Escape}");
expect(screen.getBytext("抽屉内容")).not.toBeVisible();
});
});
5. 断言是否存在某个 classname 或 style 样式
- toHaveStyle
- toHaveClass
import { render, screen } from "@testing-library/react";
describe("Button", () => {
it("toHaveStyle example", async () => {
render(<Button text="按钮文案" style={{ width: 200 }} />);
expect(screen.getBytext("按钮文案")).toHaveStyle({
width: "200px",
});
});
it("toHaveClass example", async () => {
render(<Button text="按钮文案" className="test-class" />);
expect(screen.getBytext("按钮文案")).toHaveClass("test-class");
});
});
6. canvas 组件场景下的方案
6.1 利用 jest-canvas-mock 记录所有绘制路径
-
优势
- 明确记录所有行为,排查问题可以及时发现问题节点进行反馈
-
缺点
- 需要很大的快照空间,对比速度慢
import { render, screen } from "@testing-library/react";
import { setupJestCanvasMock } from "jest-canvas-mock";
describe("lineChart", () => {
beforeEach(() => {
jest.resetAllMocks();
setupJestCanvasMock();
});
it("linecanvas events", async () => {
const { container } = render(<LineChart />);
const canvas = container.querySelector("canvas");
const ctx = canvas?.getContext("2d");
const events = ctx && ctx.__getEvents();
expect(events).toMatchSnapshot();
});
});
- 快照结果
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Canvas path 1`] = `
Array [
Object {
"props": Object {},
"transform": Array [1,0,0,1,0,0,],
"type": "beginPath",
},
Object {
"props": Object {
"value": "#6fce29",
},
"transform": Array [1,0,0,1,0,0,],
"type": "fillStyle",
},
Object {
"props": Object {
"height": 100,
"width": 100,
"x": 0,
"y": 0,
},
"transform": Array [1,0,0,1,0,0,],
"type": "fillRect",
},
]
`;
6.2 利用 canvas.toDataURL 生成 base64 快照进行比对
-
优势
- 存储空间小,对比速度快
-
缺点
- 提示有差异后,需要通过浏览器查看 base64 后手动对比差异
import { render, screen } from "@testing-library/react";
describe("lineChart", () => {
it("linecanvas create base64 snapshot", async () => {
const { container } = render(<LineChart />);
const canvas = container.querySelector("canvas");
const base64Snapshot = canvas?.toDataURL("image/jpeg", 1);
expect(base64Snapshot).toMatchSnapshot();
});
});
- 快照结果
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MeasureCard MeasureCard canvas imageData 1`] = `
Array [
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB...",
]
`;
7. Mock 第三方依赖
- jest.mock 模拟模块引用
import axios from "axios";
jest.mock("axios");
test("should fetch data from API", async () => {
const data = { results: [{ name: "John" }] };
axios.get.mockResolvedValueOnce({ data });
const response = await axios.get("/api/users");
expect(response.data).toEqual(data);
expect(axios.get).toHaveBeenCalledWith("/api/users");
});
8. 组件内部包含异步延迟处理逻辑
- 假设组件内有个 button 在延迟 1s 后展示,可以利用 waitFor 进行等待
import { render, screen, waitFor } from "@testing-library/react";
test("MyComponent should render a button", async () => {
render(<DelayComponent />);
await waitFor(() => {
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
});
});
9. 断言 throw error 场景
- toThrow 针对同步函数
function myFunction() {
throw new Error("My error message");
}
test("myFunction should throw an error", () => {
expect(() => {
myFunction();
}).toThrow(new Error("My error message"));
});
- toThrow 针对 promise 异步函数
async function myAsyncFunction() {
throw new Error("My error message");
}
test("myAsyncFunction should throw an error", () => {
return expect(myAsyncFunction()).rejects.toThrow(
new Error("My error message")
);
});
五、单元测试报告分析
通过配置 jest.config 中的 coverageDirectory 可以指定单元测试报告的生成目录
单元测试报告中包含什么?
执行 npm run test 或 npx jest 后会生成 coverage 目录,打开目录中的 index.html 可查看报告具体内容
代码覆盖率(执行过的代码 / 可执行的代码 * 100),也称为测试覆盖率,是用来度量代码测试完整性的一个指标
- Statements(语句覆盖率):程序中有多少语句已执行
- Branches(分支覆盖率):控制结构的分支(例如 if 语句)中有多少已执行
- Functions(函数覆盖率): 已定义的函数中有多少被调用
- Lines(行覆盖率):是否每行都执行过,大部分情况下等于 Statements
六、将单元测试集成到 ci 当中
这里以 github 举例,在公司基建配置会稍有不同,原理就是将 jest 的报告目录上传给 codecov
1. 打开 codecov,用 github 账户后,选中项目获取对应 token
2. 在 github 对应项目中以 secrets 形式配置 token 防止泄露
配置路径: 项目 settings > secrets and variable > actions
3. 在项目中配置 github ci
根目录创建 .github/workflows/ci.yml 文件
name: test
on:
push:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "16"
- name: install
run: npm install
- name: test
run: npm run test --coverage
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
4. 查看测试报告
对项目 master 分支发起 push 和 pr 时触发 github action ci
在 pr 中可以看到报告,点击链接可以跳转到 codecov 看更具体信息
codecov 中可以看到具体行数覆盖情况
七、jest 小技巧
1. 多进程运行 jest
通过开启多进程方式提高 jest 运行速度,默认为 cpu 核心数 - 1