单元测试深度解析
单元测试三大支柱:断言、Mock、覆盖率,以及为什么"难以测试的函数往往是设计不良的函数"。
一句话概括
单元测试是把代码拆到最小可测试单元(函数、模块、Hook、reducer),验证”给定输入 → 是否产出预期输出”。它的三大支柱是断言(Assertion,检查行为)、Mock(模拟,隔离依赖)、覆盖率(Coverage,度量测试完整性)。单元测试的价值不只是抓回归 bug,更在于它是”可测试性设计”的镜子——一个很难写测试的函数,往往本身就是一个耦合过重、职责不清的坏函数。
核心知识点
1. 断言:用对 Matcher,测试即文档
Jest 的断言方法(Matcher)选对了,测试本身就是一份可读的行为文档:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 基本值
expect(sum(1, 2)).toBe(3); // 基本类型用 toBe(===)
expect({ id: 1, name: '张三' }).toEqual({ id: 1, name: '张三' }); // 对象用 toEqual(深比较)
// 真值/空值/包含
expect(user.name).toBeTruthy();
expect(list).toHaveLength(3);
expect(list).toContain(3);
expect(users).toContainEqual({ id: 1, name: '张三' }); // 对象数组部分匹配
// 异常(注意要传函数引用,不能直接调用)
expect(() => { throw new Error('参数错误'); }).toThrow('参数错误');
// 异步异常
await expect(asyncFn()).rejects.toThrow('异步错误');
易错点:toBe 对对象比较的是引用(两个字面量对象即使内容相同也 fail),必须用 toEqual;断言异常要传 () => fn() 而不是 fn()(后者会在断言前就把异常抛出去让测试直接崩)。
2. Mock:隔离依赖,但别 Mock 过头
Mock 用来隔断被测代码对外部依赖(网络、数据库、时间、随机数)的依赖:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 1) Mock 函数:控制返回值 + 追踪调用
const fetchData = jest.fn().mockResolvedValue({ data: { id: 1 } });
await fetchData('/api/users/1');
expect(fetchData).toHaveBeenCalledWith('/api/users/1'); // 验证传参
expect(fetchData).toHaveBeenCalledTimes(1); // 验证次数
// 2) Mock 整个模块
import axios from 'axios';
jest.mock('axios');
const mocked = axios.get;
test('fetchUser 返回数据', async () => {
mocked.mockResolvedValue({ data: { id: 1, name: '张三' } });
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: '张三' });
});
// 3) 部分 Mock:保留模块真实实现,只替换某一个导出
jest.mock('./utils', () => ({
...jest.requireActual('./utils'), // 其余保持真实
generateId: jest.fn(() => 'fixed-id')
}));
// 4) Mock 时间与随机数
jest.useFakeTimers();
jest.spyOn(Math, 'random').mockReturnValue(0.5);
黄金法则:Mock 你拥有的边界,不 Mock 你测试的逻辑。如果你发现自己把被测函数自己也 Mock 了,说明测试设计错了——那样测的是 Mock 而不是真实代码。
3. 异步测试:三件容易踩的坑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 坑 1:async 函数用 await,别用返回 Promise 忘记 await
test('async', async () => {
await expect(fetchData()).resolves.toEqual({ id: 1 });
});
// 坑 2:setTimeout 定时器要用 fake timers,否则测试真的等 3 秒
jest.useFakeTimers();
const fn = jest.fn();
setTimeout(fn, 3000);
jest.advanceTimersByTime(3000); // 快进时间
expect(fn).toHaveBeenCalled();
jest.useRealTimers();
// 坑 3:回调式异步要手动结束,否则 Jest 可能提前判定通过
test('callback', (done) => {
loadData((data) => {
expect(data).toBeDefined();
done(); // 忘记 done 会超时或误报通过
});
});
4. 覆盖率:是手段不是目的
覆盖率衡量测试执行了多少生产代码,用 coverageThreshold 设门槛挡回归:
1
2
3
4
5
// jest.config.js
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
coverageThreshold: {
global: { branches: 80, functions: 85, lines: 85, statements: 85 }
}
但要清醒:高覆盖率 ≠ 高质量测试。expect(1).toBe(1) 这种断言能刷出 100% 行覆盖,却抓不住任何 bug。真正的质量看的是分支覆盖(每个 if/else 都测到)和边界条件(空数组、null、越界、并发),而不是行数数字。
5. 可测试代码的设计
单元测试逼你写出更好的代码。反推过来的设计原则:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 不可测试:内部直接 new 依赖 + 隐式全局 + 副作用
function saveUser(user) {
new ApiClient().post('/users', user); // 无法替换 ApiClient
globalThis.analytics.track('signup'); // 隐式依赖全局
fs.writeFileSync('./log.txt', user.name); // 硬编码副作用
}
// ✅ 可测试:依赖注入 + 纯逻辑与副作用分离
function saveUser(user, { api, track = () => {} }) {
const payload = validate(user); // 纯函数,可独立测
api.post('/users', payload);
track('signup');
}
// 测试时传 mock 的 api 和 track,逻辑清晰可断言
如果一个函数”测起来很痛苦”,通常意味着它同时干了太多事:取值、计算、IO 全混在一起。拆开就是重构。
其实你每天都在用
- Git 提交前跑 lint/test:CI 或 husky 的 pre-commit 钩子自动跑单元测试,测试不过连提交都不让,这就是你每天都在受”单元测试”保护
npm test的红绿反馈:改完代码跑一遍测试,看到全绿才敢合代码,红色就是告诉你”你刚刚改坏了某处”- 重构时的安全感:你敢大刀阔斧改函数签名,是因为有测试兜底,改完一跑就知道有没有破坏行为——没有测试的重构是盲拆炸弹
- Mock 掉支付/短信接口:开发时测试注册登录流程,不会真的给用户发短信、扣钱,靠的就是 Mock 把外部服务隔离开
- 覆盖率 badge 里的那个百分比:仓库 README 上那个”coverage 85%”徽章,就是 CI 每次跑完测试统计出来的覆盖率
常见误解(FAQ)
❌ 误区一:”单元测试能覆盖所有 bug,覆盖率越高越安全”
单元测试只验证单个单元的行为,测不出单元之间的集成问题(A 和 B 各自对,合起来错)。而且高覆盖率可能是 expect(1).toBe(1) 式的虚假覆盖。所以还有集成测试、E2E 测试来补单元测试的盲区,测试金字塔里单元测试打底、但绝不是全部。
❌ 误区二:”Mock 得越彻底,测试越稳定”
把 Date、Math、甚至被测函数自己都 Mock 掉,测试是”稳定”了,但也测不出真实行为了。过度 Mock 会让测试变成”验证 Mock 调用”的形式主义。正确姿势:Mock 外部边界(网络/DB/时间),真实逻辑尽量跑真的。
❌ 误区三:”单元测试是额外负担,拖慢开发速度”
短期看写测试花时间,但测试把 bug 的发现成本从”线上事故”提前到”本地秒级反馈”。而且测试驱动出的可测试代码本身就是更好的设计。投入 10 小时测试,往往能在数月内省下 30+ 小时的调试和线上救火。
❌ 误区四:”先写完功能,最后再补测试”
功能写完了才补测试,你会发现大量代码根本没法测(耦合太死、副作用太多),补测成本远超边写边测。TDD 或至少”写代码时同步写测试”才是正解——测试和实现应该是一起长出来的,不是后补的。
一句话总结
单元测试不是给代码上保险,而是给代码照镜子——断言验证行为、Mock 暴露耦合、覆盖率暴露盲区,一个写起来痛苦的测试,往往正在告诉你这段代码该重构了。