测试真正解决的问题
单元测试最常被误解的用途是「证明代码是对的」。它证明不了——测试只能证明你想到的那些情况是对的。
它真正的价值是在你修改代码时给你信心:改完之后跑一遍,如果全绿,说明你没有破坏原有行为。
所以测试的衡量标准不是覆盖率,而是:它能不能在代码被改坏时失败。
测行为,而不是测实现
这是写好测试的第一原则。看一个反例:
// 不好:断言的是内部实现细节
test("计算总价", () => {
const cart = new Cart();
cart.addItem({ price: 10 });
expect(cart.items.length).toBe(1); // 测了内部结构
expect(cart.subtotal).toBe(10); // 测了内部字段名
});
// 好:断言的是对外可观察的行为
test("加入一件 10 元商品后,总价为 10 元", () => {
const cart = new Cart();
cart.addItem({ price: 10 });
expect(cart.total()).toBe(10);
});
区别在于:把 items 从数组改成 Map 时,第一个测试会失败(但其实功能没坏),第二个测试仍然通过。
会因重构而失败的测试,是负债,不是资产。
一个测试的结构
经典结构是「准备 → 执行 → 断言」三段:
test("余额不足时提现失败", () => {
// 准备:构造初始状态
const account = { balance: 50 };
// 执行:调用被测行为
const result = () => withdraw(account, 100);
// 断言:确认结果符合预期
expect(result).toThrow("余额不足");
expect(account.balance).toBe(50); // 失败时不应该扣钱
});
三段之间用空行隔开,读的人一眼能看出测试在做什么。
命名要说清场景
测试名应该描述在什么条件下、期望什么结果:
| 差 | 好 |
|---|---|
test("test1") | 余额不足时提现失败 |
test("withdraw") | 提现金额超过余额时抛出异常 |
test("should work") | 金额为 0 时不做任何操作 |
一个好的检验方法:测试失败时,只看名字能不能猜出哪里坏了。如果名字是 test1,你还得打开代码看。
每个测试只断言一件事
一个测试里断言五个不相关的点,失败时你只知道「有问题」,不知道是哪一个。
更好的做法是按行为拆分,而不是按函数拆分:
// 一个函数对应多个行为,就该有多个测试
test("金额为负数时抛出异常", ...);
test("金额超过余额时抛出异常", ...);
test("金额合法时正确扣减余额", ...);
这不是重复,而是让每个失败都指向唯一原因。
mock 的代价
mock 用来隔离外部依赖,但它有代价:mock 越多,测试离真实行为越远。
// 过度 mock:几乎把所有依赖都替换了
jest.mock("../db");
jest.mock("../cache");
jest.mock("../logger");
jest.mock("../http");
这样的测试只能验证「调用顺序对不对」,验证不了真实逻辑。一旦 mock 的接口签名变了,测试全红,但代码其实没错。
我的原则是:只 mock 真正不可控的东西。
| 依赖类型 | 是否 mock | 理由 |
|---|---|---|
| 数据库 | 集成测试用真库,单元测试可 mock | 真实 SQL 行为 mock 不出来 |
| 第三方 API | mock | 不可控、有配额、可能收费 |
| 时间 | mock | 否则测试依赖执行时刻 |
| 随机数 | mock | 否则结果不可重现 |
| 自己写的纯函数 | 不 mock | 直接调用更真实 |
覆盖率的意义与陷阱
覆盖率是发现问题的手段,不是目标。
它真正有用的地方是找出完全没被测到的代码。一行都没覆盖的分支,很可能就是漏掉的场景。
陷阱在于:覆盖率可以用无意义的断言刷上去。
// 覆盖了这行代码,但什么都没验证
test("调用函数", () => {
calculateTotal([1, 2, 3]);
});
所以追求「覆盖率 100%」通常是有害的:它会诱导人们写这种没有断言的测试,浪费时间还制造虚假安全感。
我的建议是:核心业务逻辑要求高覆盖,工具函数和胶水代码不设要求。
什么代码不值得测
- 纯配置和常量:测了也只是把值抄一遍
- 框架生成的样板代码:框架自己的测试更可靠
- 一次性的脚本:跑一次就扔,测试成本高于收益
- 纯粹的 UI 样式:应该用视觉回归测试,不是单元测试
测试也要考虑投入产出比。把精力放在业务规则复杂、改动频繁的地方。
测试先行的真正好处
「先写测试再写实现」最大的好处不是设计更好,而是:你会被迫先想清楚接口长什么样。
写测试时必须回答:这个函数叫什么、接收什么参数、返回什么、失败时怎么办。这些想清楚了,实现往往很快。
反过来,先写实现常常会写出一个「顺手但难用」的接口。
小结
好的单元测试有三个特征:测行为不测实现、命名说清场景、失败时能直接定位原因。
记住两句话:覆盖率是手段不是目标;会因重构而失败的测试是负债。