主题
界面
主题
前三节讲了"怎么用 Mock"。这一节讲**"什么时候该停手"**——它是 Mockito 系列里最重要的一节。
因为过度 Mock 的后果很隐蔽:测试是绿的、覆盖率是高的、CI 是通的,但它什么也没保护住。等到线上出问题、你回头找那个"本该拦住它的测试"时,才发现它验证的只是自己写的桩。
完成本节后,你应该能够:
verify 什么时候是必要的、什么时候是过度约束;先看一个"绿色但零保护力"的测试:
@Test
void anti_pattern_mocking_a_pure_calculation() {
OrderCalculator calculator = mock(OrderCalculator.class);
when(calculator.payableInCents(5_000L, 2)).thenReturn(9_000L);
assertThat(calculator.payableInCents(5_000L, 2)).isEqualTo(9_000L);
}这个测试会通过,它看起来在测"满减计算"。但实际上:
OrderCalculator 一行代码都没有执行;payableInCents 的实现改成 return 0;,这个测试仍然绿。更麻烦的是:它会计入覆盖率,让 JaCoCo 报告看起来"被覆盖了"——用一个假的绿点掩盖了真实的空白。
这就是本节要解决的问题:学会识别"看起来在测、其实没测"的测试。
写完一个测试后,问自己:
如果我把被测逻辑重新实现一遍(行为完全不变,只是换个写法),这个测试还会通过吗?
| 答案 | 含义 |
|---|---|
| 会通过 | 测试绑定的是行为 ✓ 好测试 |
| 会失败 | 测试绑定的是实现细节 ✗ 脆弱的测试 |
举例:
// 断言"结果正确"——重写实现照样通过 ✓
assertThat(service.rename("u1", "新名")).isEqualTo("新名");
// 断言"必须调用了 findById"——重写实现(如改成从缓存取)就红了 ✗
verify(repository).findById("u1");注意:verify(repository).save(...) 是必要的("数据必须被持久化"是业务要求);而 verify(repository).findById("u1") 通常是多余的("怎么拿到数据"是实现选择)。
这条标准是本节的核心工具,后面的五种反模式都是它的具体表现。
// ✗ 反例
OrderCalculator calculator = mock(OrderCalculator.class);
when(calculator.payableInCents(5_000L, 2)).thenReturn(9_000L);
assertThat(calculator.payableInCents(5_000L, 2)).isEqualTo(9_000L);// ✓ 正例
OrderCalculator calculator = new OrderCalculator();
assertThat(calculator.payableInCents(5_000L, 2)).isEqualTo(9_000L);判断依据:这个类有没有需要被隔离的外部依赖? 没有(纯计算、值对象),就用真实实例。
值对象同理:User、Money、DateRange 这类只有数据没有行为的类型,构造一个真实实例的成本是零,没有理由 mock 它。
// ✗ 反例:把调用序列写成断言
verify(repository).findById("u1");
verify(repository).save(any(User.class));
verify(notifier).send("u1", "您的名字已更改为 新名");// ✓ 正例:只验证"可观察的结果"
assertThat(result).isEqualTo("新名");
verify(repository).save(new User("u1", "新名", "u1@test.com"));
verify(notifier).send("u1", "您的名字已更改为 新名");差别在第一条 verify(repository).findById("u1"):
它锁定了"必须先查一次仓储"这个实现细节。一旦有人加上缓存("先查缓存,未命中再查库"),这个测试就会红——但它没有指出任何行为退化。
怎么区分"必要"与"过度":
| 断言 | 类型 | 该不该有 |
|---|---|---|
save(...) 被调用,参数正确 | 业务要求(数据要落库) | ✓ 该有 |
send(...) 被调用,消息正确 | 业务要求(用户要收到通知) | ✓ 该有 |
findById(...) 被调用 | 实现选择(怎么读数据) | ✗ 通常多余 |
findById 被调用了恰好一次 | 实现选择(是否缓存) | ✗ 多余 |
| 调用顺序是 findById → save → send | 部分是实现细节 | 视情况 |
最后一行要说明:"先保存再通知"可能确实是业务要求(避免通知了但没存上);而"先查再存"通常不是。判断标准是"顺序错了会不会造成业务问题"。
// ✗ 反例:5 行准备,2 行断言
UserRepository mockRepository = mock(UserRepository.class);
Notifier mockNotifier = mock(Notifier.class);
when(mockRepository.findById("u1")).thenReturn(Optional.of(user));
when(mockNotifier.isEnabled("u1")).thenReturn(true);
when(mockRepository.save(any())).thenReturn(user);
service.rename("u1", "新名");
verify(mockRepository).save(any(User.class));
verify(mockNotifier).send(any(), any());信号很直观:准备代码比断言长得多。这时测试的读者要花更多精力理解"这些桩是干什么的",而不是"被测行为对不对"。
修复方向:
@BeforeEach 或私有方法(如果多个测试共用);thenAnswer 重写业务逻辑 // ✗ 反例:在测试里实现了定价规则
when(pricingClient.quote(any())).thenAnswer(invocation -> {
Order order = invocation.getArgument(0);
long total = order.items().stream().mapToLong(Item::price).sum();
if (order.member()) { total -= 500; }
return new Quote(total);
});问题:这是被测系统逻辑的一份副本。真实规则改了(比如会员改成减 800),桩不会跟着变——测试仍然绿,验证的却是一份过期的规则。
修复:用固定的、测试里显式写出的期望值:
when(pricingClient.quote(any())).thenReturn(new Quote(9_000L));这样"9000"是一个明确的输入条件,而不是被测试代码"算出来"的。
现象:为了让某个逻辑"可以被 mock",在生产代码里注入了本不需要的依赖。
// ✗ 反例:为了让测试能 mock "当前时间",注入了 Clock——这没错,
// 但如果为了 mock 一个"空实现"而注入了一个接口,就是负担
public class PriceFormatter {
private final CurrencyProvider currencyProvider; // 永远只返回 "CNY"
public String format(long cents) {
return cents / 100.0 + " " + currencyProvider.symbol();
}
}判断标准:这个依赖在真实运行时有多个实现吗? 没有的话,它就是为了测试而存在的额外间接层。
但要小心反向的误判:Clock、IdGenerator、Random 这类"环境依赖"确实应该注入——因为它们的值在测试里不可控。这不是为了 mock,而是为了让不确定性可控(T1-006 可测试性与 Arrange-Act-Assert 会展开讲)。
区分两者的简单问法:
| 问题 | 该注入 | 不该注入 |
|---|---|---|
| 值在测试里可控吗? | 不可控(时间、随机、id) | 可控(固定值、纯函数) |
| 真实运行时有多种实现吗? | 有(不同环境、不同实现) | 只有一种 |
| 反模式 | 典型症状 | 修复 |
|---|---|---|
| mock 值对象/纯计算类 | 断言在"复述打桩值" | 用真实实例 |
| 验证每一步调用 | verify 了查询、次数、顺序 | 只 verify 业务要求 |
| Mock 爆炸 | 准备比断言长 | 抽公共桩 / 审视职责 |
thenAnswer 重写逻辑 | 桩里有 if-else 与业务规则 | 用固定期望值或 Fake |
| 为 mock 造依赖 | 只有一个实现的接口 | 直接调用,去掉间接层 |
见 5.2 节。这是最容易被忽略、后果最严重的一种——因为它不报错、还贡献覆盖率。
// 改造前:3 条 verify,锁死了实现步骤
verify(repository).findById("u1");
verify(repository).save(any());
verify(notifier).send(any(), any());
// 改造后:1 条结果断言 + 2 条业务要求
assertThat(result).isEqualTo("新名");
verify(repository).save(new User("u1", "新名", "u1@test.com"));
verify(notifier).send("u1", "您的名字已更改为 新名");注意改造后仍然保留了 2 条 verify——"不该 Mock"不等于"不该验证交互"。判断标准始终是"这是不是业务要求"。
verify(repository, times(1)).findById("u1");
verify(repository, never()).findById("u2");
verify(notifier, times(1)).send(anyString(), anyString());
verifyNoMoreInteractions(repository, notifier);这四条断言组合起来,等于把"当前实现"逐字抄了一遍。任何重构都会让它变红,而它唯一能抓到的 bug 是"实现被改了"——这不是 bug,这是重构。
文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/OverMockingTest.java(节选)
注意:本节的"反例"测试都是通过的——这正是它危险的地方。看代码时请关注注释里指出的"它验证了什么"。
@ExtendWith(MockitoExtension.class)
@DisplayName("过度 Mock 的反模式与修复")
class OverMockingTest {
@Mock
private UserRepository repository;
@Mock
private Notifier notifier;
@Test
@DisplayName("反例:mock 纯计算类——断言只是在复述打桩时写下的值")
void anti_pattern_mocking_a_pure_calculation() {
OrderCalculator calculator = mock(OrderCalculator.class);
when(calculator.payableInCents(5_000L, 2)).thenReturn(9_000L);
assertThat(calculator.payableInCents(5_000L, 2)).isEqualTo(9_000L);
// 这个测试是绿的,但真实的 OrderCalculator 一行都没有执行:
// 打桩写下 9000,断言又验证 9000——保护力为零。
// 值对象同理:它只有数据没有行为,mock 它等于伪造数据。
}
@Test
@DisplayName("正例:值对象用真实实例,断言的是真实行为")
void prefer_real_value_objects() {
User user = new User("u1", "张三", "zhangsan@test.com");
assertThat(user.name()).isEqualTo("张三");
assertThat(user.rename("李四").name()).isEqualTo("李四");
// rename 是真实逻辑:新对象换了名字,原对象的邮箱保持不变
assertThat(user.rename("李四").email()).isEqualTo("zhangsan@test.com");
}
@Test
@DisplayName("反例:验证了每一步调用,把实现细节锁死")
void anti_pattern_verifying_every_call() {
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
UserService service = new UserService(repository, notifier);
service.rename("u1", "新名");
verify(repository).findById("u1");
verify(repository).save(any(User.class));
verify(notifier).send("u1", "您的名字已更改为 新名");
// verify(repository).findById("u1") 这类断言锁死了"必须先查一次库"
// 一旦实现改成"先查缓存",测试就会红——但它没有指出任何行为退化
}
@Test
@DisplayName("正例:只验证可观察的结果")
void prefer_verifying_observable_outcomes() {
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
UserService service = new UserService(repository, notifier);
String result = service.rename("u1", "新名");
assertThat(result).isEqualTo("新名");
verify(repository).save(new User("u1", "新名", "u1@test.com"));
verify(notifier).send("u1", "您的名字已更改为 新名");
}
@Test
@DisplayName("反例:为了 mock 而制造依赖——测试比生产代码还复杂")
void anti_pattern_mock_explosion() {
UserRepository mockRepository = mock(UserRepository.class);
Notifier mockNotifier = mock(Notifier.class);
when(mockRepository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
UserService service = new UserService(mockRepository, mockNotifier);
service.rename("u1", "新名");
verify(mockRepository).save(any(User.class));
verify(mockNotifier).send(any(), any());
// 5 行准备,2 行断言——当准备比断言长得多时,测试正在变脆
}
@Test
@DisplayName("正例:只 mock 真正的边界,其余用真实对象")
void prefer_mocking_only_the_boundary() {
// 只有 repository(外部存储)与 notifier(外部系统)是边界,需要替身;
// 领域逻辑(UserService、User)用真实实现
when(repository.findById("u1"))
.thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));
UserService service = new UserService(repository, notifier);
service.rename("u1", "新名");
verify(repository).save(new User("u1", "新名", "u1@test.com"));
verify(notifier).send("u1", "您的名字已更改为 新名");
}
}运行命令:
cd labs
./mvnw -pl testing-unit test -Dtest=OverMockingTest预期输出:
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.301 s
-- in cn.kb.learn.testing.OverMockingTest注意 6 个测试全绿——反例和正例一样"通过"。测试的世界里,"绿"从来不等于"对"。
三组对比的核心差别:
| 对比 | 反例的问题 | 正例做对了什么 |
|---|---|---|
| 纯计算 vs 真实实例 | 真实逻辑没跑 | 用真实对象测真实行为 |
| 验证每步调用 vs 验证结果 | 锁死实现细节 | 只 verify 业务要求 |
| Mock 爆炸 vs 只 mock 边界 | 准备淹没断言 | 边界才替身,领域逻辑用真货 |
anti_pattern_mocking_a_pure_calculation 的 when(...) 处断点,观察 OrderCalculator 类从未被加载执行——只有 mock 的代理对象在工作;prefer_real_value_objects 的 user.rename("李四") 处断点:这里是真实的 record 方法在执行;anti_pattern_verifying_every_call 的 verify(repository).findById("u1") 注释掉,然后在 UserService 里把"查库"改成"查两次库"(多加一行 repository.findById(userId)),重跑: verify):会失败(因为多调用了一次)JaCoCo: OrderCalculator 覆盖 100%但如果测试里 mock(OrderCalculator.class),覆盖率统计的是"字节码被执行过",而 mock 代理不会执行真实字节码——那 100% 是从别的测试来的。
提醒:覆盖率是"有没有测"的粗略指标,不是"测得好不好"的指标(T1-013 分支覆盖率与覆盖率误区 会展开)。
verify 越多越严谨" 恰恰相反:每多一条 verify,就多锁死一个实现细节。严谨应该体现在"业务规则的覆盖"上,而不是"调用次数的精确"上。
new 而引入工厂 // 过度设计:为了"可 mock"而抽象
interface UserFactory {
User create(String id, String name, String email);
}new User(...) 没有任何需要隔离的东西。测试里出现 new 是正常的——它通常意味着"这里用了真实的领域对象",是好事。
如果一个类必须 mock 五个依赖才能测试,那问题不在测试技巧,在这个类的职责过多。
这是本节最重要的"溢出价值":测试的难写程度是设计的体检报告。
| 测试里的症状 | 对应的设计问题 |
|---|---|
| 需要 mock 5 个以上依赖 | 职责过多,该拆分 |
需要 thenAnswer 模拟复杂状态 | 状态应该由 Fake 或领域对象承载 |
| 断言要穿透三层对象 | 得墨忒耳定律违反(链式调用过深) |
| 每个测试都要重新搭一遍 | 缺少合适的构造方式(建造者/工厂) |
// 极端:为了"真实",测试直接连数据库
class UserServiceTest {
private final UserService service =
new UserService(new JdbcUserRepository("jdbc:mysql://localhost/test"), new SmtpNotifier());
}问题:测试依赖外部环境(数据库、邮件服务器),变慢、变脆、CI 上跑不了。
正确的边界:只 mock 边界(进程外的东西:数据库、网络、文件、时间),进程内的领域逻辑用真实实现。
目标:亲手验证"测试绑定了实现细节会怎样"。
步骤:
UserService.rename 的实现改一下(行为不变)——比如先判断 findById 的结果再校验名字; anti_pattern_verifying_every_call 与 prefer_verifying_observable_outcomes 分别重跑;anti_pattern_mocking_a_pure_calculation 之外,再写一个真实的 OrderCalculator 测试,把 payableInCents 的实现改成 return 0;; UserService 的所有测试里,verify 语句有几条是"查询类"、几条是"写入/通知类",判断哪些可以删掉;验收方式:能说出第 2 步中"变红的那条断言"为什么不能算 bug 保护。
扩展任务:找一个"必须 mock 4 个以上依赖"的测试(可以自己造一个),尝试只保留 1-2 个真正必要的替身,用 Fake 或真实对象替换其余部分——记录改造后测试的行数与可读性变化。
基础题
verify(repository).save(...) 通常是必要的,而 verify(repository).findById(...) 通常是多余的?代码阅读题
@Test
void calculate_total() {
Cart cart = mock(Cart.class);
when(cart.itemCount()).thenReturn(3);
when(cart.totalInCents()).thenReturn(30_000L);
assertThat(cart.totalInCents()).isEqualTo(30_000L);
}Cart 是个有真实逻辑的类,正确的写法应该是什么?编程题
@Test
void place_order() {
StockRepository stock = mock(StockRepository.class);
PaymentGateway gateway = mock(PaymentGateway.class);
when(stock.stockOf("A")).thenReturn(10);
when(stock.stockOf("A")).thenReturn(10); // 扣减后仍是 10?
Payment payment = mock(Payment.class);
when(payment.quantity()).thenReturn(3);
new OrderService(stock, gateway).place("A", 3);
verify(stock).stockOf("A");
verify(stock).decrease("A", 3);
verify(gateway).charge(any());
verifyNoMoreInteractions(stock);
}综合任务
RegisterService): UserRepository(查重与保存)、EmailSender(发欢迎邮件)、IdGenerator(生成 id);RegisterService 的实现换个写法(行为不变),看哪一版会变红;提示 1(方向):第 4 题的关键是"Cart 的真实逻辑完全没有执行"。
提示 2(关键点):第 6 题的 when(payment.quantity()) 是"mock 值对象"的典型——Payment 应该直接 new。
提示 3(接近答案):第 7 题的"合理版本"里:UserRepository 用 Fake(有状态:查重后保存)、EmailSender 用 Mock(验证交互)、IdGenerator 用 Stub(() -> "固定id" 或 Fake 递增)。
save 对应的是业务要求("改名必须被持久化"),而 findById 对应的是实现选择("怎么读到数据")。前者一旦不满足,用户会发现问题;后者改成缓存、批量查询、走另一个数据源都不影响用户看到的结果。测试应该守住前者,而不是限制后者。第 4 题的问题:
mock(Cart.class) 让真实的 Cart 逻辑完全没有执行。这个测试验证的只是"mock 按打桩返回值"——它是一条自证预言。
会通过 ✓(这正是它危险的地方)
第 5 题的正确写法:
@Test
void calculate_total() {
Cart cart = new Cart();
cart.add("键盘", 19_900L, 1);
cart.add("鼠标", 9_900L, 1);
cart.add("鼠标垫", 200L, 1);
assertThat(cart.itemCount()).isEqualTo(3);
assertThat(cart.totalInCents()).isEqualTo(30_000L);
}为什么更好:真实的 Cart 逻辑(加商品、累加金额、计算数量)真的被验证了。如果 add 或 totalInCents 有 bug,这个测试会抓到;而 mock 版本不会。
顺带一个收益:测试变更短了——不用写 when(...),只需要构造真实场景。
三处反模式:
| 位置 | 反模式 | 问题 |
|---|---|---|
when(stock.stockOf("A")).thenReturn(10) 写了两遍 | 用 Mock 模拟状态 | 第二行会覆盖第一行,且扣减后库存没有真实变化 |
Payment payment = mock(Payment.class) | mock 值对象 | Payment 是 record,直接 new 就行 |
verify(stock).stockOf("A") | 验证实现细节 | "查库存"是实现选择,不是业务要求 |
verifyNoMoreInteractions(stock) | 过度约束 | 任何新增的合理调用都会让它红 |
改写后的版本:
package cn.kb.learn.testing;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@DisplayName("下单:合理替身版本")
class PlaceOrderCleanTest {
interface StockRepository {
int stockOf(String sku);
void decrease(String sku, int quantity);
void increase(String sku, int quantity);
}
record Payment(String sku, int quantity) {
}
interface PaymentGateway {
void charge(Payment payment);
}
/** Fake:库存有状态,让它真实流转 */
static class FakeStockRepository implements StockRepository {
private final Map<String, Integer> stocks = new HashMap<>();
void put(String sku, int quantity) {
stocks.put(sku, quantity);
}
@Override
public int stockOf(String sku) {
return stocks.getOrDefault(sku, 0);
}
@Override
public void decrease(String sku, int quantity) {
stocks.merge(sku, -quantity, Integer::sum);
}
@Override
public void increase(String sku, int quantity) {
stocks.merge(sku, quantity, Integer::sum);
}
}
interface OrderService {
void place(String sku, int quantity);
}
private FakeStockRepository stockRepository;
private PaymentGateway paymentGateway;
private OrderService service;
@BeforeEach
void setUp() {
stockRepository = new FakeStockRepository();
paymentGateway = mock(PaymentGateway.class);
service = new PlaceOrderService(stockRepository, paymentGateway);
}
@Test
@DisplayName("下单成功:库存减少、扣款参数正确")
void decreases_stock_and_charges() {
stockRepository.put("A", 10);
service.place("A", 3);
assertThat(stockRepository.stockOf("A")).isEqualTo(7); // 状态断言
verify(paymentGateway).charge(new Payment("A", 3)); // 业务要求(值对象用真实实例)
}
@Test
@DisplayName("库存不足:不改库存")
void rejects_when_not_enough_stock() {
stockRepository.put("A", 2);
assertThatThrownBy(() -> service.place("A", 5))
.isInstanceOf(IllegalStateException.class);
assertThat(stockRepository.stockOf("A")).isEqualTo(2);
}
}改动清单:
| 原写法 | 新写法 | 理由 |
|---|---|---|
mock(StockRepository) + 两次打桩 | Fake | 库存是有状态依赖,状态断言比交互断言更真实 |
mock(Payment) + 打桩 | new Payment("A", 3) | 值对象直接构造 |
verify(stock).stockOf("A") | 删除 | 实现细节 |
verifyNoMoreInteractions(stock) | 删除 | 过度约束 |
verify(gateway).charge(any()) | verify(gateway).charge(new Payment("A", 3)) | 从"被调用了"提升到"参数正确" |
注意最后一行:any() 与具体参数的差别很大——前者只能发现"没调用",后者还能发现"调用了但参数错了"。
过度 Mock 版本(三处反模式都体现):
@Test
void register_over_mocked() {
// 反模式 1:mock 值对象(User)
User mockUser = mock(User.class);
when(mockUser.id()).thenReturn("u1");
// 反模式 2:thenAnswer 重写业务逻辑(自己算校验和)
when(repository.exists(anyString())).thenAnswer(invocation -> false);
// 反模式 3:验证每一步调用
when(idGenerator.next()).thenReturn("u1");
when(repository.save(any())).thenReturn(mockUser);
service.register("zhangsan@test.com", "密码");
verify(repository).exists("zhangsan@test.com");
verify(idGenerator).next();
verify(repository).save(any(User.class));
verify(emailSender).sendWelcome("zhangsan@test.com");
verifyNoMoreInteractions(repository, idGenerator, emailSender);
}问题清单:
| 反模式 | 具体表现 | 后果 |
|---|---|---|
| mock 值对象 | mock(User.class) | 真实 User 逻辑(如邮箱规范化)没被执行 |
thenAnswer 重写逻辑 | 用 Lambda 返回 false | 看似"模拟查重通过",实际是把结论写死 |
| 验证每一步调用 | 4 条 verify + verifyNoMoreInteractions | 任何实现调整都会红 |
合理替身版本:
@DisplayName("注册流程:合理替身")
class RegisterServiceCleanTest {
interface UserRepository {
boolean exists(String email);
void save(User user);
}
interface EmailSender {
void sendWelcome(String email);
}
interface IdGenerator {
String next();
}
/** Fake:有状态(记住已注册的邮箱),支持真实的查重语义 */
static class FakeUserRepository implements UserRepository {
private final Set<String> emails = new HashSet<>();
@Override
public boolean exists(String email) {
return emails.contains(email);
}
@Override
public void save(User user) {
emails.add(user.email());
}
}
private FakeUserRepository repository;
private EmailSender emailSender;
private RegisterService service;
@BeforeEach
void setUp() {
repository = new FakeUserRepository();
emailSender = mock(EmailSender.class); // Mock:验证"欢迎邮件发出去了"
IdGenerator idGenerator = () -> "fixed-id"; // Stub:返回值可控
service = new RegisterService(repository, emailSender, idGenerator);
}
@Test
@DisplayName("注册成功:邮箱已存、欢迎邮件发出")
void registers_new_user() {
service.register("zhangsan@test.com", "密码");
assertThat(repository.exists("zhangsan@test.com")).isTrue(); // 状态断言
verify(emailSender).sendWelcome("zhangsan@test.com"); // 业务要求
}
@Test
@DisplayName("邮箱重复:抛异常且不发邮件")
void rejects_duplicate_email() {
repository.save(new User("u1", "zhangsan@test.com", "密码"));
assertThatThrownBy(() -> service.register("zhangsan@test.com", "密码"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("已注册");
verify(emailSender, never()).sendWelcome(anyString());
}
}替身选择说明:
| 依赖 | 选择 | 理由 |
|---|---|---|
UserRepository | Fake | 有状态:注册后 exists 必须返回 true。"邮箱重复"这个核心场景只有 Fake 能自然表达 |
EmailSender | Mock | 无状态、无法直接观察(没法检查收件箱),只能验证交互 |
IdGenerator | Stub(Lambda) | 单方法接口、无状态、返回值固定 |
"实现重写测试"的结果:
把 RegisterService.register 的实现换个写法(如把"先查重"改成"先规范化邮箱再查重"):
verify(repository).exists("zhangsan@test.com") 可能因为参数变了(规范化后的邮箱)而变红——但业务行为没变;怎么向同事解释"哪些 verify 可以删":
一句话标准——问"这条断言失败时,是不符合业务要求,还是不符合当前实现?"
| 断言 | 失败意味着 | 结论 |
|---|---|---|
verify(emailSender).sendWelcome(email) | 用户收不到欢迎邮件 | 保留 ✓ |
verify(repository).exists(email) | 只是实现改了读写顺序 | 删除 ✗ |
verify(idGenerator).next() | 只是 id 来源变了 | 删除 ✗ |
最后的建议:写测试时先写"结果断言",写完后再问"还需要验证哪些交互"。顺序反过来(先写一堆 verify)最容易滑向过度 Mock。
verify 越多不等于越严谨,反而更容易变脆;Mockito 三节到此完成。接下来:
暂无