Skip to content

Mockito:Mock、Stub 与 Verify ​

T1-003必学进阶约 90 分钟JDK 21测试专项 A:单元测试 · Mockito

1. 本节定位 ​

《JUnit 5:生命周期与参数化测试》 能测试"没有依赖的类"。但真实业务类必然有依赖:查数据库、发短信、调第三方接口。

本节解决这个矛盾:如何在不连数据库、不发真实短信的前提下,验证业务逻辑正确。答案是测试替身(Test Double),而 Mockito 是 Java 里用得最多的替身工具。

2. 学习目标 ​

完成本节后,你应该能够:

  • 用构造方法注入依赖,让类变得可测试;
  • 用 mock() 创建替身,用 when(...).thenReturn(...) 准备返回值(打桩);
  • 用 verify(...) 验证交互,用 never() / verifyNoInteractions() 验证"不该发生的事";
  • 说清 Stub 与 Mock 的区别(同一个对象,两种用法);
  • 判断哪些依赖该被替身化,哪些不该。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

看一个典型的业务方法:

java
public String rename(String userId, String newName) {
    User user = repository.findById(userId).orElseThrow(...);   // 要查数据库
    repository.save(user.rename(newName));                      // 要写数据库
    notifier.send(userId, "名字已修改");                          // 要发短信
    return newName;
}

如果测试真的连数据库和短信网关:

  • 慢:每条用例都要等网络;
  • 不稳定:数据库被清空、短信接口限流,测试就红了——但代码是对的;
  • 写不进去数据:怎么测"用户不存在"?总不能把生产库的用户删掉。

测试替身把依赖换成可控的假对象:想返回什么就返回什么,想验证有没有被调用就验证。于是测试变成了纯粹的"输入 → 逻辑 → 输出"。

从 QA 视角理解

这就是你在做接口测试时的"挡板服务"(Mock Server)思路:依赖不稳定或不可控时,用可控的替身隔离它。区别是编码级替身更轻——不需要起服务,直接是内存里的一个对象。

5. 核心概念 ​

5.1 测试替身的五种类型 ​

类型作用Mockito 对应
Dummy占位用,不会被真正调用mock() 后不设置任何行为
Stub提供预设返回值when(...).thenReturn(...)
Spy包装真实对象,可选择性打桩spy(realObject)、@Spy
Mock记录交互,用于验证调用mock() + verify(...)
Fake有简化实现的替代品(如内存数据库)自己写类,Mockito 不涉及

实际工作中 90% 的场景只用 Stub 和 Mock 两种用法,而它们可以是同一个对象:

java
UserRepository repository = mock(UserRepository.class);

// 当成 Stub 用:准备返回值
when(repository.findById("u1")).thenReturn(Optional.of(user));

// 当成 Mock 用:验证交互
verify(repository).save(any());

5.2 核心三件套 ​

java
// 1. 创建替身
UserRepository repository = mock(UserRepository.class);

// 2. 打桩(Stub):指定输入对应的输出
when(repository.findById("u1")).thenReturn(Optional.of(user));
when(repository.findById("u404")).thenReturn(Optional.empty());

// 3. 验证(Mock):确认某次交互发生过
verify(repository).save(any(User.class));
verify(repository, never()).deleteById("u1");
verifyNoInteractions(notifier);            // 完全没碰过这个替身

常用验证模式:

写法含义
verify(x).method()恰好调用一次
verify(x, times(2)).method()恰好两次
verify(x, never()).method()从未调用
verify(x, atLeastOnce()).method()至少一次
verifyNoInteractions(x)该替身完全没有交互
verifyNoMoreInteractions(x)除了已验证的,没有其他交互

5.3 可测试性的前提:依赖注入 ​

java
// 不可测试:依赖写死在内部,测试时无法替换
public class UserService {
    private final UserRepository repository = new MySqlUserRepository();
}

// 可测试:依赖从构造方法传入,测试时传替身
public class UserService {
    private final UserRepository repository;

    public UserService(UserRepository repository) {
        this.repository = repository;
    }
}

"难测试"通常是设计问题的信号:当一个类无法注入依赖时,说明它把创建依赖的责任揽在了自己身上(违反了单一职责)。这也是测试驱动设计的价值之一。

5.4 哪些该替身,哪些不该 ​

该替身理由
数据库访问(Repository)慢、有状态、不可控
外部服务(HTTP、短信、支付)慢、收费、不确定
时间、随机数结果不可预测
消息发送、文件写入有副作用
不该替身理由
被测类自己替身了就等于没测
值对象、record、String简单且无副作用,真实使用更可靠
纯计算逻辑真实调用才能验证正确性

6. 递进示例 ​

6.1 最小示例 ​

java
interface Clock {
    long nowInMillis();
}

@Test
void should_use_fixed_time() {
    Clock clock = mock(Clock.class);
    when(clock.nowInMillis()).thenReturn(1_000L);

    assertEquals(1_000L, clock.nowInMillis());
}

6.2 业务示例:改名流程的三个场景 ​

业务规则:

  1. 用户不存在 → 抛异常,不保存、不通知;
  2. 名字没变化 → 不保存、不通知(避免无意义的写库与打扰);
  3. 名字变化 → 保存新用户 + 发送通知。

这三条规则正好对应三个测试,其中第 1、2 条的重点是验证"什么都没发生"——这是 Mock 最擅长、也是手工测试最难覆盖的部分。

6.3 反例:测试里 mock 了不该 mock 的东西 ​

java
// 错误:把被测对象自己 mock 了,测试变成"验证 Mockito 能用"
UserService service = mock(UserService.class);
when(service.rename("u1", "新名")).thenReturn("新名");
assertEquals("新名", service.rename("u1", "新名"));   // 永远通过,毫无意义

判断标准:如果一个测试删掉被测类的实现(留空方法体)仍然通过,那它没有在测试任何东西。

7. 完整可运行代码 ​

接口与模型:labs/testing-unit/src/main/java/cn/kb/learn/testing/

java
package cn.kb.learn.testing;

import java.util.Optional;

public interface UserRepository {
    Optional<User> findById(String id);

    User save(User user);
}
java
package cn.kb.learn.testing;

public interface Notifier {
    void send(String userId, String message);
}
java
package cn.kb.learn.testing;

public record User(String id, String name, String email) {
    public User rename(String newName) {
        return new User(id, newName, email);
    }
}

被测类:

java
package cn.kb.learn.testing;

public class UserService {
    private final UserRepository repository;
    private final Notifier notifier;

    public UserService(UserRepository repository, Notifier notifier) {
        this.repository = repository;
        this.notifier = notifier;
    }

    public String rename(String userId, String newName) {
        if (newName == null || newName.isBlank()) {
            throw new IllegalArgumentException("新名字不能为空");
        }
        User user = repository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("用户不存在:" + userId));
        if (user.name().equals(newName)) {
            return newName;
        }
        repository.save(user.rename(newName));
        notifier.send(userId, "您的名字已更改为 " + newName);
        return newName;
    }
}

测试类:labs/testing-unit/src/test/java/cn/kb/learn/testing/UserServiceTest.java

java
package cn.kb.learn.testing;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

class UserServiceTest {
    private UserRepository repository;
    private Notifier notifier;
    private UserService service;

    @BeforeEach
    void setUp() {
        repository = mock(UserRepository.class);
        notifier = mock(Notifier.class);
        service = new UserService(repository, notifier);
    }

    @Test
    @DisplayName("用户存在且名字变化:保存新用户并发送通知")
    void should_rename_and_notify_when_user_exists() {
        // Arrange
        when(repository.findById("u1"))
                .thenReturn(Optional.of(new User("u1", "旧名", "u1@test.com")));

        // Act
        String result = service.rename("u1", "新名");

        // Assert
        assertEquals("新名", result);
        verify(repository).save(new User("u1", "新名", "u1@test.com"));
        verify(notifier).send("u1", "您的名字已更改为 新名");
    }

    @Test
    @DisplayName("用户不存在:抛异常且不保存、不通知")
    void should_throw_when_user_not_found() {
        when(repository.findById("u404")).thenReturn(Optional.empty());

        assertThrows(IllegalArgumentException.class, () -> service.rename("u404", "新名"));

        verify(repository, never()).save(any());
        verifyNoInteractions(notifier);
    }

    @Test
    @DisplayName("名字未变化:不保存、不通知")
    void should_skip_when_name_unchanged() {
        when(repository.findById("u1"))
                .thenReturn(Optional.of(new User("u1", "同名", "u1@test.com")));

        assertEquals("同名", service.rename("u1", "同名"));

        verify(repository, never()).save(any());
        verifyNoInteractions(notifier);
    }
}

运行方式(Maven):

powershell
mvn -pl testing-unit test

预期输出:

text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

为什么 verify 里的 save 能匹配上

verify(repository).save(new User("u1", "新名", "u1@test.com")) 依赖 User 的 equals。这里 User 是 record,自动按字段比较——所以能匹配。如果 User 是普通类且没重写 equals,这条验证会失败,你会怀疑"代码没执行",其实是"对象不相等"。

8. Debug 观察点 ​

  1. 在 verify(repository).save(...) 处打断点,展开 Variables 里的 repository,可以看到 Mockito 生成的替身对象名(如 userRepository$MockitoMock$123)以及它记录的调用历史;

  2. 故意把 verify(notifier).send("u1", "改动的内容") 的消息文本写错,运行后看失败信息:

    text
    Argument(s) are different! Wanted:
    notifier.send("u1", "改动的内容");
    Actual invocations have different arguments:
    notifier.send("u1", "您的名字已更改为 新名");

    这段信息直接给出"期望调用"与"实际调用"的对比,是 Mockito 最好用的地方;

  3. 把 verify(repository, never()).save(any()) 改成 verify(repository).save(any()),观察失败信息变成 Wanted but not invoked——用来区分"调用了但参数不对"与"根本没调用"这两类问题。

9. 常见错误 ​

错误 1:忘记打桩,得到 null 然后 NPE ​

java
// 没有 when(...),findById 默认返回 null
User user = repository.findById("u1").orElseThrow();   // NullPointerException

原因:Mockito 的默认返回值是"空值"(对象返回 null、Optional 返回 Optional.empty()、数字返回 0、boolean 返回 false)。

修复:明确打桩;如果就该返回空,用 thenReturn(Optional.empty())。

错误 2:打桩参数对象没重写 equals ​

java
when(repository.findById(new UserId("u1"))).thenReturn(Optional.of(user));
// 实际调用时传的是另一个 UserId 实例,equals 未重写 → 打桩不生效

修复:参数用 record 或重写 equals;或使用 any() 等匹配器放宽条件(但别为了绕过问题而放宽)。

错误 3:verify 过度约束实现细节 ​

java
verify(repository, times(2)).findById("u1");   // 实现改一次查询,测试就红了

修复:只验证业务结果(保存了什么、通知了什么),不要验证"查了几次"这类实现细节。默认 verify(x).method()(一次)已经足够表达意图。

错误 4:mock 了具体类而不是接口 ​

java
OrderCalculator calculator = mock(OrderCalculator.class);   // 把被测逻辑 mock 掉了

修复:被测类要真实实例化;只有依赖才需要替身。

10. 动手实验 ​

目标:为一段带依赖的业务逻辑写出完整测试。

步骤:

  1. 把第 7 节的五个类放到本地项目,运行测试,确认 3 个全绿;
  2. 给 UserService 增加方法 delete(String userId):用户不存在时抛异常;存在时先 repository.delete(user) 再 notifier.send(userId, "账号已删除")(需要给 UserRepository 加 delete 方法);
  3. 为 delete 写三个测试:正常删除、用户不存在、以及"删除时不发送改名通知"(verify(notifier, never()).send("u1", "您的名字已更改为 新名"));
  4. 故意删掉实现里的 notifier.send(...),确认测试变红;恢复后确认变绿。

验收方式:能解释第 4 步"测试是怎么发现漏掉的通知"。

扩展任务:把 notifier 换成 List<Notifier>(多个通知渠道),测试"两个渠道都收到通知"与"第一个渠道抛异常时的行为"(提示:这涉及异常处理策略,先写清期望再实现)。

11. 练习题 ​

基础题

  1. 判断:mock() 创建的对象,未打桩的方法会调用真实实现。(对 / 错)
  2. 填空:准备返回值用 ______,验证交互用 ______。
  3. 简答:Stub 和 Mock 的区别是什么?为什么说"可以是同一个对象"?

代码阅读题

java
UserRepository repository = mock(UserRepository.class);
when(repository.findById("u1")).thenReturn(Optional.of(new User("u1", "张三", "a@b.c")));
UserService service = new UserService(repository, mock(Notifier.class));

assertThrows(IllegalArgumentException.class, () -> service.rename("u1", "  "));
  1. 这个测试会通过吗?它验证了哪条业务规则?

编程题

  1. 为下面的 ArticleService 写测试(先给出接口实现,再写 4 个测试):
java
public interface ArticleRepository {
    Optional<Article> findById(String id);
    Article save(Article article);
}

public record Article(String id, String title, String content, boolean published) {}

public class ArticleService {
    private final ArticleRepository repository;

    public ArticleService(ArticleRepository repository) { this.repository = repository; }

    /** 发布文章:标题为空或内容为空时抛异常;已发布时抛异常;否则发布并保存 */
    public Article publish(String id, String title, String content) { ... }
}

综合任务

  1. 设计一个"秒杀下单"服务 OrderService:
    • 依赖 StockRepository(查库存、扣库存)与 PaymentGateway(扣款);
    • 规则:库存不足抛异常且不扣款;扣款失败要回滚库存;成功则扣库存并扣款;
    • 用 Mockito 验证:正常下单、库存不足时不调用扣款、扣款失败时库存被回滚;
    • 说明哪些交互必须验证、哪些不该验证。
分级提示

提示 1(方向):第 5 题的四个测试分别是:标题为空、内容为空、已发布、正常发布。

提示 2(关键点):第 6 题的"回滚"要验证 StockRepository 被调用了两次(扣减 + 回滚),可以用 verify(stockRepository, times(2)).update(any())。

提示 3(接近答案):第 6 题里"库存不足时不调用扣款"用 verifyNoInteractions(paymentGateway) 最直观;"扣款失败"要注意 charge 返回 void,必须用 doThrow(...).when(paymentGateway).charge(any()),写成 when(...).thenThrow(...) 直接编译不过。

12. 参考答案 ​

基础题
  1. 错。mock() 创建的是完全空壳的替身,所有方法默认返回空值,不会调用真实实现(要部分真实行为请用 spy)。
  2. when(...).thenReturn(...)、verify(...)。
  3. Stub 关注"给什么返回值"(输入 → 输出),Mock 关注"有没有被调用、怎么调的"(交互)。它们是同一个替身对象的两类用法,所以一个对象既可以打桩又能验证。
代码阅读题(第 4 题)

不会通过。

rename 的实现里,空名字校验在查用户之前:

java
if (newName == null || newName.isBlank()) {
    throw new IllegalArgumentException("新名字不能为空");
}

所以调用会抛异常,测试会通过断言——等一下,结论要看完:" " 是空白字符串,isBlank() 返回 true,确实抛异常,所以这个测试会通过。

它的价值在于:验证"空名字校验发生在查询数据库之前"——这也是为什么 when(repository.findById("u1")) 在这里其实是多余的(不会被调用)。如果你想顺带验证这一点,可以补上 verifyNoInteractions(repository)。

编程题(第 5 题)
java
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.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class ArticleServiceTest {
    private ArticleRepository repository;
    private ArticleService service;

    @BeforeEach
    void setUp() {
        repository = mock(ArticleRepository.class);
        service = new ArticleService(repository);
    }

    @Test
    @DisplayName("标题为空时抛出异常且不保存")
    void should_throw_when_title_blank() {
        assertThrows(IllegalArgumentException.class, () -> service.publish("a1", " ", "内容"));
        verify(repository, never()).save(any());
    }

    @Test
    @DisplayName("内容为空时抛出异常且不保存")
    void should_throw_when_content_blank() {
        assertThrows(IllegalArgumentException.class, () -> service.publish("a1", "标题", ""));
        verify(repository, never()).save(any());
    }

    @Test
    @DisplayName("文章已发布时抛出异常")
    void should_throw_when_already_published() {
        when(repository.findById("a1"))
                .thenReturn(Optional.of(new Article("a1", "标题", "内容", true)));

        assertThrows(IllegalStateException.class, () -> service.publish("a1", "标题", "内容"));
        verify(repository, never()).save(any());
    }

    @Test
    @DisplayName("正常发布:保存为已发布状态")
    void should_publish_and_save() {
        when(repository.findById("a1"))
                .thenReturn(Optional.of(new Article("a1", "旧标题", "旧内容", false)));
        when(repository.save(any(Article.class)))
                .thenAnswer(invocation -> invocation.getArgument(0));

        Article published = service.publish("a1", "新标题", "新内容");

        assertTrue(published.published());
        verify(repository).save(new Article("a1", "新标题", "新内容", true));
    }
}

要点:前三个测试都在验证"不该发生的事"(不保存),这正是 Mock 的核心价值。

综合任务(第 6 题)
java
class OrderServiceTest {
    private StockRepository stockRepository;
    private PaymentGateway paymentGateway;
    private OrderService service;

    @BeforeEach
    void setUp() {
        stockRepository = mock(StockRepository.class);
        paymentGateway = mock(PaymentGateway.class);
        service = new OrderService(stockRepository, paymentGateway);
    }

    @Test
    @DisplayName("正常下单:扣库存、扣款、发送通知")
    void should_place_order_successfully() {
        when(stockRepository.stockOf("sku-1")).thenReturn(10);

        service.place("sku-1", 2);

        verify(stockRepository).decrease("sku-1", 2);          // 必须验证:核心副作用
        verify(paymentGateway).charge(any());                  // 必须验证:资金相关
    }

    @Test
    @DisplayName("库存不足:抛出异常且不调用扣款")
    void should_not_charge_when_stock_insufficient() {
        when(stockRepository.stockOf("sku-1")).thenReturn(1);

        assertThrows(IllegalStateException.class, () -> service.place("sku-1", 2));

        verify(stockRepository, never()).decrease(any(), anyInt());
        verifyNoInteractions(paymentGateway);                   // 资金绝不能动
    }

    @Test
    @DisplayName("扣款失败:库存被回滚")
    void should_rollback_stock_when_payment_fails() {
        when(stockRepository.stockOf("sku-1")).thenReturn(10);
        doThrow(new RuntimeException("扣款失败")).when(paymentGateway).charge(any());

        assertThrows(RuntimeException.class, () -> service.place("sku-1", 2));

        InOrder inOrder = Mockito.inOrder(stockRepository);
        inOrder.verify(stockRepository).decrease("sku-1", 2);
        inOrder.verify(stockRepository).increase("sku-1", 2);   // 先扣后加,顺序不能反
    }
}

必须验证的:影响外部世界的动作(扣库存、扣款、发通知)和顺序(先扣库存再扣款,失败必须反向补偿)。 不该验证的:内部查询次数、日志打印、私有方法调用。

InOrder 是验证顺序的工具——当你关心"A 必须在 B 之前发生"时用它,而不是给每个 verify 加 times(1)。

13. 掌握标准 ​

  • [ ] 能用构造方法注入让类变得可测试,并说明为什么;
  • [ ] 会打桩(when/thenReturn)与验证(verify/never/verifyNoInteractions);
  • [ ] 能为"不该发生的事"写测试(这往往是最有价值的用例);
  • [ ] 能判断哪些依赖该替身、哪些该用真实对象。

14. 扩展资源 ​

15. 下一步学习 ​

《JaCoCo:覆盖率指标与报告》 会给出"测试写了多少"的量化反馈;T1-016 Spring 切片测试 会把这套思路搬到 Spring 环境。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引