【Mockito 单元测试进阶指南】从基础到彻底避坑 (含实战案例)
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。 - 场景:外部依赖,比如
UserMapper、RedisTemplate。
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 的行号和字节码对不上。
- 破局:
- 在 IDEA 中设置 Java Exception Breakpoints(异常断点),拦截
BizException或NullPointerException,看看死在哪里。 - 如果断点乱跳,先
mvn clean然后再mvn compile,消除幽灵字节码。 - 检查你的
anyString()是否遇到了null入参,如果有,请改成any()。
- 在 IDEA 中设置 Java Exception Breakpoints(异常断点),拦截
坑位 3:使用了 @Spy,但内部抛出 NullPointerException
- 症状:测试
authService,把authManager设置为@Spy。但代码走到authManager内部时,里面的xxxMapper报空指针。 - 原因:
@Spy是半个真实对象,如果你不手动把假 Mapper 塞进去,它的依赖就是null。 - 破局:在
@BeforeEach的setUp方法中手动注入。
@BeforeEach
void setUp() {
// 将假对象 userRoleMapper 强行塞进真实躯干 authManager 中
ReflectionTestUtils.setField(authManager, "userRoleMapper", userRoleMapper);
}
坑位 4:UnnecessaryStubbingException (不必要的打桩)
- 症状:测试全跑通了,但 Mockito 故意给你标红报错,提示检测到无效代码。
- 原因:Mockito 严格模式发现,你写了一句
when(xxx)的打桩代码,但整个测试跑完,你的业务代码根本没有调用过这个方法(通常是因为前置校验没通过,代码提前抛异常退出了)。 - 破局:
- 上策:删掉那行没用的打桩代码,保持测试干净。
- 下策:如果是公共
setUp里的代码,在前面加上lenient().(宽容模式),例如lenient().when(...)。
🎯 6. 总结
写好单元测试不是为了应付差事,而是为了重构时的底气。记住以下核心心法:
- 优先不查数据库:单测的核心是 Mockito,绝不滥用
@SpringBootTest。 - 纯逻辑不 Mock:对象转换(MapStruct)、纯计算逻辑,直接跑真实的。
- 敬畏异常:遇到 Debug 乱跳,先查异常;遇到 Strictness 报错,先查自己是不是 Mock 越界了。
希望这篇文章能帮你彻底驯服 Mockito!
更多推荐




所有评论(0)