2579 字
约 8 分钟
16
S16-X10 · 单元测试的边界与替身

一个测试类里堆了六个 @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. 自测

  1. dummy 与 stub 的边界在哪里?你在断言里引用它时,它变成了什么?
  2. 一段测试里有 4 个 verify,怎么判断哪几个该删?
  3. 为什么说 spy 打桩必须用 doReturn().when()?用 when() 会多发生什么?
  4. 什么情况下 mockStatic 是合理的,什么情况下它其实在提示你重构?
  5. Fake 仓库替代数据库能测得更真,它测不出什么?
  6. @Transactional 测试回滚会在什么问题上制造假绿?
S16-X10 · 单元测试的边界与替身
https://www.clxhxhhr.top/posts/5833/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。