S16-J05 · 测试替身:Stub、Mock、Fake 与它们的失效方式
一个测试类里堆了六个 @Mock,断言全是 verify(repo, times(1)).save(...),被测方法体反而一行没被读——测试是绿的,但它锁住的是调用顺序,不是行为结果。重构一次,二十个测试同时报红。
问题从来不是"用不用替身",而是你要验证什么:结果,还是过程。
1. 五类替身的定义边界
| 替身 | 返回什么 | 执行真实逻辑 | 断言对象 | 写法 |
|---|---|---|---|---|
| dummy | 从不被读 | 否 | 不参与断言 | new Object() |
| stub | 你指定的值 | 否 | 返回值 | when().thenReturn() |
| spy | 真实结果,可局部覆盖 | 是(未打桩处) | 状态或交互 | spy(real) |
| mock | null/0/false |
否 | 调用 | mock()、@Mock |
| fake | 简化但真实的实现 | 是 | 状态 | 内存仓库 |
起作用的是两列:是否执行真实逻辑、断言结果还是断言调用。dummy 一出现在断言里就已经是 stub。
选型压成一句:验证"算得对不对"用 stub/fake;验证"有没有按约定通知别人"才用 mock。多数业务测试属前者,多数 @Mock 因此多余。
2. 状态验证 vs 行为验证
| 维度 | 状态验证 | 行为验证 |
|---|---|---|
| 断言什么 | 返回值与可达状态 | 调用、次数、参数 |
| 实现改动 | 通常不受影响 | 顺序或次数一变就红 |
| 失效方式 | 断言太弱,什么都通过 | 断言太强,无法演进 |
| 该用在哪 | 领域逻辑、计算、状态机 | 对外副作用:发消息、扣款 |
行为验证耦合实现,是因为调用方式本身就是实现细节。今天 save(entity),明天合并成 saveAll(...)——语义没变,测试全红。这类红不提供信息,只提供阻力。
它合理的场景很少,特征明确:这次调用本身就是业务结果,如"下单后必须发 OrderPlaced"、"重试三次后进死信队列"。此时验证的是对外契约,不是内部结构。
// 合理:验证对外副作用
verify(eventPublisher).publish(argThat(e -> e.type() == EventType.ORDER_PLACED));
// 可疑:改成 saveAll 就红,但业务没变
verify(orderRepository, times(1)).save(any());
3. Mockito 的能力面与常见误用
| API | 作用 | 误用形态 |
|---|---|---|
when/thenReturn |
打桩返回值 | 用 any() 匹配,真实契约从未被校验 |
verify(mock, times(n)) |
交互计数 | 固定次数,实现一变即红 |
ArgumentCaptor |
抓实参深断 | 只断言 notNull(),等于没断言 |
Answer/doAnswer |
自定义行为 | 把业务逻辑写进 Answer,替身成第二实现 |
@Mock |
全空实现 | 对值对象、JDK 类型使用 |
@Spy |
部分真实 | 只打桩一个方法,其余带初始字段跑 |
mockStatic |
静态方法替身 | 为一个 static 工具类把整类 mock 掉 |
@Spy 坑最集中:spy 的构造方法会真的执行,字段是初始值。只打桩 a(),调 b() 时它带着真实字段跑,读到 null 抛 NPE——栈指向业务代码,实际是测试搭错台。
mockStatic 的成本不在写法,而在它把静态依赖的不可测性从设计问题变成测试技巧。每块都要配 try-with-resources 关闭,漏关就污染后续测试。
try (MockedStatic<Clock> mock = mockStatic(Clock.class)) {
mock.when(Clock::systemUTC).thenReturn(fixed);
} // 必须关闭,否则污染同线程后续测试
4. 过度 mock 的六个症状
| 症状 | 现象 | 说明 |
|---|---|---|
| 改一行实现就红 | 提取方法、合并调用即失败 | 断言绑在调用结构上 |
| mock 了 JDK 类型 | mock(List.class) |
语义由 JDK 保证,mock 只引入失真 |
| 链式 mock 三层 | a.getB().getC() 全打桩 |
对象图过深,是设计信号 |
| 只验证次数 | verify(x, times(2)) 无状态断言 |
次数不是业务语义 |
| 每个测试 mock 全依赖 | setup 二十行,测试体三行 | 依赖太多或粒度太粗 |
| mock 与真实契约不符 | 真实返回空集合,mock 返回 null |
测试绿,线上 NPE |
最后一条最隐蔽:mock 的返回值是你写的,不是真实实现的行为。真实仓储查不到返回 Optional.empty(),mock 却 thenReturn(null)——测试恰好绕开真实边界,只能在集成层暴露。
链式 mock 里 when(a.getB().getC()).thenReturn(d) 的 a.getB() 在打桩时就被真实调用:a 是 mock 则返回 null,getC() 立刻 NPE。写成 RETURNS_DEEP_STUBS 能过,但那本身就是"该拆依赖"的告警。
5. Fake 的价值与它自己的测试
Fake 是有真实行为的最简实现:内存仓储、内存队列、可推进时钟。优势是断言结果、覆盖多条路径,不必为每个方法写 when。
final class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> store = new ConcurrentHashMap<>();
@Override public Order save(Order o) { store.put(o.id(), o); return o; }
@Override public Optional<Order> findById(String id) { return Optional.ofNullable(store.get(id)); }
@Override public List<Order> findByStatus(Status s) {
return store.values().stream().filter(o -> o.status() == s)
.sorted(Comparator.comparing(Order::createdAt)).toList();
}
}
findByStatus 里的显式 sorted 是重点:Fake 必须自带确定性语义,否则顺序随 HashMap 桶变化,测试变偶发红。
Fake 自身要测,这是它最大的隐性成本:
| Fake 的风险 | 后果 | 对策 |
|---|---|---|
| 语义漂移 | 测试绿、线上红 | 同一套契约测试跑真实实现与 Fake |
| 缺约束(唯一键、外键) | 测不出冲突 | 关键约束交给 Testcontainers |
| 线程安全差异 | 并发假绿 | 内部用并发结构并加测 |
| 分页语义简化 | 边界漏测 | 只对语义简单的接口用 |
契约复用是关键手法:同一组断言分别跑 Fake 与真实实现,行为一偏离就被自己的测试抓住。
6. 什么时候该用 Testcontainers
| 要验证的语义 | mock 能覆盖 | 该用什么 |
|---|---|---|
| SQL 方言、类型映射 | 否 | Testcontainers + 真实库 |
| 事务传播、回滚 | 否 | 同上,显式提交或回滚 |
| 唯一键 / 外键 / 死锁 | 否 | 真实库 |
| 序列化契约 | 否 | 真实 broker 或 schema 校验 |
| 索引行为(分词、排序) | 否 | 真实 ES 容器 |
| 纯业务分支 | 是 | stub / fake |
判据很干脆:要验证的语义若由依赖本身提供,替身就没有意义。mock JpaRepository 测"唯一键冲突抛什么异常",测的是你写的 thenThrow。
7. 时间与随机性的可控化
| 不可控源 | 症状 | 手段 |
|---|---|---|
now() / Instant.now() |
跨零点、时区边界偶发红 | 注入 Clock.fixed(...) |
currentTimeMillis() |
同上且无法冻结 | 统一走 Clock |
new Random() |
结果不可复现 | 注入 Random 或固定种子 |
randomUUID() |
断言里出现随机值 | 注入 Supplier<UUID> |
public final class CouponService {
private final Clock clock;
CouponService(Clock clock) { this.clock = clock; }
boolean expired(Coupon c) { return c.expiresAt().isBefore(clock.instant()); }
}
// 测试
var svc = new CouponService(Clock.fixed(Instant.parse("2026-11-09T00:00:00Z"), ZoneOffset.UTC));
Clock 注入几乎零成本,收益是时间相关测试从"偶尔红"变成"永远确定"。
8. 外部 HTTP 的替身选择
| 方案 | 控制粒度 | 契约校验 | 适用 |
|---|---|---|---|
| 手写 stub | 方法级 | 无 | 依赖是自己定义的接口 |
| MockWebServer | 请求/响应级,真走 HTTP | 部分 | 客户端、重试与超时 |
| WireMock | 匹配、延迟、故障注入 | 较强 | 第三方 API 契约 |
| 真实沙箱 | 完整 | 最强 | 上线前最后一层 |
三者都会漂移:替身记录的是你对第三方的假设。对方改字段名、加必填参数、调错误码,替身不知道。对关键依赖至少留一条打真实沙箱的测试,或用录制回放定期刷新桩数据。
// 验证重试行为,而不只是解析
server.enqueue(new MockResponse().setResponseCode(503));
server.enqueue(new MockResponse().setBody("{\"ok\":true}"));
assertThat(client.call()).isTrue();
assertThat(server.getRequestCount()).isEqualTo(2);
9. 可测性设计:替身之前先改结构
| 设计问题 | 症状 | 改法 |
|---|---|---|
构造里 new 依赖 |
无法替换 | 构造注入 |
| 逻辑与 IO 糊在一起 | 测计算必须 mock 网络 | 副作用推到边界 |
| 静态单例 | 必须 mockStatic |
改实例 + 注入 |
方法内 now() |
无法冻结时间 | 注入 Clock |
"为可测而改设计"是正当的:可测性差通常等于耦合度高,构造里 new 的依赖、静态单例、方法内取时间,同时也是并发与维护的痛点,解耦收益不只落在测试上。
但要守一条线:不要为测试往生产代码加测试专用分支或后门。if (testMode) 一旦存在,就会有人把它当功能用。
10. 排错:替身失灵时的典型现象
| 现象 | 原因 | 处理 |
|---|---|---|
UnnecessaryStubbingException |
打了桩但没走该路径 | 删多余 stub |
PotentialStubbingProblem |
参数匹配过窄 | 核对参数与重载 |
static mocking already registered |
上一处未关闭 | try-with-resources |
| spy 报 NPE 且栈指向业务 | spy 字段是初始值 | 用构造好的真实对象 |
verify 失败但逻辑正确 |
异步,断言早于调用 | Awaitility |
| 本地绿、集成红 | mock 与契约不符 | 补真实依赖测试 |
11. 边界:不该用替身的地方
| 场景 | 更合适的做法 |
|---|---|
| 值对象与不可变集合 | 直接用真实实例 |
| 真实实现比替身更快 | 用真实现 |
语言运行时(List、Optional) |
不替身 |
| 要验证 SQL、事务、索引 | Testcontainers |
| 要验证跨服务契约 | 契约测试 |
| 遗留静态工具类 | 先包一层可注入门面 |
最后一行的顺序很重要:先包一层,再替身。直接 mockStatic 让测试依赖 inline mock maker,Java 升级或 JVM 参数变化都会带来额外风险。
12. 自测
- 什么时候该用 stub 而不是 mock?判据是哪一列?
verify(repo, times(1)).save(...)为什么会在重构时误报?@Spy对象只打桩一个方法时,其余方法为什么会带着初始字段跑?mockStatic未关闭会怎样影响后续测试?- 用 mock 测"唯一键冲突"为什么没有意义?
- 什么情况下"为了可测而改设计"是正当的,什么情况下不是?