单元测试
单元测试是对代码最小单元(函数/类/组件)的自动化验证。它不能保证产品没有 bug,但能保证"逻辑正确性"在每次改动后依然成立,是重构与持续集成的安全网。本文以 Jest 为主线讲解完整玩法,并对比新一代的 Vitest。
一、测试的价值与类型
1. 测试金字塔
| 层级 | 粒度 | 速度 | 数量 | 稳定性 |
|---|---|---|---|---|
| 单元测试 | 单个函数/组件 | 毫秒级 | 最多 | 最稳 |
| 集成测试 | 模块间协作、接口 | 秒级 | 适中 | 较稳 |
| E2E 测试 | 用户视角全流程 | 秒~分钟 | 最少 | 最脆 |
金字塔的核心结论:底大顶小。单元测试覆盖率高,E2E 只覆盖关键路径,才能在速度与信心之间取得平衡。
2. 单元测试的价值
- 快速反馈:一次改动,秒级得知哪里被破坏。
- 保护重构:重构(见《代码规范与重构》)没有测试护航就是裸奔。
- 充当文档:用例即"输入 → 期望输出"的活文档。
- 驱动设计:写不出来的代码往往耦合太重、不易测试,反过来倒逼设计。
二、测试框架选择:Jest vs Vitest
| 对比维度 | Jest | Vitest |
|---|---|---|
| 底层 | 自研 runner + jsdom | 复用 Vite 构建,原生 ESM |
| 启动速度 | 较慢(全量转译) | 极快(esbuild 预构建) |
| 配置 | 较多且偏重 | 极简,继承 Vite 配置 |
| TS 支持 | 需要 babel/ts-jest | 开箱即用 |
| 生态 | 最成熟、资料最多 | 快速追赶,兼容 Jest API |
| 适用 | 老项目、非 Vite 项目 | Vite 系新项目(Vue3 官方推荐) |
两个框架的 API 高度兼容(describe/test/expect),学会一个迁移成本极低。下文以 Jest 语法为例,Vitest 可几乎原样使用。
三、Jest 基本使用
1. 安装与配置
npm install --save-dev jest
# package.json 中配置
# "scripts": { "test": "jest", "test:watch": "jest --watch" }
# 默认匹配 *.test.js / *.spec.js 文件2. test 与 expect 的基础结构
// math.js
export function add(a, b) { return a + b; }
// math.test.js
import { add } from "./math.js";
test("add 返回两数之和", () => {
expect(add(1, 2)).toBe(3);
});
// it 是 test 的别名,语义上强调"它应该……"
it("add 支持负数", () => {
expect(add(-1, -2)).toBe(-3);
});3. toBe 与 toEqual 的区别
| 匹配器 | 比较方式 | 适用 |
|---|---|---|
toBe | Object.is 严格相等(引用比较) | 数字、字符串、布尔、同引用对象 |
toEqual | 递归比较值是否相等 | 普通对象、数组 |
toStrictEqual | 值相等且类型/结构完全一致 | 需要严格结构校验时 |
test("引用与值的区别", () => {
expect({ a: 1 }).not.toBe({ a: 1 }); // 不同引用 → 不通过断言逻辑,见下方说明
expect({ a: 1 }).toEqual({ a: 1 }); // 值相同 → 通过
});4. Matchers 大全
| 类别 | 匹配器 | 示例 |
|---|---|---|
| 相等 | toBe、toEqual | expect(x).toBe(3) |
| 真值 | toBeTruthy、toBeFalsy、toBeNull、toBeUndefined | expect(fn()).toBeNull() |
| 数字 | toBeGreaterThan、toBeLessThanOrEqual | expect(n).toBeGreaterThan(0) |
| 浮点 | toBeCloseTo | expect(0.1 + 0.2).toBeCloseTo(0.3) |
| 字符串 | toMatch | expect("hello").toMatch(/ell/) |
| 数组/可迭代 | toContain、toHaveLength | expect(list).toContain(2) |
| 对象 | toHaveProperty、toMatchObject | expect(u).toHaveProperty("name", "小明") |
| 异常 | toThrow | expect(() => f()).toThrow("非法") |
| 反向 | .not 前缀 | expect(x).not.toBe(0) |
test("匹配器综合示例", () => {
expect(0.1 + 0.2).toBeCloseTo(0.3);
expect("hello world").toMatch("world");
expect([1, 2, 3]).toContain(2);
expect(() => JSON.parse("{")).toThrow();
});四、describe 分组与生命周期钩子
1. describe 组织用例
describe("计算器", () => {
describe("加法", () => { // 可以嵌套分组
test("正数相加", () => expect(add(1, 2)).toBe(3));
test("负数相加", () => expect(add(-1, -2)).toBe(-3));
});
describe("除法", () => {
test("除以零抛错", () => expect(() => div(1, 0)).toThrow());
});
});2. 生命周期钩子
| 钩子 | 执行时机 | 典型用途 |
|---|---|---|
beforeAll | 本组所有用例前一次 | 连接数据库、初始化全局资源 |
afterAll | 本组所有用例后一次 | 清理资源、断开连接 |
beforeEach | 每个用例前 | 重置状态、构造测试数据 |
afterEach | 每个用例后 | 清理副作用、恢复 mock |
let db;
beforeAll(async () => { db = await connectDB(); });
afterAll(async () => { await db.close(); });
beforeEach(() => { db.reset(); });
afterEach(() => { jest.restoreAllMocks(); });关键原则:用例之间必须相互独立,beforeEach 负责重置,保证任何用例可以单独运行、随机顺序运行结果一致。
五、mock 与 spy
1. jest.fn:替换函数
const mockFn = jest.fn().mockReturnValue(42);
mockFn(1, 2);
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith(1, 2);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn()).toBe(42);常用断言:toHaveBeenCalled、toHaveBeenCalledWith、toHaveBeenCalledTimes、toHaveLastCalledWith。
2. jest.mock:替换整个模块
// api.js —— 真实实现会发网络请求
export const fetchUser = id => fetch(`/user/${id}`).then(r => r.json());
// userService.js —— 被测代码
import { fetchUser } from "./api.js";
export async function getUserName(id) {
const u = await fetchUser(id);
return u.name;
}
// userService.test.js —— 模拟 api 模块,不碰网络
jest.mock("./api.js", () => ({
fetchUser: jest.fn(),
}));
import { fetchUser } from "./api.js";
import { getUserName } from "./userService.js";
test("getUserName 返回用户名", async () => {
fetchUser.mockResolvedValue({ name: "小明" });
await expect(getUserName(1)).resolves.toBe("小明");
expect(fetchUser).toHaveBeenCalledWith(1);
});| API | 用途 |
|---|---|
jest.fn() | 替换单个函数,记录调用 |
jest.mock(module, factory) | 整体模拟模块 |
jest.spyOn(obj, "method") | 保留原实现,只监听调用 |
mockResolvedValue / mockRejectedValue | 设置 Promise 返回值 |
mockImplementation | 自定义实现 |
jest.useFakeTimers() | 启用假定时器 |
jest.restoreAllMocks() | 还原所有 spy |
// spyOn:既想调用真实方法,又想统计调用次数
const spy = jest.spyOn(console, "log");
console.log("hi");
expect(spy).toHaveBeenCalledWith("hi");六、测试异步代码
1. 三种写法
// 写法一:直接 return Promise,Jest 会等待其完成
test("promise 风格", () => {
return fetchData().then(data => expect(data).toBe("ok"));
});
// 写法二:async/await(推荐)
test("async 风格", async () => {
await expect(fetchData()).resolves.toBe("ok");
});
// 写法三:done 回调(老式 API 或事件场景)
test("done 风格", (done) => {
fetchData((data) => {
try { expect(data).toBe("ok"); done(); }
catch (err) { done(err); } // 必须显式把错误交给 done
});
});注意:done 写法中如果回调从未触发,用例会一直挂起到超时;断言失败也务必传给 done(err),否则可能误报通过。
2. Fake Timers:测试定时器逻辑
jest.useFakeTimers(); // 开启假定时器
test("debounce 延迟触发", () => {
const fn = jest.fn();
debounce(fn, 300)();
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(300); // 快进 300ms
expect(fn).toHaveBeenCalledTimes(1);
});常用 API:jest.advanceTimersByTime(ms)、jest.runAllTimers()、jest.setSystemTime(date)。
七、覆盖率
1. 五个核心指标
| 指标 | 含义 |
|---|---|
% Stmts | 语句覆盖率 |
% Branch | 分支覆盖率(if/else 每个分支都跑到) |
% Funcs | 函数覆盖率 |
% Lines | 行覆盖率 |
% Uncovered | 未覆盖行占比(越低越好) |
2. 配置与门槛
{
"jest": {
"collectCoverageFrom": ["src/**/*.{js,ts}"],
"coverageDirectory": "coverage",
"coverageThreshold": {
"global": { "lines": 80, "statements": 80, "branches": 70, "functions": 80 }
}
}
}jest --coverage # 生成覆盖率报告(HTML 位于 coverage/ 目录)覆盖率是参考而非目标:关键业务逻辑应追求高覆盖,UI 模板与简单透传代码不必强求 100%。低覆盖率说明测试薄弱,但"凑数的高覆盖率"同样有害。
八、TDD 流程:红 → 绿 → 重构
| 步骤 | 动作 | 状态 |
|---|---|---|
| 红 | 先写失败的测试 | 运行:失败(红色) |
| 绿 | 写最少代码让测试通过 | 运行:通过(绿色) |
| 重构 | 在测试保护下改进代码质量 | 运行:依然通过 |
// 第一步:先写测试(红)
test("isAdult 年龄满 18 返回 true", () => {
expect(isAdult({ age: 18 })).toBe(true);
expect(isAdult({ age: 17 })).toBe(false);
});
// 第二步:写最简实现(绿)
function isAdult(user) { return user.age >= 18; }
// 第三步:重构 —— 有测试兜底,放心优化
function isAdult(user) { return user?.age >= 18; }TDD 的价值在于测试先行倒逼接口设计:先想清楚"输入什么、期望什么",函数签名自然清晰;同时天然保证每个行为都有测试覆盖。
九、测试组织与命名
| 建议 | 示例 |
|---|---|
测试文件与被测文件同目录或 __tests__ | src/math.js → src/math.test.js |
| 用例名描述"行为"而非"实现" | getUserName 返回昵称 而非 调用 fetch 的第 3 行 |
| 一个 describe 一个主题 | describe("购物车") |
| 断言聚焦一个行为 | 一个用例少而准的断言 |
| 不测实现细节 | 断言输出结果,不断言内部调用了哪些私有函数 |
十、组件测试简介
组件测试的核心思路:渲染组件 → 模拟交互 → 断言结果。Vue 与 React 各有官方测试工具。
// Vue 3 + @vue/test-utils
import { mount } from "@vue/test-utils";
import Counter from "./Counter.vue";
test("点击按钮计数加一", async () => {
const wrapper = mount(Counter);
await wrapper.find("button").trigger("click");
expect(wrapper.text()).toContain("1");
});// React + @testing-library/react
import { render, screen, fireEvent } from "@testing-library/react";
import Counter from "./Counter";
test("点击按钮计数加一", () => {
render(<Counter />);
fireEvent.click(screen.getByText("加一"));
expect(screen.getByTestId("count")).toHaveTextContent("1");
});组件测试要点:优先从用户视角断言(找按钮文本、找可见结果),而不是查询内部状态;涉及网络请求的组件一律 mock API 层。
小结
单元测试的完整体系可以浓缩为四个问题:测什么(金字塔底部的最小单元)、用什么测(Jest/Vitest 的断言与匹配器)、怎么隔离(mock 掉外部依赖)、跑多快多稳(覆盖率门槛与 CI 集成)。把这套能力变成日常习惯后,重构会变得安全、发布会变得自信,这正是测试驱动工程化的意义所在。