测试写完第一次跑就全绿,之后每次改代码还是靠手点页面确认——这不是测试没写好,是这些测试本来就没在验证任何东西。判断一条测试值不值,只问一句:把被测代码改坏,它会不会红。会红才叫回归保护,不会红就是装饰。
1. 测试要提供三件事
| 要提供的东西 | 具体形式 | 缺失后的症状 |
|---|---|---|
| 回归保护 | 行为被改坏时自动变红 | 改一行代码先全量手测 |
| 设计反馈 | 难测 → 耦合暴露 | 构造方法里 new 出一堆客户端 |
| 文档 | 读测试就知道用法与边界 | 只能读实现反推语义 |
对应的无效测试长这样:
@Test
void getterWorks() {
User u = new User("a", 1);
assertEquals("a", u.getName()); // 只证明 Lombok 生成了方法
}
一个只测 getter/setter 的套件覆盖率可能很好看,但它对任何真实回归都不敏感。
2. AAA 结构与最小示例
Arrange(准备)→ Act(执行)→ Assert(断言),三段各自独立,一个测试只做一次 Act。
class DiscountServiceTest {
@Test
void vip_满三百_打八折() {
// Arrange
DiscountService svc = new DiscountService(new FixedClock("2026-01-01T00:00:00Z"));
Order order = new Order(ROLE_VIP, new BigDecimal("300.00"));
// Act
BigDecimal payable = svc.payable(order);
// Assert
assertEquals(new BigDecimal("240.00"), payable);
}
}
def test_vip_over_300_gets_20_percent_off():
svc = DiscountService(clock=FrozenClock("2026-01-01T00:00:00Z")) # Arrange
payable = svc.payable(Order(role="vip", amount=Decimal("300.00"))) # Act
assert payable == Decimal("240.00") # Assert
两处 Act 意味着这是两个测试;两处 Assert 针对不同行为,同样应该拆开——拆开后失败信息才能直接指出哪个行为坏了。
3. 断言该断言什么
| 断言对象 | 适用 | 反例 |
|---|---|---|
| 返回值 | 纯函数、计算类 | —— |
| 状态变化 | 有副作用的写操作 | 只看返回值,不查落库 |
| 交互次数 | 边界处(发消息、调第三方) | 对内部私有方法做 verify |
| 抛出的异常 | 校验与失败路径 | 只测成功路径 |
过度断言的代价最容易被忽略:
// 脆弱:重构一次实现就要改测试
verify(repo, times(3)).findById(any());
verify(logger).debug("cache miss for {}", "u1");
断言实现细节(内部调用顺序、日志文案、私有方法调用)会让每次重构都变成测试重写,于是团队开始顺手删测试。断言对外可见的契约,不追内部过程。
4. 四种测试替身与各自的误用
| 替身 | 行为 | 典型用途 | 误用 |
|---|---|---|---|
| Stub | 返回固定值 | 隔离外部依赖 | 用它模拟复杂业务逻辑 |
| Mock | 记录调用、可校验 | 验证边界交互 | 对每个协作者都 verify |
| Fake | 有可用的轻量实现 | 内存版仓储 | 让它和真实实现行为不一致 |
| Spy | 包装真实对象记录调用 | 保留真实现再观察 | 对已 mock 的对象再套一层 |
class InMemoryUserRepo: # Fake:行为可用,不是摆设
def __init__(self):
self._rows = {}
def save(self, user):
self._rows[user.id] = user
def by_id(self, uid):
return self._rows.get(uid)
选择顺序:能用 Fake 就不用 Mock。Fake 校验的是行为结果,Mock 校验的是调用方式;调用方式改起来频繁,行为改起来少。
5. 命名与组织:一个行为一个测试
void 空购物车_结算金额为零() {}
void 优惠券过期_不参与计算() {}
void 库存不足_抛出库存异常() {}
名字里写清三件事:条件、动作、预期。读测试列表就像读需求清单,执行失败时不必打开 IDE 就知道坏了哪条。
组织上按被测单元分文件,别按层级分(不建 test/service/impl/internal/xxx 这种目录)。测试和被测算同一个包名下,只测 public 行为,private 方法通过 public 入口间接覆盖。
6. 什么时候不该写单测
| 场景 | 更合适的手段 |
|---|---|
| 框架胶水(配置类、注解映射) | 启动一次集成测试即可 |
| 纯配置与常量表 | 抽查或用校验脚本 |
| 只做转发的 Controller | 集成测试或契约测试 |
| 一次性数据修复脚本 | 跑一次看结果,写完即弃 |
| 生成代码 | 不测生成物,测生成器 |
把力气花在分支多、逻辑密、回归代价高的地方。判断入口:这段代码改错了,多久能被发现?越晚,越值得写单测。
7. 可测性设计:三根硬骨头
| 不可测的写法 | 改法 |
|---|---|
方法内 new 依赖 |
构造注入,接口类型接收 |
LocalDateTime.now() |
注入 Clock / now 函数 |
| 静态工具类持有可变状态 | 去掉静态状态,改为实例或参数传入 |
Random 直接调用 |
注入种子或随机源 |
| 单例缓存与全局配置 | 显式传入,或提供重置入口 |
class ReportService {
private final Clock clock;
private final MailClient mail;
ReportService(Clock clock, MailClient mail) { // 依赖从外面进来
this.clock = clock; this.mail = mail;
}
}
依赖注入不是为了框架好看,它让"时间、随机数、网络"这三样最容易带来不确定性的东西变成可替换参数。
8. 覆盖率的正确用法
覆盖率回答的是"哪些分支从没被执行过",它的正确用法是当警戒下限:
- 看未覆盖列表,挑出高风险代码补用例;
- 看分支覆盖率而非行覆盖率,
if只有一条路走过时行覆盖仍然很高; - 设下限(低于阈值构建失败),不设上限与目标(当 KPI 会催生无断言测试)。
按 JaCoCo / coverage.py 的默认口径核对阈值即可,不要追求数字本身。
9. 速度:反馈闭环决定用不用
单测的价值靠被频繁运行兑现。典型量级上,一个健康套件跑完在秒级,单条在毫秒级。
| 层级 | 内容 | 运行时机 |
|---|---|---|
| 单测 | 纯内存、无 IO | 保存文件即跑 |
| 组件/集成 | 起容器、连真实依赖 | 提交前、CI |
| 端到端 | 真实环境链路 | 合并后、定时 |
$ pytest -q -m "not integration" # 本地只跑快的
$ pytest -q --durations=10 # 揪出拖慢套件的十条
一旦"按保存就全量跑"变得不可忍受,团队就会退回到不跑。套件速度是设计约束的一部分。
10. 参数化与边界值选取
边界值从"空、单、多、极值、非法"五个方向取:
import pytest
@pytest.mark.parametrize("amount,expected", [
(None, Decimal("0")), # 非法/缺失
(Decimal("0"), Decimal("0")), # 空
(Decimal("299.99"), Decimal("299.99")), # 单(未过阈)
(Decimal("300.00"), Decimal("240.00")), # 极值(阈值点)
(Decimal("100000"), Decimal("80000")), # 多
])
def test_payable_boundaries(amount, expected):
assert discount_service.payable(Order("vip", amount)) == expected
参数化不是省行数,是把"同一逻辑的不同输入"显式列出来,边界一眼可见。
11. 常见坑
| 现象 | 根因 | 处理 |
|---|---|---|
| 单独跑过,全量跑红 | 测试顺序依赖 | 每条测试自建数据,不依赖前一条 |
| 偶发失败 | 共享可变状态 | 每例新建对象,静态成员放 @BeforeEach 清理 |
| 慢且不稳 | 用 sleep 等异步 |
轮询带超时,或注入可控时钟/调度器 |
| 重构就大面积红 | 断言了实现细节 | 改断对外可见行为 |
| 测试数量涨、缺陷照旧 | 只覆盖 happy path | 补失败路径与边界 |
12. 边界:什么该交给别的层
| 想验证的东西 | 不该用单测 | 该用 |
|---|---|---|
| SQL 是否正确 | 内存替身测不出方言与索引 | 集成测试 + 真实库 |
| 事务与并发 | 单线程跑不出竞态 | 并发测试 / 故障注入 |
| 序列化与框架绑定 | mock 掉就等于没测 | 契约测试 |
| 跨服务调用 | 双方都 mock 就谁都对 | 契约测试 / E2E |
13. 一个带依赖的服务怎么测
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock PaymentClient payment;
@Mock OrderRepo repo;
@InjectMocks OrderService svc;
@Test
void 支付成功_订单状态改为已支付() {
when(payment.charge(any())).thenReturn(new ChargeResult(true, "OK"));
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
Order paid = svc.pay(new Order("o1", new BigDecimal("10.00")));
assertEquals(OrderStatus.PAID, paid.status());
verify(payment).charge(any()); // 边界交互断言一次就够
}
}
def test_pay_marks_order_paid(fake_repo, stub_payment):
svc = OrderService(payment=stub_payment(ok=True), repo=fake_repo)
paid = svc.pay(Order(id="o1", amount=Decimal("10.00")))
assert paid.status == "PAID"
assert fake_repo.by_id("o1").status == "PAID" # 断言副作用
写完后做一次自检:把 if (ok) 的分支改成永远走 else,套件该红;把状态赋值删掉,套件该红。都红了,这套测试才真的在保护你。
14. 自测
- 怎样用一句话判断一条测试有没有价值?
- 一条测试里出现两次 Act,说明什么?
verify(repo, times(3)).findById(...)这类断言,为什么重构时会成为负担?- Fake 与 Mock 在"校验什么"上的区别是什么?
- 覆盖率高但缺陷漏得多,最可能的三种原因是什么?
- 想让"当前时间"可控,依赖注入改的是哪一处?