2743 字
约 9 分钟
17
S16-J05 · 测试替身:Stub、Mock、Fake 与它们的失效方式
2024-12-31
2026-11-09

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. 自测

  1. 什么时候该用 stub 而不是 mock?判据是哪一列?
  2. verify(repo, times(1)).save(...) 为什么会在重构时误报?
  3. @Spy 对象只打桩一个方法时,其余方法为什么会带着初始字段跑?
  4. mockStatic 未关闭会怎样影响后续测试?
  5. 用 mock 测"唯一键冲突"为什么没有意义?
  6. 什么情况下"为了可测而改设计"是正当的,什么情况下不是?
S16-J05 · 测试替身:Stub、Mock、Fake 与它们的失效方式
https://www.clxhxhhr.top/posts/6038/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。