Mock、Stub 和 Fake 有什么区别
1. 概述
编写单元测试时,你经常需要用“替身”对象替换数据库、支付网关或电子邮件服务等真实依赖。这些对象统称为 test double。很多人把它们都称为“mock”,但 mock、stub 和 fake 有三种不同的用途。
本文通过同一个示例帮助你区分这三种类型,知道什么时候用哪种类型,以及如何避免导致测试变脆或失去意义的常见错误。
2. 全文示例
2.1. 依赖
本文使用 JUnit 5、AssertJ 和 Mockito。如果你使用 Spring Boot 3.x,spring-boot-starter-test 已经内置了这三个库。否则,请添加以下依赖:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
</dependencies>
2.2. 待测代码
我们有一个简单的 OrderService。这个服务进行收费、保存订单,然后发送确认电子邮件:
public record Order(String id, String customerEmail, BigDecimal amount) {}
public interface PaymentGateway {
boolean charge(String customerEmail, BigDecimal amount);
}
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
}
public interface EmailSender {
void send(String to, String subject);
}
public class OrderService {
private final PaymentGateway paymentGateway;
private final OrderRepository orderRepository;
private final EmailSender emailSender;
public OrderService(PaymentGateway paymentGateway,
OrderRepository orderRepository,
EmailSender emailSender) {
this.paymentGateway = paymentGateway;
this.orderRepository = orderRepository;
this.emailSender = emailSender;
}
public boolean placeOrder(Order order) {
if (!paymentGateway.charge(order.customerEmail(), order.amount())) {
return false;
}
orderRepository.save(order);
emailSender.send(order.customerEmail(), "订单 " + order.id() + " 已确认");
return true;
}
}
OrderService 有三个依赖。每个依赖都适合不同类型的 test double:
PaymentGateway提供输入数据(支付结果),所以适合用 stub。EmailSender是外部副作用,需要验证(是否发送了电子邮件),所以适合用 mock。OrderRepository有实际的持久化行为,所以适合用 fake。
3. Stub:返回预设答案
Stub 是一个为方法调用返回固定值的对象。Stub 不关心它被调用多少次,也不会对 stub 进行任何断言。Stub 的唯一目的是让被测代码进入你想要检查的逻辑分支。
3.1. 手写 Stub
最简单的方法是自己实现接口:
class DecliningPaymentGateway implements PaymentGateway {
@Override
public boolean charge(String customerEmail, BigDecimal amount) {
return false;
}
}
这个类总是拒绝支付。我们用它来检查“支付失败”的分支:
class OrderServiceStubTest {
@Test
void givenDeclinedPayment_whenPlaceOrder_thenReturnFalse() {
OrderService service = new OrderService(
new DecliningPaymentGateway(),
mock(OrderRepository.class),
mock(EmailSender.class));
boolean result = service.placeOrder(
new Order("ORD-1", "an@example.com", new BigDecimal("150000")));
assertThat(result).isFalse();
}
}
在这个测试中:
DecliningPaymentGateway是 stub。它强制placeOrder()进入return false分支。- 断言基于服务返回的结果,不是基于 stub。
mock(OrderRepository.class)和mock(EmailSender.class)这里只扮演 dummy 角色。这些对象只是为了填充构造函数参数,并不实际使用。
3.2. 用 Mockito 创建 Stub
为每种情况手写一个类很快就会变得繁琐。Mockito 允许在测试中直接创建 stub:
@Test
void givenApprovedPayment_whenPlaceOrder_thenReturnTrue() {
PaymentGateway paymentGateway = mock(PaymentGateway.class);
when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(true);
OrderService service = new OrderService(
paymentGateway, mock(OrderRepository.class), mock(EmailSender.class));
assertThat(service.placeOrder(
new Order("ORD-2", "binh@example.com", new BigDecimal("99000"))))
.isTrue();
}
虽然用 mock() 函数创建,但这里的 paymentGateway 在职责上仍然是 stub:我们只通过 when(...).thenReturn(...) 配置返回值,完全不会 verify 它。库函数的名称不会决定 test double 的类型。你如何使用这个对象才是决定性因素。
4. Mock:验证交互
Mock 是一个记录对它的方法调用的对象,这样你可以之后验证代码是否调用了正确的方法、使用了正确的参数、调用了正确的次数。与 stub 不同的是,断言直接作用于 mock 本身。
Mock 在验证不返回任何内容的副作用时很有用,例如发送电子邮件、发布事件或写审计日志。
@ExtendWith(MockitoExtension.class)
class OrderServiceMockTest {
@Mock
PaymentGateway paymentGateway;
@Mock
OrderRepository orderRepository;
@Mock
EmailSender emailSender;
@InjectMocks
OrderService orderService;
@Test
void givenApprovedPayment_whenPlaceOrder_thenSendConfirmationEmail() {
when(paymentGateway.charge("an@example.com", new BigDecimal("150000")))
.thenReturn(true);
orderService.placeOrder(new Order("ORD-1", "an@example.com", new BigDecimal("150000")));
verify(emailSender).send("an@example.com", "订单 ORD-1 已确认");
}
@Test
void givenDeclinedPayment_whenPlaceOrder_thenNoEmailSent() {
when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(false);
orderService.placeOrder(new Order("ORD-1", "an@example.com", new BigDecimal("150000")));
verifyNoInteractions(emailSender, orderRepository);
}
}
逐部分分析:
@ExtendWith(MockitoExtension.class)为 JUnit 5 激活 Mockito。标有@Mock的字段在每个测试前被初始化。@InjectMocks创建OrderService并通过构造函数注入三个@Mock对象。paymentGateway仍然是 stub,而emailSender才是真正的 mock,因为我们对它进行verify。verify(emailSender).send(...)检查send()被调用恰好一次,且使用正确的两个参数。verifyNoInteractions(...)确认当支付失败时,系统既不保存订单也不发送电子邮件。
如果你不小心删除了 OrderService 中的 emailSender.send(...) 一行,第一个测试将失败并显示类似的消息:
Wanted but not invoked:
emailSender.send(
"an@example.com",
"订单 ORD-1 已确认"
);
-> at OrderServiceMockTest.givenApprovedPayment_whenPlaceOrder_thenSendConfirmationEmail
Actually, there were zero interactions with this mock.
注意: 不应该既 stub 又 verify 同一个调用,例如
when(paymentGateway.charge(...))然后再verify(paymentGateway).charge(...)。如果 stub 没有被调用,服务已经没有返回true,测试会在别的地方失败。这时的verify命令就显得多余,只会让测试与实现耦合更紧密。
5. Fake:真实的简化实现
Fake 是一个真实有效的实现,但使用了对生产环境不适用的快速路径。典型的例子是在 HashMap 中而不是数据库中存储数据的 repository:
public class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> storage = new HashMap<>();
@Override
public void save(Order order) {
storage.put(order.id(), order);
}
@Override
public Optional<Order> findById(String id) {
return Optional.ofNullable(storage.get(id));
}
}
与 stub 不同的是,fake 有逻辑和状态。调用 save() 然后调用 findById() 会返回刚保存的订单。因此,你检查的是最终状态,而不是方法调用:
class OrderServiceFakeTest {
private final InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();
private final OrderService orderService = new OrderService(
(email, amount) -> true, // lambda 形式的 stub
orderRepository, // fake
(to, subject) -> { }); // lambda 形式的 dummy
@Test
void givenApprovedPayment_whenPlaceOrder_thenOrderIsPersisted() {
Order order = new Order("ORD-3", "chi@example.com", new BigDecimal("250000"));
orderService.placeOrder(order);
assertThat(orderRepository.findById("ORD-3")).contains(order);
}
}
有几点值得注意:
PaymentGateway和EmailSender都只有一个抽象方法,所以我们用 lambda 创建 stub 和 dummy,无需 Mockito。- 测试不知道
OrderService调用save()多少次或以什么顺序调用。测试只关心放置订单后,该订单是否在存储中。 - 如果之后你重构
OrderService使用另一个方法,例如saveAll(),使用 fake 的测试仍然可以运行,只要最终结果正确。使用verify(orderRepository).save(order)的测试就会破裂。
注意: Fake 是代码,所以 fake 也可能有 bug。如果 fake 的行为与真实实现不同(例如在主键重复时不抛出异常),测试可能在开发环境通过而在生产环境失败。对于重要的 fake,应该编写一个契约测试套件,同时在 fake 和真实实现上运行(例如用 Testcontainers 在 JPA 实现上运行)。
6. 比较和选择
下表总结了差异:
| 标准 | Stub | Mock | Fake |
|---|---|---|---|
| 目的 | 提供输入数据 | 验证交互 | 用更轻的方式替代真实行为 |
| 有逻辑吗 | 没有,返回固定值 | 没有,只记录调用 | 有,表现得像真实的 |
| 断言在哪里 | 在被测代码的结果上 | 在 mock 本身上(verify) |
在 fake 的状态上 |
| 测试类型 | 基于状态 | 基于交互 | 基于状态 |
| 重构时的健壮性 | 高 | 较低 | 高 |
除了这三种,你还会遇到另外两种:
- Dummy:作为参数传入但从不使用的对象。
- Spy:包装真实对象的对象,仍然执行真实逻辑但记录调用以便
verify。在 Mockito 中,用spy(...)创建 spy。
选择的简单规则:
- 依赖为你的代码返回数据就用 stub。
- 依赖是外部副作用(电子邮件、消息队列、第三方 API)且无法观察状态就用 mock。
- 依赖有状态且在多个测试中使用(repository、缓存、时钟)就考虑写一次 fake 然后重复使用。
总的来说,应该优先进行基于状态的检查(stub、fake)而不是基于交互的检查(mock)。基于状态的测试描述结果,而基于交互的测试描述做法,做法比结果变化更频繁。
7. 常见错误
所有东西都 mock。 如果一个测试有五个 when 语句和四个 verify 语句,那么该测试是在复制实现而不是检查行为。改变两行代码的顺序,测试就会失败,尽管结果仍然正确。
Mock 值对象。 别 mock Order、BigDecimal 或 LocalDate。创建真实的实例。Mockito 5 默认可以 mock final 类和 record,但能做不代表应该做。
Mock 你不拥有的类型。 直接 mock RestTemplate 或供应商的 SDK 会让测试对库的行为做出错误的假设。应该将库包装在你拥有的接口后面(如 PaymentGateway),然后 mock 该接口。真实的实现应该用集成测试检查。
多余的 Stub。 使用 MockitoExtension 时,Mockito 默认使用严格 stub。如果你 stub 了一个代码永远不会执行的调用,测试将失败:
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
不要急着用 lenient() 关闭这个警告。这个消息通常表明测试没有进入你认为会进入的分支,或者 setup 部分已经过时。
用 fake 替代集成测试。 InMemoryOrderRepository 无法检查 SQL、约束或事务。Fake 使单元测试更快,但你仍然需要一些与真实数据库的集成测试。
8. 总结
Stub、mock 和 fake 都是 test double,但它们回答三个不同的问题。Stub 回答“依赖返回什么?”。Mock 回答“代码是否以正确的方式调用依赖?”。Fake 回答“如果有一个真实但轻量级的版本,最终结果会是什么?”。
根据依赖的角色选择 test double 类型,而不是根据工具:用 mock() 创建的对象完全可以只是 stub。优先进行状态检查,只对真正需要验证的副作用使用 verify。你的测试会更简洁、更易读,重构时也更不容易破裂。