前端单元测试入门与最佳实践
前言
当你开启一个新项目的时候,相信你一定会对自己的每一个变量命名、参数设计、目录结构都经过了反复斟酌和多次修改,你希望自己的设计哲学能够被后续的开发者理解并且参照着继续维护下去。然而随着需求数量的增加,迭代周期的拉长,开发人员的更替,最终代码风格和代码质量都会逐渐下降。于是研发同学开始设计出各类工具希望能够对代码的质量进行一定的限制,提前规避技术债务。
常见治理工具
治理对象 | 治理方式 | 执行时机 |
---|---|---|
代码规范 | ESLint / StyleLint / Prettier | 本地开发 / Git Hook / Merge Request |
Commit Message | CommitLint | Git Hook |
圈复杂度 / 重复代码 | 代码检查工具 | 本地开发 / Merge Request |
敏感信息 / 安全漏洞 | 定时代码扫描 | 定时任务 |
BUG / 边界问题 | 单元测试 | 本地开发 / Merge Request |
冒烟 / 巡检 | E2E 测试 | 本地开发 / Merge Request / 定时任务 |
预期收益
规范代码质量,在代码上线前降低 BUG 风险;
优秀的单元测试用例也是最佳的代码使用文档;
可以放心的重构代码,可以放心的将代码模块交接给新同学开发;
技术选型
为什么是单元测试?
在做自动化测试调研的过程中,有不少同学提出过这个问题。有些同学认为单元测试的覆盖面太小,提升单测覆盖率所需要付出的时间成本相比于编写 E2E 测试要高出不少,有些同学认为单元测试更加适合对工具函数进行测试,不擅长对 pages 或 components 进行测试,同时也向我推荐过不少 E2E 的测试框架和工具,如:NightWatch、Selenium、Puppeteer 等,接下来我们分别来进行对比。
E2E 测试
关于 E2E 测试的定义及使用介绍在社区中已经有许多的内容,这里不再重复介绍
优点:
- 自动化测试的运行环境和用户所处环境更接近;
- 编写测试用例需要兼容的环境问题以及数据 mock 相对较少;
- 编写相对较少的测试用例即可覆盖较多的代码行数;
缺点:
-
需要开发环境的上下游都能提供稳定的测试环境
- 上下游中的某个环节环境挂掉,整体测试流程会被阻断;
-
执行测试需要的时间较长,降低流水线的执行速度
- 需要执行完毕前置用户操作,才能测试到后续的场景;
- 需要等待服务端接口响应才可继续执行;
-
debug 相对困难
- 测试过程中如遇到账号风控,或者不符合预期的广告弹窗,原测试用例无法执行;
-
触发特殊逻辑分支的成本较高
- 如果需要测试某个极特殊且小概率的场景,需要提前配置好专用测试账号,或为代码预留后门才能保证测试用例的稳定执行;
- 测试环境需要安装的依赖较多且沉重;
单元测试
上图摘自《Testing Vue.js Applications 1st Edition》
优点:
- 测试环境的稳定性更强,对上下游环境的依赖较少;
- 测试执行速度较快,不需要启动浏览器环境即可执行测试;
- 极强的灵活性,可以自主模拟各种小概率场景;
缺点:
- 需要编写的测试用例较多,编写初期较为痛苦;
- 缺少浏览器环境下的全局对象及方法,如测试涉及 window、fetch、storage 等操作需要开发者自行 mock;
推荐使用什么?
Jest
测试框架对比:
测试框架 | 断言 | Mock | 异步 | 快照 |
---|---|---|---|---|
Jest | 默认支持 | 默认支持 | 友好 | 默认支持 |
Mocha | 不支持(可配置) | 不支持(可配置) | 友好 | 不支持(可配置) |
Jasmine | 默认支持 | 默认支持 | 不友好 | 默认支持 |
基本用法:
description("src/pages/components/auth", () => {
test("should return 100", () => {
// 测试代码
// const result = ...
expect(result).toEqual(100);
});
it("should not return 100", () => {
// 测试代码
// const result = ...
expect(result).not.toBe(100);
});
});
测试运行效果:
Testing Library
核心功能
-
强大的 Query 能力
- 提供丰富的 API 使得用户可以轻松的在 JS-DOM 中查询、获取元素;
-
模拟触发用户操作触发的事件
- 提供了基础的 fireEvent 能力;
- 也可以使用官方提供的更强大的 user-event;
前端框架友好
在核心功能之上,Testing Library 还支持 React、Vue、Angular、Svelte、Cypress 等框架,进一步的降低接入单元测试的成本。
除此之外,还提供了 @testing-library/jest-dom 工具,在 jest 的运行环境下引入该工具,可以为你的断言提供更多的判断方法,比如:
toBeDisabled
toBeEnabled
toBeEmptyDOMElement
toBeInTheDocument
toBeInvalid
toBeRequired
toBeValid
toBeVisible
toContainElement
toContainHTML
toHaveAccessibleDescription
toHaveAccessibleName
toHaveAttribute
toHaveClass
toHaveFocus
toHaveFormValues
toHaveStyle
toHaveTextContent
toHaveValue
toHaveDisplayValue
toBeChecked
toBePartiallyChecked
toHaveErrorMessage
React Testing Library vs Enzyme
在此之前大部分的 React 项目会选择基于 Enzyme 来作为运行时,在 React 官方推荐 Testing Library 后越来越多的新项目开始迁移过来。两者的测试用例哲学有着明显的差异,Enzyme 提供给开发者访问 React Component state 的能力,你常常能够在测试用例中看到对组件状态的断言。
待测试源码:
import React from "react";
class Counter extends React.Component {
constructor() {
this.state = {
count: 0,
};
}
increment = () => {
this.setState(({ count }) => ({ count: count + 1 }));
}
decrement = () => {
this.setState(({ count }) => ({ count: count - 1 }));
}
render() {
return (
<div>
<button onClick={this.decrement}>-</button>
<p>{this.state.count}</p>
<button onClick={this.increment}>+</button>
</div>
);
}
}
export default Counter;
Enzyme 测试用例:
import React from "react";
import { shallow } from "enzyme";
import Counter from "./counter";
describe("<Counter />", () => {
test("properly increments and decrements the counter", () => {
const wrapper = shallow(<Counter />);
// 对组装内部状态进行断言
expect(wrapper.state("count")).toBe(0);
// 触发组件实例上的方法
wrapper.instance().increment();
expect(wrapper.state("count")).toBe(1);
wrapper.instance().decrement();
expect(wrapper.state("count")).toBe(0);
});
});
React Testing Library 测试用例:
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import Counter from "./counter";
describe("<Counter />", () => {
it("properly increments and decrements the counter", () => {
render(<Counter />);
// 通过 getByText 或者节点,也可以通过无障碍属性获取
const counter = screen.getByText("0");
const incrementButton = screen.getByText("+");
const decrementButton = screen.getByText("-");
// 触发用户操作
fireEvent.click(incrementButton);
expect(counter.textContent).toEqual("1");
fireEvent.click(decrementButton);
expect(counter.textContent).toEqual("0");
});
});
从上面两个示例中可以明显看出两种框架的测试哲学不同之处,Enzyme 更偏向于对代码进行控制来完成测试流程,React Testing Library 更倾向于事件驱动,通过模拟用户操作来完成测试流程,现在越来越多的测试框架开始向后者靠近。
推荐使用
配置参数太多不想看怎么办?
jest 提供了类似 npm init 的能力,执行下方命令会为你创建最小可用的配置文件
jest --init
如何组织测试用例文件?
过去推荐
在早期组织单元测试用例时,通常建议在 src
目录下创建 __tests__
目录,按照源码的目录结构组织测试代码,部分人会在这个目录结构之上再增加一层目录用以区分 unit 和 integration。
现在推荐
越来越多的语言自带的测试框架(Go),还有新测试框架开始推荐将测试用例放在你的源码边(也有人称之为领域驱动管理),这个方式主要带来的好处有:
- 能够快速的找到你的测试用例和源码;
- 减少许多无意义的文件夹嵌套;
- 当一个模块废弃的时候,我们可以快速的移除所有与他相关的文件;
如何配置通用环境?
在 Jest 中提供了 setupFilesAfterEnv 配置,可以指向我们的脚本文件,该脚本的内容会在测试框架环境运行之后,执行测试用例之前执行:
import "@testing-library/jest-dom"; // 扩展你的断言方法
import routeData from "react-router";
// mock 全局 i8n 方法,放弃网络请求,直接使用降级处理
window.i18n = (key, options, fallback) => fallback;
const mockLocation = {
pathname: "/",
hash: "",
search: "?test=initial",
state: "",
};
const mockHistory = {
replace: ({ search }) => {
mockLocation.search = search;
},
};
// mock react router hooks
beforeEach(() => {
jest.spyOn(routeData, "useLocation").mockReturnValue(mockLocation);
jest.spyOn(routeData, "useHistory").mockReturnValue(mockHistory);
});
如何 polyfill 浏览器的方法和属性?
代码中肯定会使用到 BOM / DOM 方法,这类方法通过直接 window.func = () => { ... }
是无法直接覆写的,此时可以使用 defineProperty 重写。这类 polyfill 建议统一放到 polyfill 目录下,在 setupFilesAfterEnv 时全部引入即可。
// test-utils/polyfill/matchMedia.js
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
如何 mock 第三方 node_modules?
在 node_modules
同级目录下创建文件夹 __mocks__
,然后创建和 npm package name 同名的 JavaScript 文件即可,例如:
- node_modules
- __mocks__
- react.js
- lodash.js
- src
- components
- App.jsx
- main.js
现在你的代码中对于 react 和 lodash 的引用全都会指向 mocks 目录下的 react.js 和 lodash.js。
如果依赖的第三方包具有 scope,则需要增加一个目录,命名为 scope 即可,例如:
- node_modules
- __mocks__
- @arco-design
- web-react.js
- src
- components
- App.jsx
- main.js
单测覆盖率怎么看?
Statements(语句覆盖率):是否每个语句都执行了
Branches(分支覆盖率):是否每个判断都执行了
Functions(函数覆盖率): 是否每个函数都执行了
Lines(行覆盖率):是否每行都执行了,大部分情况下等于 Statements
如何将单测接入到研发流程中?
对一个现存项目进行单元测试接入是比较困难的,我们可以结合 Gitlab 的能力,在 Merge Request 的环节对增量代码进行单测覆盖率的准入红线限制。如下图,我们对 MR 的单测覆盖率、reviewer、title、work item 绑定均进行了检查,增量代码必须满足至少 15% 的覆盖率红线才能够达到 approve 标准。