Mockito 单元测试进阶指南:从基础到彻底避坑 (含实战案例)

在 Java 后端开发中,单元测试是保障代码质量的最后一道防线。而 Mockito 作为 Java 生态中最强大的 Mock 框架,几乎是每个开发者的必修课。

然而,很多开发者在实际写单测时,经常会遇到“Debug 乱跳”、“抛空指针”、“Static 方法无法 Mock”、“UnnecessaryStubbingException”等各种令人抓狂的灵异事件。

本文将结合实际的 Spring Boot 项目场景,从基础概念入手,带你深入理解 Mockito 的运行机制,并彻底解决那些让你头疼的报错。

🛠️ 1. 环境与版本说明

在开始之前,请确保你的项目使用了现代化的测试技术栈。本文的所有示例基于以下版本:

  • Java 版本:Java 8+ (推荐 Java 11/17)
  • 测试框架:JUnit 5 (org.junit.jupiter)
  • Mockito 版本:3.4.0 及以上 (最好是 4.x 或 5.x)

注意:从 3.4.0 开始,Mockito 官方才原生支持静态方法的 Mock (mockito-inline)。

Maven 依赖参考:

<!-- 包含 JUnit 5 和 Mockito -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

<!-- 如果你用的 Mockito 版本较低,必须手动引入此包才能 Mock 静态方法 -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-inline</artifactId>
    <scope>test</scope>
</dependency>

📖 2. 核心注解:@Mock vs @Spy vs @InjectMocks

在编写测试类时,这三个注解是最常用的,理解它们的区别是写好单测的第一步。

2.1 @Mock (完全替身)

  • 作用:创建一个 100% 虚假的代理对象。
  • 行为:默认情况下,它的所有方法都不会真正执行。如果你不给它“打桩”(Stubbing),它有返回值的方法默认返回 null,返回集合的返回空集合,返回布尔的返回 false
  • 场景:外部依赖,比如 UserMapperRedisTemplate

2.2 @Spy (半真半假 / 间谍)

  • 作用:基于一个真实的实例创建一个代理对象,也叫部分 Mock (Partial Mock)。
  • 行为:默认情况下,它会执行真实的业务代码。除非你显式地对它的某个方法进行了打桩拦截。
  • 场景:你想测试某个复杂类(比如 AuthManager),你想让它的大部分逻辑跑真的,但仅仅拦截其中某几个调外系统的方法。

2.3 @InjectMocks (组装车间)

  • 作用:创建一个实例,并把当前测试类里用 @Mock@Spy 创建好的假对象,自动注入(塞进)到这个实例中。
  • 注意:它本身并不是 Mock 对象,它就是我们要真正测试的目标对象。

基础案例演示:

@ExtendWith(MockitoExtension.class) // JUnit 5 必须加这个注解
class AuthServiceImplTest {

    @Mock
    private UserMapper userMapper; // 假的 Mapper

    @Spy
    private AuthManager authManager; // 半真半假的 Manager

    @InjectMocks
    private AuthServiceImpl authService; // 真正的测试目标
}

⚔️ 3. 核心语法:打桩 (Stubbing) 的两条路线

给 Mock 对象设定“当调用 X 方法时,返回 Y 结果”,这个过程叫打桩。

3.1 普通 Mock 写法:when(…).thenReturn(…)

这是最符合人类直觉的写法。

// 语义:当调用 userMapper.selectById() 时,就返回 mockUser
when(userMapper.selectById(anyInt())).thenReturn(mockUser);

限制:绝对不要把它用在 @Spy 对象上! 因为 Mockito 会先去真正执行一次里面的逻辑,然后再拦截。如果真实逻辑里包含未初始化的依赖,会直接抛出空指针异常。

3.2 Spy 专属写法:doReturn(…).when(…)

这种写法叫语序倒装,也是最安全、防漏的写法。

// 语义:强行返回 mockTenants,当调用 authManager.getTenants() 时(根本不跑真代码)
doReturn(mockTenants).when(authManager).getTenants(any());

黄金法则:对于 @Mock 对象,怎么写都行;对于 @Spy 对象,必须用 doReturn()

🚀 4. 进阶战术:Mock 静态方法

很多老项目里充斥着大量的静态工具类(比如 VerifyCodeUtils.verify(), WebContext.get())。在 Mockito 3.4 之后,我们终于可以优雅地 Mock 它们了。

关键语法:Mockito.mockStatic() 配合 try-with-resources

@Test
void testLogin() {
    // 使用 try-with-resources,确保代码块结束后静态 Mock 自动释放,不污染其他测试!
    try (MockedStatic<EncMD5Utils> encUtils = mockStatic(EncMD5Utils.class)) {
        
        // 注意打桩语法变成了:mock对象.when(() -> 静态方法调用).thenReturn(...)
        encUtils.when(() -> EncMD5Utils.getLoginToken(anyString()))
                .thenReturn("fake_token");

        // 执行业务逻辑
        String token = authService.login(request);
        assertEquals("fake_token", token);
    } 
}

注意事项:静态 Mock 如果不关闭(Close),会导致后续其他测试用例引发各种诡异的报错。如果你觉得写 try 块太麻烦,可以将它提升为类成员变量,并在 @AfterEach 中统一关闭。

💣 5. 常见血泪坑与破局之道

坑位 1:试图 Mock 一个静态常量 (例如 MapStruct 的 INSTANCE)

  • 症状:代码 when(AuthStruct.INSTANCE.toVO(...)) 直接报语法错误或不生效。
  • 原因INSTANCE 是一个真实的单例变量,Mockito 只能拦截方法,不能拦截变量。
  • 破局
    • 首选:直接删掉这行 Mock!让测试跑真实的 MapStruct 转换逻辑,既省事又能顺便测转换逻辑有没有配错。
    • 备选:如果你非要替换,只能用反射 ReflectionTestUtils.setField(AuthStruct.class, "INSTANCE", mockObject)

坑位 2:Debug 代码光标直接飞走(未按预期往下走)

  • 症状:F8 往下走着走着,突然跳到了方法最后一行,或者直接结束了,没报错但是就是“失踪”了。
  • 原因:代码其实在某一行抛出了异常(NPE 或者 BizException),只是你没抓住它。或者是因为代码修改后没重新编译,导致 IDE 的行号和字节码对不上。
  • 破局
    1. 在 IDEA 中设置 Java Exception Breakpoints(异常断点),拦截 BizExceptionNullPointerException,看看死在哪里。
    2. 如果断点乱跳,先 mvn clean 然后再 mvn compile,消除幽灵字节码。
    3. 检查你的 anyString() 是否遇到了 null 入参,如果有,请改成 any()

坑位 3:使用了 @Spy,但内部抛出 NullPointerException

  • 症状:测试 authService,把 authManager 设置为 @Spy。但代码走到 authManager 内部时,里面的 xxxMapper 报空指针。
  • 原因@Spy 是半个真实对象,如果你不手动把假 Mapper 塞进去,它的依赖就是 null
  • 破局:在 @BeforeEachsetUp 方法中手动注入。
@BeforeEach
void setUp() {
    // 将假对象 userRoleMapper 强行塞进真实躯干 authManager 中
    ReflectionTestUtils.setField(authManager, "userRoleMapper", userRoleMapper);
}

坑位 4:UnnecessaryStubbingException (不必要的打桩)

  • 症状:测试全跑通了,但 Mockito 故意给你标红报错,提示检测到无效代码。
  • 原因:Mockito 严格模式发现,你写了一句 when(xxx) 的打桩代码,但整个测试跑完,你的业务代码根本没有调用过这个方法(通常是因为前置校验没通过,代码提前抛异常退出了)。
  • 破局
    • 上策:删掉那行没用的打桩代码,保持测试干净。
    • 下策:如果是公共 setUp 里的代码,在前面加上 lenient().(宽容模式),例如 lenient().when(...)

🎯 6. 总结

写好单元测试不是为了应付差事,而是为了重构时的底气。记住以下核心心法:

  1. 优先不查数据库:单测的核心是 Mockito,绝不滥用 @SpringBootTest
  2. 纯逻辑不 Mock:对象转换(MapStruct)、纯计算逻辑,直接跑真实的。
  3. 敬畏异常:遇到 Debug 乱跳,先查异常;遇到 Strictness 报错,先查自己是不是 Mock 越界了。

希望这篇文章能帮你彻底驯服 Mockito!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐