单元测试编写
为业务逻辑编写真正有效的单元测试——完整的文件结构、5 种常见反模式(含 before/after 代码对比)、async/await 正确处理、参数化测试(test.each)与 Snapshot 维护策略。
「行覆盖率 90%」不等于测试有效——没有 expect 的用例、永远通过的断言,依然能贡献覆盖率。本页的目标是让每条用例都能在行为变坏时精准报红。
在 SKILL 中除了声明框架名(Jest / Vitest / Mocha)外,还应写清:测试文件放在哪(__tests__/ 目录 vs *.spec.ts 同级)、复用哪个 factory 文件路径,以及「哪些代码不需要单测」(Express 路由注册、ORM 迁移脚本、纯配置文件等),避免 Agent 在无意义处补测。
- 每个公共行为:至少 1 条正向路径 + 1 条边界或异常路径。
- 命名遵循
it('should <预期行为> when <条件>'),失败报告直接可读。 - 复用仓库内 factory / builder,不在每条用例里手写
{ id: 1, name: 'test' }。
测试金字塔与分层
单元测试应占套件总数的 70% 以上,目标是每次 push 的 CI 反馈在 60 秒内完成;集成测试数量控制在个位数到二十条;E2E 只保留 5–10 个最高商业价值的场景。数量比例参考:单元 200 条 / 集成 15 条 / E2E 7 条——逆金字塔(E2E 过多)会让 PR 反馈从 1 分钟退化到 30 分钟。
┌─────────┐
╱ E2E ╱│ 5-10 条:关键用户旅程(注册/支付/核心流)
╱────────╱ │ 慢(分钟级),只在 main 分支跑完整套件
╱ 集成 ╱ │
╱────────╱ │ 10-20 条:真实 DB/HTTP/队列边界
╱ 单元 ╱ │ 在 PR 上跑,每条 < 50ms
╱────────╱ │
────────── └─ 200+ 条:纯逻辑、mock 外部依赖,全套 < 60s
完整测试文件结构与 5 个反模式
标准的测试文件结构:用 describe 按功能分组、beforeEach 集中构造共用 fixture、每条 it 保持单一行为场景。下面是一个完整的、可直接运行的示例(Jest / Vitest 风格):
// src/billing/__tests__/computeTotal.test.ts
import { computeTotal } from '../computeTotal';
import { buildCart, buildItem } from '../../test/factories';
describe('computeTotal', () => {
let baseCart: Cart;
beforeEach(() => {
baseCart = buildCart({ items: [buildItem({ price: 100, qty: 2 })] });
});
describe('with tax rate', () => {
it('should apply tax on top of subtotal', () => {
// Arrange
const taxRate = 0.1;
// Act
const result = computeTotal(baseCart, taxRate);
// Assert
expect(result).toBe(220); // 200 * 1.1
});
it('should return subtotal when tax rate is 0', () => {
expect(computeTotal(baseCart, 0)).toBe(200);
});
});
describe('edge cases', () => {
it('should return 0 for empty cart', () => {
expect(computeTotal(buildCart({ items: [] }), 0.1)).toBe(0);
});
it('should throw when tax rate is negative', () => {
expect(() => computeTotal(baseCart, -0.1)).toThrow('Invalid tax rate');
});
});
});
5 个高频反模式(before / after 对比):
-
反模式 1:测试实现细节而非行为
❌expect(service._cache.has('key')).toBe(true)(内部私有字段)
✅expect(await service.get('key')).toEqual(expectedData)(可观测输出) -
反模式 2:一个用例断言多个不相关行为
❌ 一个it里同时断言返回值、副作用调用、日志输出
✅ 拆分成三条独立的it,每条只失败于一个原因 -
反模式 3:用
donecallback 处理异步(容易遗漏调用导致永远通过)
❌it('should fetch', (done) => { fetchUser(1).then(u => { expect(u.id).toBe(1); done(); }); })
✅it('should fetch', async () => { const u = await fetchUser(1); expect(u.id).toBe(1); }) -
反模式 4:Mock 过于深入,测试只在测替身
❌ Mock 被测函数内部调用的每一个子函数
✅ 只 Mock 进程外边界(HTTP、DB、时钟),让内部逻辑真实运行 -
反模式 5:用全局 Mock 状态造成用例顺序耦合
❌ 在describe顶层jest.mock(...)后从不重置,第 3 条用例依赖第 2 条设置的状态
✅ 在beforeEach里显式设置每条用例的 mock 返回值
参数化测试(test.each):适合同一逻辑需要验证多组输入输出的场景,避免复制粘贴用例体。
// test.each:验证折扣计算的多种场景
test.each([
[0, 100, 100], // [discountRate, price, expected]
[0.1, 100, 90],
[0.5, 200, 100],
[1, 50, 0],
])(
'should apply %s discount to %s → %s',
(rate, price, expected) => {
expect(applyDiscount(price, rate)).toBe(expected);
}
);
Snapshot 测试:适用场景与维护策略:
- ✅ 适合:UI 组件渲染输出(React/Vue 组件树)、复杂序列化对象(API 响应结构)的回归检测。
- ❌ 不适合:频繁变动的组件、包含时间戳或随机 ID 的输出(会产生大量无意义的 diff)。
- 维护规则:Snapshot 文件必须随代码一起 commit;PR 中 Snapshot 变更需人工审查是否符合预期,不可无脑
--updateSnapshot。 - 大型 Snapshot(超过 50 行)通常意味着组件职责过重——考虑拆分或改用精确断言(
expect(el).toHaveTextContent('...'))。
Mock:宜与忌
在 SKILL 中写清 mock / stub / fake 的选用边界。Mock 的目的是隔离进程外依赖,而不是绕过测试难度。每次引入 mock 时问自己:「如果我把这个 mock 去掉,测试会不会变成集成测试?」若答案是否,说明这个 mock 是多余的。
| 宜做 | 忌做 |
|---|---|
| 隔离 I/O、时钟、随机数、第三方 SDK 等进程外依赖 | Mock 被测单元内部的私有函数或为了测试改生产可见性 |
| 用 fake 表达可读的领域行为(内存版仓储) | 过度 mock 导致测试与真实类型/协议漂移仍能通过 |
| 断言「调用了什么、带什么参数」(对协作边界) | 断言实现细节(私有字段、调用顺序无关紧要的内部步骤) |
| 每个用例内明确设置替身返回值,避免隐式全局 | 在 afterEach 里大面积 resetMocks 却不重建场景,掩盖用例间泄漏 |
异步用 await / 框架 flush,保证断言在微任务之后 |
裸 setTimeout 猜延迟、依赖机器速度 |
本页小工具:用例名与 Fixture 序号
将 snake_case 用例名转为可读标题(便于文档或报告);下方 Fixture 计数器用于本地起名的序号草稿,与仓库内真实 fixture 无关。
Fixture 草稿序号
fixture_000
---
name: unit-testing-cn
description: 为业务模块编写隔离、可重复的单元测试与断言
---
# 要点
1. 金字塔:单测为主,集成/E2E 覆盖装配与旅程
2. 结构:AAA 或 GWT,Act 保持单一职责
3. Mock 边界:替身对外部依赖,不断言无关实现细节
4. 覆盖:正常、边界、错误与异步可控