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。

选择的简单规则:

  1. 依赖为你的代码返回数据就用 stub。
  2. 依赖是外部副作用(电子邮件、消息队列、第三方 API)且无法观察状态就用 mock。
  3. 依赖有状态且在多个测试中使用(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。你的测试会更简洁、更易读,重构时也更不容易破裂。

testingmockitojunit5unit-testjava