主题
界面
主题
《Mockito:Mock、Stub 与 Verify》 认识了 mock() / when() / verify()。但"测试替身"不止 mock 一种——用错类型,测试的可信度会打折。
本节把五种替身(Dummy、Stub、Spy、Fake、Mock)摆在一起对比,重点是选择依据:同一个依赖,什么时候该用 Fake、什么时候该用 Mock。
完成本节后,你应该能够:
spy() 与手写 Spy、mock() 的区别;很多人的测试工具箱里只有一把锤子:mock()。于是出现这些写法:
// 用 mock 模拟"查询返回空"
when(repository.findById("u404")).thenReturn(Optional.empty());
// 用 mock 模拟"库存为 0"
when(stockRepository.stockOf("A")).thenReturn(0);
// 用 mock 模拟"库存流转"——这里开始别扭了
when(stockRepository.stockOf("A")).thenReturn(10);
// ... 被测代码调用 decrease 之后 ...
// 需要再打一次桩才能让后续的 stockOf 返回 9
when(stockRepository.stockOf("A")).thenReturn(9);最后那种写法是信号:被测代码维护了状态(扣减库存),而 mock 没有状态——你不得不手工同步"世界状态"。改成 Fake(一个内存实现)就自然多了:
FakeStockRepository fake = new FakeStockRepository();
fake.put("A", 10);
// decrease 一次,再查就是 9——Fake 自己维护状态,不需要重新打桩这就是本节要解决的问题:知道每种替身擅长什么,才能选对工具。
| 替身 | 一句话定义 | 是否被断言 | 手写难度 | 典型场景 |
|---|---|---|---|---|
| Dummy | 只为填参数,从不被使用 | ✗ | 极简(空实现) | 被测路径走不到它 |
| Stub | 返回预先准备的值 | 间接(通过被测结果) | 简单 | 依赖提供固定数据 |
| Spy | 记录调用,事后断言 | ✓(断言调用) | 简单 | 验证"通知发出去了" |
| Fake | 有真实逻辑的简化实现 | ✓(断言状态) | 中等 | 内存仓储、内存队列 |
| Mock | 预设期望 + 自动记录调用 | ✓(断言交互) | 用 Mockito | 验证与外部系统的交互 |
AuditLog dummy = (sku, price) -> { }; // 空实现,什么都不做
PricingService service = new PricingService(stubRepo, dummy);使用条件:被测路径不会调用它。本节代码里测的是"非法折扣参数校验",校验在查价与记录日志之前就抛异常了——所以 dummy 从头到尾没被调用。
如果它被调用了呢? 那就不是 Dummy,而是"一个空实现的 Stub"——没被断言、也没提供数据,等于白写。这种情况下要么补断言,要么承认它是多余的。
PriceRepository stub = sku -> 10_000L; // 任何 SKU 都返回 10000特点:只关心"喂给被测代码什么",不关心"被测代码拿它做了什么"。
用 Mockito 的等价写法:when(mock.priceOf(anyString())).thenReturn(10_000L)。
Stub 就该这么简单——如果打桩超过两三行,通常是设计出了问题(见 T1-012)。
手写版:
List<String> recorded = new ArrayList<>();
AuditLog spy = (sku, price) -> recorded.add(sku + ":" + price);
service.applyDiscount("A", 10);
assertThat(recorded).containsExactly("A:9000"); // 事后断言Mockito 版:
PriceRepository spyRepository = spy(realRepository); // 包装真实对象
service.applyDiscount("A", 10);
verify(spyRepository).priceOf("A"); // 自动记录,自动断言两种 Spy 的关键区别:
| 手写 Spy | Mockito 的 spy() | |
|---|---|---|
| 行为 | 自己决定(通常只记录) | 默认执行真实逻辑,可选择性打桩覆盖 |
| 适用 | 只需要"记一笔" | 想在真实行为上验证或局部替换 |
spy() 的定位要清楚:它是"真实对象 + 可验证的调用记录"。所以它适合"我想用真实实现,但还想验证某个方法被调过"——如果整个对象都需要替换,那就该用 mock()。
static class InMemoryPriceRepository implements PriceRepository {
private final Map<String, Long> prices = new HashMap<>();
void put(String sku, long priceInCents) {
prices.put(sku, priceInCents);
}
@Override
public long priceOf(String sku) {
return prices.getOrDefault(sku, 0L); // 真实逻辑:不存在则返回 0
}
}Fake 与 Mock 的本质区别:
| Mock | Fake | |
|---|---|---|
| 状态 | 无状态(除非手工打桩模拟) | 自己维护状态 |
| 行为真实性 | 由打桩决定 | 有真实逻辑 |
| 断言方式 | 验证交互(verify) | 验证最终状态 |
| 复用性 | 每个测试各打各的桩 | 可跨测试复用(甚至能单测它自己) |
| 风险 | 桩写错 = 假的也许 | Fake 自己可能有 bug |
什么时候该用 Fake:
这就是很多项目里 InMemoryXxxRepository、FakeClock、FakeMessageQueue 存在的原因。
Fake 的代价:它自己也需要维护和测试。所以判断标准是"这个 Fake 会被多少个测试用到"——只用一次,不如直接打桩。
verify(mockRepository).priceOf("A");
verify(mockAuditLog).record("A", 9_000L);
verifyNoMoreInteractions(mockRepository);Mock 最擅长的场景:验证"对一个无法直接观察结果的依赖做了什么"。
比如"发送邮件"——你没法在单元测试里检查收件箱,只能验证"send 被调用了,参数是什么"。
列表里的 verifyNoMoreInteractions 值得单说:它断言"除了上面验证过的,这个 mock 没有再被调用过"。它比逐条 verify 更严格,能抓住"多调用了一次"这种问题。
但要小心:verifyNoMoreInteractions 会让测试对"实现多调了一次查询"这类无害变化也敏感——只在"多余的调用确实是问题"时使用(如"不该重复扣款")。
需要验证"与外部系统的交互" → Mock
依赖有状态、被测代码会读改写 → Fake
只想喂数据、不关心交互 → Stub
想用真实实现、但记录调用 → Spy(Mockito 的 spy)
只为了填参数、不会被用到 → Dummy再加一条实践判断:
| 信号 | 说明 |
|---|---|
| 打桩超过 3 行 | 考虑换成 Fake |
| 需要在多个测试里重复同一套桩 | 抽成 Fake 或公共的 fixture 方法 |
| 为了模拟状态变化而"再打一次桩" | 强烈建议换 Fake |
| 只有一个方法、无状态、返回值固定 | Stub 或直接 Lambda |
PriceRepository stub = sku -> 10_000L;单方法接口的 Stub 不需要 Mockito——Lambda 更短、更直白、没有隐式行为(Mockito 的 mock 默认返回 null/0,容易踩坑)。
InMemoryPriceRepository fake = new InMemoryPriceRepository();
fake.put("A", 10_000L);
fake.put("B", 20_000L);Fake 的数据在测试里一眼可见——不用在 when(...) 里拼装。
when(stockRepository.stockOf("A")).thenReturn(10); // 第一次查:10
// ... 被测代码 decrease("A", 3) ...
when(stockRepository.stockOf("A")).thenReturn(7); // 手工同步到 7问题:这种桩里的数字是"你希望的流程",不是"代码真实执行后的结果"。一旦被测代码少扣一次或多扣一次,这个测试仍然会通过——因为它验证的是你手写的剧本,而不是系统的行为。
修复:换成 Fake,让状态真实地流转。
文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/TestDoubleTest.java(节选)
@DisplayName("五种测试替身")
class TestDoubleTest {
interface PriceRepository {
long priceOf(String sku);
}
interface AuditLog {
void record(String sku, long priceInCents);
}
/** Fake:有真实逻辑的简化实现(这里用 Map 代替数据库) */
static class InMemoryPriceRepository implements PriceRepository {
private final Map<String, Long> prices = new HashMap<>();
void put(String sku, long priceInCents) {
prices.put(sku, priceInCents);
}
@Override
public long priceOf(String sku) {
return prices.getOrDefault(sku, 0L);
}
}
static class PricingService {
private final PriceRepository repository;
private final AuditLog auditLog;
PricingService(PriceRepository repository, AuditLog auditLog) {
this.repository = repository;
this.auditLog = auditLog;
}
long applyDiscount(String sku, int percent) {
if (percent < 0 || percent > 100) {
throw new IllegalArgumentException("折扣必须在 0-100 之间");
}
long price = repository.priceOf(sku);
long payable = price * (100 - percent) / 100;
auditLog.record(sku, payable);
return payable;
}
}
/** Dummy:只为满足构造参数,从不会被使用 */
private static AuditLog dummyAuditLog() {
return (sku, priceInCents) -> {
};
}
@Test
@DisplayName("Dummy:只用来填充参数,整个测试里从未被调用")
void dummy_only_fills_the_parameter() {
PricingService service = new PricingService(sku -> 10_000L, dummyAuditLog());
assertThatThrownBy(() -> service.applyDiscount("A", 200))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("折扣必须在 0-100 之间");
// dummyAuditLog 从头到尾没有被调用:这正是 Dummy 的定义
}
@Test
@DisplayName("Stub:提供预先准备的返回值")
void stub_returns_canned_values() {
PriceRepository stub = sku -> 10_000L;
PricingService service = new PricingService(stub, dummyAuditLog());
assertThat(service.applyDiscount("ANY-SKU", 20)).isEqualTo(8_000L);
}
@Test
@DisplayName("Spy(手写):记录调用,事后断言")
void handwritten_spy_records_invocations() {
List<String> recorded = new ArrayList<>();
AuditLog spy = (sku, price) -> recorded.add(sku + ":" + price);
PricingService service = new PricingService(sku -> 10_000L, spy);
service.applyDiscount("A", 10);
assertThat(recorded).containsExactly("A:9000");
}
@Test
@DisplayName("Fake:有真实逻辑的简化实现")
void fake_has_real_behavior() {
InMemoryPriceRepository fake = new InMemoryPriceRepository();
fake.put("A", 10_000L);
fake.put("B", 20_000L);
PricingService service = new PricingService(fake, dummyAuditLog());
assertThat(service.applyDiscount("A", 10)).isEqualTo(9_000L);
assertThat(service.applyDiscount("B", 50)).isEqualTo(10_000L);
// Fake 的真实行为:不存在的 SKU 返回 0,而不是抛异常
assertThat(service.applyDiscount("NOT_EXIST", 0)).isZero();
}
@Test
@DisplayName("Mock:验证交互,而不只是返回值")
void mock_verifies_interactions() {
PriceRepository mockRepository = mock(PriceRepository.class);
AuditLog mockAuditLog = mock(AuditLog.class);
when(mockRepository.priceOf("A")).thenReturn(10_000L);
PricingService service = new PricingService(mockRepository, mockAuditLog);
service.applyDiscount("A", 10);
verify(mockRepository).priceOf("A");
verify(mockAuditLog).record("A", 9_000L);
verifyNoMoreInteractions(mockRepository);
}
@Test
@DisplayName("Mockito 的 spy:包装真实对象,默认走真实逻辑")
void mockito_spy_wraps_a_real_object() {
InMemoryPriceRepository real = new InMemoryPriceRepository();
real.put("A", 10_000L);
PriceRepository spyRepository = spy(real);
PricingService service = new PricingService(spyRepository, dummyAuditLog());
assertThat(service.applyDiscount("A", 10)).isEqualTo(9_000L);
verify(spyRepository).priceOf("A");
}
}运行命令:
cd labs
./mvnw -pl testing-unit test -Dtest=TestDoubleTest预期输出:
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.241 s
-- in cn.kb.learn.testing.TestDoubleTest六种写法对照同一个被测类——把它们并排放进笔记,比背定义有用得多:
| 测试方法 | 替身 | 断言的是什么 |
|---|---|---|
dummy_only_fills_the_parameter | Dummy | 异常类型与消息 |
stub_returns_canned_values | Stub | 计算结果 |
handwritten_spy_records_invocations | Spy | 记录下来的调用参数 |
fake_has_real_behavior | Fake | 多个 SKU 的真实计算结果(含"不存在"的边界) |
mock_verifies_interactions | Mock | 交互顺序与参数 |
mockito_spy_wraps_a_real_object | Mockito Spy | 真实结果 + 调用记录 |
dummy_only_fills_the_parameter 的 lambda 里断点,重跑——断点永远不会命中,这就是 Dummy 的证明;fake_has_real_behavior 的 prices.getOrDefault(...) 处断点,观察 Fake 内部那个 Map 的真实内容——这是它"有状态"的证据;spy(real) 处按 F7 进入,观察 Mockito 如何复制真实对象的字段创建一个代理实例;mock_verifies_interactions 里的 verifyNoMoreInteractions 注释掉,然后在 PricingService 里多加一行 repository.priceOf(sku)(重复查一次价),重跑——观察哪个版本能抓到这个问题。MockitoExtension 找不到(少了依赖) 找不到符号: 类 MockitoExtension
程序包 org.mockito.junit.jupiter 不存在原因:mockito-junit-jupiter 是独立于 mockito-core 的 artifact。只加 mockito-core 能用 mock(),但用不了 @Mock / @InjectMocks。
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>java.lang.IllegalArgumentException: Java 25 (69) is not supported by the current version
of Byte Buddy which officially supports Java 24 (68)
- update Byte Buddy or set net.bytebuddy.experimental as a VM property现象很有迷惑性:mock 接口正常,mock/spy 具体类挂掉——因为 mock 具体类需要 Byte Buddy 生成子类,而接口不需要。
原因:Mockito 内部依赖 Byte Buddy,而 Byte Buddy 对新 JDK 的字节码版本有明确的支持上限。JDK 升级快于 Mockito/Byte Buddy 的跟进。
两种修复:
<!-- 方案一(推荐):显式升级 byte-buddy 到支持当前 JDK 的版本 -->
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.17.6</version>
<scope>test</scope>
</dependency><!-- 方案二:让 surefire 打开实验开关(治标,不推荐长期使用) -->
<configuration>
<argLine>-Dnet.bytebuddy.experimental=true</argLine>
</configuration>这条经验值得记住:当"接口 mock 正常、具体类 mock 报错"时,先怀疑字节码工具与新 JDK 的兼容性,而不是自己的代码。
AuditLog dummy = (sku, price) -> { };
service.applyDiscount("A", 10);
// 什么都没验证:这个测试只证明了"没抛异常"修复:要么补上断言(此时它就不再是 Dummy 了),要么确认"不抛异常"本身就是被测目标。
spy() 替代 mock() PriceRepository spy = spy(new RealPriceRepository()); // 会真的去查数据库!问题:spy() 默认执行真实逻辑,如果真实实现有副作用(查库、写文件、发请求),测试会变慢甚至失败。修复:需要完全替换就用 mock();只在"想保留真实行为"时才用 spy()。
class InMemoryRepository implements Repository {
// 这里有个 bug:get 时用了 put(key, value) 而不是 return
}问题:Fake 有逻辑,就可能有 bug。如果 Fake 错了,所有用它的测试都建立在错误的地基上。
修复:给 Fake 写它自己的单元测试(它也是代码)。这是使用 Fake 的隐性成本,也是"Fake 只该给多个测试复用"的原因。
目标:为同一个依赖分别实现五种替身,并体会选择依据。
步骤:
FakeStockRepository(内存实现 StockRepository:stockOf / decrease / increase),并写一个测试:初始库存 10,扣减 3 后查得 7;when(...) 才能模拟出"扣减后的状态"——记录这个数字;验收方式:能说出第 3 步中"需要几次打桩"以及它的含义。
扩展任务:给 PricingService 增加一个"折扣不可超过 50%"的新规则,然后用 Dummy 写一个测试(因为该场景下 AuditLog 不会被调用)——说明为什么这里用 Dummy 比用 Mock 更合适。
基础题
spy() 与 mock() 的关键区别是什么?代码阅读题
interface Clock {
Instant now();
}
// 测试 A
Clock stubClock = () -> Instant.parse("2026-09-21T10:00:00Z");
// 测试 B
class FakeClock implements Clock {
private Instant current;
FakeClock(Instant start) { this.current = start; }
void advance(Duration duration) { current = current.plus(duration); }
@Override public Instant now() { return current; }
}编程题
MessageQueue:void publish(String topic, String payload)、Optional<String> poll(String topic);NotificationService:void notifyUser(String userId, String message)——内部调用 queue.publish("notification", ...);综合任务
OrderService.place(sku, quantity):先查库存、扣减、再支付;支付失败要回滚库存(参考 labs 里的 OrderService);StockRepository 与 PaymentGateway 各选择一种替身,并写出理由;StockRepository 换一种替身类型再实现一遍,对比两次的代码量与可读性,最后说明你的选择。提示 1(方向):第 4 题的关键是"测试 A 时间不动、测试 B 时间可推进"。
提示 2(关键点):第 6 题的 Fake 用 List<String> 或 Deque 就能实现队列语义。
提示 3(接近答案):第 7 题的"支付失败回滚库存"场景需要验证状态(库存恢复了没有)而不是验证交互——这直接指向 Fake;而"支付网关"只需验证"扣款参数对不对",Mock 更合适。
thenReturn(a).thenReturn(b))——而那是"你手写的剧本",不是状态。mock() 完全是假的(所有方法返回默认值/null,除非打桩);spy() 包装一个真实对象,默认执行真实逻辑,只在打了桩的方法上替换行为。所以 spy() 适合"用真实实现 + 记录调用",mock() 适合"完全替换依赖"。第 4 题:
Instant;第 5 题:用 Fake(FakeClock)。
"30 分钟内未支付则取消"这个需求需要时间前进才能测到边界:
| 想测的场景 | FakeClock 怎么做 |
|---|---|
| 刚下单(0 分钟) | 不推进,直接查→订单未取消 |
| 29 分钟 | advance(Duration.ofMinutes(29)) → 未取消 |
| 30 分钟(边界) | advance(Duration.ofMinutes(30)) → 取消 |
| 31 分钟 | advance(Duration.ofMinutes(31)) → 取消 |
用 Stub 做不到:返回值恒定,时间不会前进,你只能测到"一个时间点"的行为。
用 Thread.sleep(30 分钟) 更不行——这是"测试卡死"的典型来源。
顺带一个提醒:"把 Clock 作为构造参数注入"是让这类测试可行的前提——这也是"可测试性设计"的典型案例(T1-006 可测试性与 Arrange-Act-Assert)。
package cn.kb.learn.testing;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
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 NotificationServiceTest {
interface MessageQueue {
void publish(String topic, String payload);
Optional<String> poll(String topic);
}
static class NotificationService {
private final MessageQueue queue;
NotificationService(MessageQueue queue) {
this.queue = queue;
}
void notifyUser(String userId, String message) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("用户不能为空");
}
if (message == null || message.isBlank()) {
throw new IllegalArgumentException("消息不能为空");
}
queue.publish("notification", userId + ":" + message);
}
}
/** Fake:内存队列,按主题分组、先进先出 */
static class InMemoryMessageQueue implements MessageQueue {
private final Map<String, Deque<String>> topics = new HashMap<>();
@Override
public void publish(String topic, String payload) {
topics.computeIfAbsent(topic, key -> new ArrayDeque<>()).addLast(payload);
}
@Override
public Optional<String> poll(String topic) {
Deque<String> queue = topics.get(topic);
return (queue == null || queue.isEmpty()) ? Optional.empty() : Optional.of(queue.pollFirst());
}
}
@Nested
@DisplayName("Dummy:消息队列不会被用到")
class WithDummy {
@Test
void rejects_blank_user_before_publishing() {
MessageQueue dummy = (topic, payload) -> { };
NotificationService service = new NotificationService(dummy);
assertThatThrownBy(() -> service.notifyUser(" ", "你好"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("用户不能为空");
}
}
@Nested
@DisplayName("Mock:验证发送的主题与内容")
class WithMock {
@Test
void publishes_to_notification_topic() {
MessageQueue queue = mock(MessageQueue.class);
NotificationService service = new NotificationService(queue);
service.notifyUser("u1", "订单已发货");
verify(queue).publish("notification", "u1:订单已发货");
}
}
@Nested
@DisplayName("Fake:验证消息顺序")
class WithFake {
@Test
void keeps_messages_in_order() {
InMemoryMessageQueue fake = new InMemoryMessageQueue();
NotificationService service = new NotificationService(fake);
service.notifyUser("u1", "第一条");
service.notifyUser("u2", "第二条");
assertThat(fake.poll("notification")).contains("u1:第一条");
assertThat(fake.poll("notification")).contains("u2:第二条");
assertThat(fake.poll("notification")).isEmpty();
}
@Test
void unknown_topic_returns_empty() {
InMemoryMessageQueue fake = new InMemoryMessageQueue();
assertThat(fake.poll("not-exist")).isEmpty();
}
}
}注意 Fake 自己那两个测试:它们不测 NotificationService,只测 Fake。这是 Fake 的必要配套——Fake 也是代码,也需要被验证。
为什么第 3 个测试用 Fake 而不是 Mock:因为要验证的是"顺序"这个跨调用的状态属性。用 Mock 就得写连续桩:
when(queue.poll("notification")).thenReturn(Optional.of("u1:第一条"));
when(queue.poll("notification")).thenReturn(Optional.of("u2:第二条"));这段桩本身没有证明任何东西——它只是把你想要的顺序写了两遍。Fake 让顺序成为"真实的执行结果",这才是有意义的验证。
替身选择与理由:
| 依赖 | 选择 | 理由 |
|---|---|---|
StockRepository | Fake | 有状态:查→扣→(失败时)加。要验证的是"回滚后库存恢复了没有",这是状态断言;用 Mock 就得手工同步每次打桩的返回值,等于把测试写成剧本 |
PaymentGateway | Mock | 无状态、只关心"扣款参数是否正确"、且"支付失败"要能一键模拟。这是交互断言的典型场景 |
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.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@DisplayName("下单流程:Fake + Mock 组合")
class PlaceOrderStrategyTest {
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);
}
}
static class OrderService {
private final StockRepository stockRepository;
private final PaymentGateway paymentGateway;
OrderService(StockRepository stockRepository, PaymentGateway paymentGateway) {
this.stockRepository = stockRepository;
this.paymentGateway = paymentGateway;
}
void place(String sku, int quantity) {
int stock = stockRepository.stockOf(sku);
if (stock < quantity) {
throw new IllegalStateException("库存不足:" + stock + " < " + quantity);
}
stockRepository.decrease(sku, quantity);
try {
paymentGateway.charge(new Payment(sku, quantity));
} catch (RuntimeException exception) {
stockRepository.increase(sku, quantity);
throw exception;
}
}
}
private FakeStockRepository stockRepository;
private PaymentGateway paymentGateway;
private OrderService service;
@BeforeEach
void setUp() {
stockRepository = new FakeStockRepository();
paymentGateway = mock(PaymentGateway.class);
service = new OrderService(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_stock_not_enough() {
stockRepository.put("A", 2);
assertThatThrownBy(() -> service.place("A", 5))
.isInstanceOf(IllegalStateException.class)
.hasMessage("库存不足:2 < 5");
assertThat(stockRepository.stockOf("A")).isEqualTo(2);
verify(paymentGateway, never()).charge(org.mockito.ArgumentMatchers.any());
}
@Test
@DisplayName("支付失败:库存回滚")
void rolls_back_stock_when_payment_fails() {
stockRepository.put("A", 10);
doThrow(new IllegalStateException("支付网关不可用"))
.when(paymentGateway).charge(new Payment("A", 3));
assertThatThrownBy(() -> service.place("A", 3))
.isInstanceOf(IllegalStateException.class)
.hasMessage("支付网关不可用");
assertThat(stockRepository.stockOf("A")).isEqualTo(10);
}
}换成"全 Mock"方案的对比:
// 全 Mock:需要手工同步"库存状态"
when(stockRepository.stockOf("A")).thenReturn(10); // 第一次查
// place 内部:decrease 后没有再查,所以只需在回滚场景再打一次桩
when(stockRepository.stockOf("A")).thenReturn(10); // 回滚后再查——必须再写一遍
assertThat(stockRepository.stockOf("A")).isEqualTo(10);关键差别:
| Fake 方案 | 全 Mock 方案 | |
|---|---|---|
| 断言内容 | stockOf("A") 的真实返回值 | 打桩时写的数字 |
| 能否发现"少扣一次" | 能(状态会不符) | 不能(decrease 调用被 verify 了就通过) |
| 代码量 | 一个 Fake 类(可复用) | 每个测试多几行桩 |
结论:"被测代码会改的状态"用 Fake,"只被调用一次就结束的边界"用 Mock。 这条经验能解决大部分选择困难。
spy() 默认执行真实逻辑,理解它与 mock()、手写 Spy 的差别;《Mockito 进阶:ArgumentCaptor 与依赖注入》:把替身用得更精确。
暂无