一个测试类里堆了六个 @Mock,断言全是 verify(repo, times(1)).save(...),业务方法体反而一行没被读——这是替身用错时的典型形态。测试通过了,但它锁住的是调用顺序,不是行为结果。重构一次,二十个测试一起报红。
替身不是"把依赖换成假的"。五种替身回答不同的问题,选错一种,测试就从保护重构变成阻碍重构。
1. 五种替身的差别
| 替身 | 返回什么 | 是否执行真实逻辑 | 断言方式 | 典型写法 |
|---|---|---|---|---|
| dummy | 无所谓,从不被读 | 否 | 不参与断言 | new Object()、List.of() |
| stub | 你指定的值 | 否 | 状态断言 | when(x).thenReturn(v) |
| spy | 真实结果,可局部覆盖 | 是(未打桩的方法) | 状态或交互 | spy(real)、@Spy |
| mock | 默认 null/0/false |
否 | 交互断言 | mock()、@Mock |
| fake | 简化但真实的实现 | 是(自己的实现) | 状态断言 | 内存仓库、H2、FakeClock |
关键区别只有两列:是否执行真实逻辑、断言结果还是断言调用。dummy 只负责让参数列表合法,一旦在断言里引用它,它就已经是 stub 了。
2. 什么时候不该用替身
| 情况 | 为什么 | 该怎么做 |
|---|---|---|
| 协作者是值对象 | mock 掉 BigDecimal、Money,equals 语义失真 |
用真实例 |
| 真实现更快 | 内存 Map 比 mock 的设置还便宜 |
直接用真实现 |
| 依赖是语言运行时 | mock List、Optional 只会引入噪声 |
不 mock |
| 需要验证 SQL、映射、事务 | 替身里没有方言、没有约束、没有传播 | @DataJpaTest + H2,或 Testcontainers |
| 替身比被测代码更复杂 | 测试维护成本超过收益 | 改成集成测试 |
判据:写完替身设置代码后你已经不确定被测逻辑在验什么,就该换成真实现。
3. Mockito 的常用能力
| 能力 | 用途 | 注意 |
|---|---|---|
when/thenReturn/thenThrow |
打桩返回值与异常 | 对 spy 用 doReturn().when() |
thenAnswer |
按入参动态返回 | 别在里面写复杂逻辑,那是把测试写成实现 |
verify(times/never/atLeast) |
交互断言 | 只用于"必须发生的外部副作用" |
ArgumentCaptor |
抓取传给协作者的参数 | 适合验 save 的内容,不适合验调用次数 |
@Mock + @InjectMocks |
构造注入替身 | 多构造器时会挑参数最多的那个 |
lenient() |
放宽未使用桩的检查 | 是止痛药,不是解药 |
mockStatic / mockConstruction |
拦静态方法与构造器 | 需 inline mock maker,作用域必须关闭 |
@ExtendWith(MockitoExtension.class)
class PlaceOrderTest {
@Mock InventoryClient inventory;
@Captor ArgumentCaptor<Reservation> reservationCaptor;
@Test
void shouldReserveBeforePersist() {
when(inventory.reserve(any())).thenReturn(Reservation.ok("R-1"));
service.place(new Order(1L, "SKU-9", 2));
verify(inventory).reserve(reservationCaptor.capture());
assertThat(reservationCaptor.getValue().qty()).isEqualTo(2);
}
}
@ExtendWith(MockitoExtension.class) 默认开启严格桩检查,未用到的桩会抛 UnnecessaryStubbingException。它拦住的是"复制粘贴来的桩"。
4. 误用:过度 verify 锁住实现细节
// 反面:断言的是编排,不是结果
verify(repo, times(1)).save(any());
verify(repo, times(2)).findById(anyLong());
verify(validator, times(1)).validate(any());
verify(eventPublisher, times(1)).publish(any());
这段测试重构即红:两次 findById 合成批量查询,行为没变,测试崩了。正确形态是状态断言为主,交互断言只留给不可观察的副作用:
// 正面:断言外部可观察的结果
OrderResult result = service.place(order);
assertThat(result.status()).isEqualTo(CONFIRMED);
assertThat(repo.findById(order.id())).isPresent();
// 只对这一条做交互断言:库存扣减是外部副作用,没有本地状态可看
verify(inventory).reserve(any());
判断某次 verify 该不该保留:换掉协作者的调用方式而结果不变时,这条断言还该失败吗? 否,就删。
5. spy 与 mock 对真实方法的调用差异
| 维度 | mock | spy |
|---|---|---|
| 未打桩方法 | 返回默认值,不执行逻辑 | 执行真实逻辑 |
| 初始化 | 无需实例 | 需完整真实实例 |
| 适用 | 隔离外部 I/O | 部分覆盖、遗留代码、想验真实分支 |
| 风险 | 假对象掩盖真实行为 | 真实逻辑有副作用(写库、发消息) |
| 打桩写法 | when(spy.x()) |
doReturn(v).when(spy).x() |
// spy 打桩必须用 doReturn,否则 when(...) 会先真实执行一次
doReturn(Optional.empty()).when(repo).findById(anyLong());
when(spy.method()) 会先真实调用一次再拿返回值做匹配,等于执行了方法本身。方法有写操作时最容易踩。
6. 静态方法与构造器依赖
try (MockedStatic<IdGen> mocked = mockStatic(IdGen.class)) {
mocked.when(IdGen::next).thenReturn("FIXED-ID");
assertThat(service.create(dto).id()).isEqualTo("FIXED-ID");
}
// try-with-resources 关闭作用域,否则污染同线程后续测试
| 情况 | 选择 | 理由 |
|---|---|---|
| 无状态纯函数工具类 | 可以 mockStatic,但优先改成注入 |
静态调用是接缝缺失,不是设计 |
| 静态单例持有连接/缓存 | 该重构,不是 mock | mock 掉就等于不测真实初始化 |
| 第三方 final 类 | mockConstruction 或包一层适配器 |
适配器更可控,能断言参数 |
| 系统时间 | 注入 java.time.Clock |
mockStatic(LocalDate) 是反模式 |
| 代价 | inline mock maker 需 agent,启动略慢;对 JDK 内部类覆盖受限 | 按 Mockito 5.x 核对 |
判据:静态依赖在真实运行时会失败、mock 后永远不会失败,这条测试就在骗你。
7. Fake 的价值:内存仓库替代数据库
final class InMemoryOrderRepository implements OrderRepository {
private final Map<Long, Order> store = new ConcurrentHashMap<>();
@Override public Order save(Order o) { store.put(o.id(), o); return o; }
@Override public Optional<Order> findById(long id) { return Optional.ofNullable(store.get(id)); }
@Override public long count() { return store.size(); }
}
Fake 比 mock 更真、比数据库更快,且能被多个测试复用而不必重复打桩。代价是它只实现了你写过的语义:唯一约束、版本号、flush 时机、默认值,都要自己补。
选择顺序:真实现 → Fake → stub → mock。只有在被测对象需要"出错"或"必须被调用"时,mock 才是唯一选择。
8. 测试边界的划分
| 验证目标 | 测试形态 | 替身策略 |
|---|---|---|
| 分支、边界、状态机 | 纯单元 | stub 外部 I/O |
| 映射、序列化、仓储查询 | 切片(@DataJpaTest/@JsonTest) |
H2 或 Testcontainers,不 mock |
| 事务传播、回滚、锁 | 集成 | 真库,显式 flush |
| HTTP 契约、参数校验 | @WebMvcTest + MockMvc |
service 层可 stub |
| 跨服务协议 | 契约测试 | 双方共享契约文件 |
反模式是"全 mock 掉,只剩测试和自己的 mock 对话":测试能过,系统可能连启动都失败,因为从没验证过一次真实装配。
9. 断言选择:状态优先于交互
| 断言类型 | 表达什么 | 抗重构性 |
|---|---|---|
| 返回值/状态 | 外部可观察的结果 | 高 |
| 持久化后状态 | 副作用真实落地 | 高 |
交互(verify) |
协作者被如何调用 | 低 |
| 参数捕获 | 副作用的内容正确 | 中 |
交互断言只留给三类:跨进程副作用(发消息、调外部 API)、审计要求、性能契约(禁止 N+1)。
10. 可测性设计
public class CouponService {
private final Clock clock; // 注入,不调 LocalDate.now()
private final Random random; // 注入种子,不调 Math.random()
public CouponService(Clock clock, Random random) {
this.clock = clock;
this.random = random;
}
public boolean usable(Coupon c) {
return !LocalDate.now(clock).isAfter(c.expireAt());
}
}
// 测试侧:固定时钟 + 固定种子,结果完全可复现
Clock fixed = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
var svc = new CouponService(fixed, new Random(42));
优先级最高的三条改造:时间抽成 Clock、随机抽成可注入的 Random、外部 I/O 走构造器。做完这些,多数 mockStatic 都不再需要。
11. 排错:六种常见病灶
| 现象 | 原因 | 处理 |
|---|---|---|
UnnecessaryStubbingException |
严格桩模式下桩没被用到 | 删桩;确有共用需求才 lenient() |
| 测试单独跑过、一起跑失败 | 共享 Fake 状态、静态 mock 未关闭 | @BeforeEach 重建;try-with-resources |
| 测试全绿但生产报唯一键冲突 | @Transactional 回滚掩盖真实提交 |
关回滚或 TestTransaction 显式提交 |
| mock 值对象后断言恒真 | 被 mock 的 equals 返回默认值 |
值对象用真实例 |
| spy 打桩时副作用被执行了 | when(spy.x()) 先调真实方法 |
改 doReturn().when(spy).x() |
| 并行执行随机失败 | inline mock maker 的全局状态、ThreadLocal | @ResourceLock 或串行该测试类 |
第三条最危险:提交失败类问题在测试阶段完全隐形,直到灰度才暴露。
12. 边界:替身解决不了的问题
| 需求 | 替身无能为力 | 该用什么 |
|---|---|---|
| SQL 方言与索引行为 | 内存仓库没有执行计划 | Testcontainers + 真库 |
| 事务隔离级别与锁等待 | mock 没有连接与事务 | 集成测试 + 并发用例 |
| 序列化兼容性 | stub 不校验字段演进 | 契约测试、跨版本夹具 |
| 真实网络超时与重试 | 打桩的超时是假的 | 故障注入(Toxiproxy 等) |
| 装配是否完整 | mock 掩盖了缺 bean | 一次 @SpringBootTest 冒烟 |
最后一条最易跳过:替身测试再全,也要有一个真正把上下文起起来的冒烟测试,否则漏的是"应用能否启动"。
13. 自测
- dummy 与 stub 的边界在哪里?你在断言里引用它时,它变成了什么?
- 一段测试里有 4 个
verify,怎么判断哪几个该删? - 为什么说 spy 打桩必须用
doReturn().when()?用when()会多发生什么? - 什么情况下
mockStatic是合理的,什么情况下它其实在提示你重构? - Fake 仓库替代数据库能测得更真,它测不出什么?
@Transactional测试回滚会在什么问题上制造假绿?