大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
Mockito 与 Mock
这一课你将能做到
- 用
@ExtendWith(MockitoExtension.class)+@Mock/@InjectMocks给 CodeForge 的SubmissionService、AuthService写出毫秒级单元测试 - 掌握 stub(
when/thenReturn/thenThrow)、verify行为断言与ArgumentCaptor参数捕获三件套 - 用
mockStatic隔离 sa-token 的StpUtil静态调用,测通注册成功全链路
为什么要学
单元测试要隔离被测类:数据库、Redis、HTTP、时钟都不该拖慢或污染测试。人话:Mock 是「听话的替身演员」——你规定它怎么答,再看被测 Service 有没有按剧本调度。
CodeForge 的 Service 层依赖 MyBatis-Flex 的 Mapper(SubmissionMapper、UserMapper)。用 Mockito 测「语言白名单、代码长度校验、用户名重复拒绝」这类编排逻辑,完全不必起 MySQL。真实的 SubmissionServiceTest 就是这么写的——本课带你从读懂到写出同款,并补上它还没覆盖的进阶手法(参数捕获、静态 mock)。
本课练的是不启 Spring 的纯单元写法;容器里的替身(@MockitoBean)是下一课的事。这个分界线本身就是面试考点。
先建立图像
真实运行: Controller → Service → Mapper → MySQL
单元测试: 测试代码 → Service → @Mock Mapper(假的)
↑
when(...).thenReturn(...) 规定台词
verify(...).方法(...) 查出勤
| 术语 | 人话 | 常见 API |
|---|---|---|
| Mock | 假对象 | @Mock / mock(X.class) |
| Stub | 规定「问什么答什么」 | when(...).thenReturn(...) |
| Verify | 检查「有没有按预期调用」 | verify(mock).方法(...) |
| InjectMocks | 把 mock 注入被测类 | @InjectMocks Service |
| MockedStatic | 给静态方法换替身 | mockStatic(StpUtil.class) |
不要过度 mock:纯函数不用 mock;测 Service 才 mock Mapper / 外部 Client;测 SQL 正确性 Mock 救不了,那是集成测试的事(第 6 课)。
跟着做
步骤 1:搭最小骨架,测判题提交的 happy path
import com.codeforge.entity.Submission;
import com.codeforge.mapper.SubmissionMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SubmissionMockitoTest {
@Mock
SubmissionMapper submissionMapper;
@InjectMocks
SubmissionService service;
@Test
void submit_构造记录并置pending() {
when(submissionMapper.insert(any(Submission.class))).thenReturn(1);
Submission result = service.submit(3L, 5L, "java", "class A {}");
assertThat(result.getStatus()).isEqualTo("pending");
assertThat(result.getUserId()).isEqualTo(3L);
assertThat(result.getLanguage()).isEqualTo("java");
verify(submissionMapper).insert(any(Submission.class));
}
}
应看到:测试毫秒级通过,全程无数据库。若 @InjectMocks 注入失败,检查 SubmissionService 的构造器参数(Lombok @RequiredArgsConstructor 生成的那个)类型是否与 @Mock 字段一一对应。
步骤 2:补校验失败分支(不需要 stub 的用例)
submit 的校验发生在 Mapper 调用之前,所以这类用例不用写任何 when:
@Test
void submit_非法语言直接拒绝() {
assertThatThrownBy(() -> service.submit(3L, 5L, "brainfuck", "code"))
.isInstanceOf(BusinessException.class)
.hasMessage("不支持的语言类型");
}
@Test
void submit_空代码拒绝() {
assertThatThrownBy(() -> service.submit(3L, 5L, "java", " "))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("代码长度");
}
@Test
void submit_非法课时拒绝且不落库() {
assertThatThrownBy(() -> service.submit(3L, 0L, "java", "code"))
.isInstanceOf(BusinessException.class);
verify(submissionMapper, never()).insert(any());
}
第三条把 never() 与异常断言组合——「拒绝的同时没写库」才是完整规格。
步骤 3:ArgumentCaptor——比 verify(any()) 更强的证据
verify(insert(any())) 只证明「调了」,ArgumentCaptor 能证明「调的时候塞进去的是什么」:
import org.mockito.ArgumentCaptor;
@Test
void submit_落库的是待判题记录() {
when(submissionMapper.insert(any(Submission.class))).thenReturn(1);
service.submit(3L, 5L, "java", "class A {}");
ArgumentCaptor<Submission> captor = ArgumentCaptor.forClass(Submission.class);
verify(submissionMapper).insert(captor.capture());
Submission saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo("pending");
assertThat(saved.getCreatedAt()).isNotNull();
assertThat(saved.getCode()).isEqualTo("class A {}");
}
人话:stub 决定 Service 拿到什么,captor 决定你能检查它还回去什么。两者配合才闭环。
步骤 4:测注册逻辑的「用户名已存在」分支
@ExtendWith(MockitoExtension.class)
class AuthServiceDuplicateTest {
@Mock UserMapper userMapper;
@InjectMocks AuthService authService;
@Test
void register_用户名已存在时拒绝且不插入() {
when(userMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
assertThatThrownBy(() ->
authService.register("existing", "a@example.com", "123456"))
.isInstanceOf(BusinessException.class)
.hasMessage("用户名已存在");
verify(userMapper, never()).insert(any(User.class));
}
}
注意真实 AuthService 的查重靠 selectCountByQuery 返回值大于 0 判断——stub 时返回 1L 就能让分支亮起来。读被测实现再写 stub,是单测的日常。
步骤 5:mockStatic 隔离静态调用,测注册成功全链路
注册成功路径会调 StpUtil.login(...) 和 StpUtil.getTokenValue()——没有 Web 上下文会炸。Mockito 5 默认内置 inline mockmaker,静态方法可以直接换替身:
import cn.dev33.satoken.stp.StpUtil;
import org.mockito.MockedStatic;
@Test
void register_成功时加密入库并返回token() {
when(userMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
when(userMapper.insert(any(User.class))).thenReturn(1);
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
stp.when(StpUtil::getTokenValue).thenReturn("token-abc");
String token = authService.register("newbie", "n@example.com", "123456");
assertThat(token).isEqualTo("token-abc");
stp.verify(() -> StpUtil.login(anyLong()));
}
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userMapper).insert(captor.capture());
// 密码必须是哈希,不能是明文
assertThat(captor.getValue().getPasswordHash())
.isNotEqualTo("123456")
.startsWith("$2");
}
try-with-resources 保证静态替身用完即释放,不泄漏到其他用例——这一行写漏会引发整套件连环红。
步骤 6:匹配器规则与定向运行
// 一旦某个参数用了匹配器,同一调用的其它参数也要用匹配器
when(userMapper.selectOneByQuery(any())).thenReturn(user); // 全匹配器
when(mapper.count(eq("java"))).thenReturn(1L); // eq 包裸值
混用裸值与匹配器能编译、运行时才报错,是初学者最常见的 Mockito 崩溃点。
mvn test -Dtest=SubmissionMockitoTest,AuthServiceDuplicateTest
讲清楚要点
- 单测要快且稳,所以用 mock 挡掉一切 I/O:真库留给集成测试,毫秒级红绿才养得起「改码即跑」的习惯。
- stub 只规定返回值,行为还要靠 assert / verify 证明:「调用了 mock」≠「业务对」,两者缺一就是假绿。
- 只 mock 直接依赖:mock 隔壁的邻居的邻居,测出来的是「和自己对话」,没有回归价值。
- 关键入参优先
eq精确值:any()到处飞会掩盖「传错参数」类 bug,宽松匹配按需开放。 - 构造器注入让
@InjectMocks更可靠:CodeForge 的 Service 用 Lombok@RequiredArgsConstructor,字段一目了然;字段注入 + 大量 setter 会让替身装配变得玄学。 - 静态调用用
mockStatic且必须 try-with-resources:Mockito 5 起开箱即用,但替身作用域要手动圈死。
你可能会懵的地方
-
@Mock和@MockitoBean是不是一回事? 概念像,作用域不同。@Mock只活在测试类里,配@InjectMocks手工装配;@MockitoBean(Boot 3.4+,旧版叫@MockBean)替换的是 Spring 容器里的 Bean。下一课展开。 -
为什么 verify 失败提示「Wanted but not invoked」? 被测代码没走到那条分支:可能是 stub 条件没对上(参数不匹配导致返回默认值),也可能校验提前抛了异常。先用 IDE 断点确认流程走向,再改 stub。
-
UnnecessaryStubbingException是错误吗? 是严格模式的保护:你 stub 了没被用到的方法,多半意味着「用例走不到你以为的分支」。删掉多余 stub,或修正测试意图,不要急着换 lenient 模式。 -
要不要
verifyNoMoreInteractions? 它能抓多余调用,但对重构极其敏感。入门只对关键副作用(发信、扣款、删数据、写库)做verify/never,全量「查户口」留给真正需要零信任的模块。 -
什么时候不该用 Mockito? 纯函数(直接 new + 断言)、SQL 正确性(mock 答的是你编的,只有真库能证明)、以及被测类本身就是薄代理时——单测只会复读实现。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 单测打真 MySQL/Redis | 慢、脆、难并行 | mock Mapper 与 RedisTemplate |
只 when 无 assert | 假绿 | 断言返回值 + 关键 verify |
| mock 一切连被测类自己 | 测试无意义 | 只 mock 直接依赖 |
any() 到处飞 | 掩盖错误入参 | 关键参数 eq,必要时 captor |
| 用 mock 验证 SQL 写对没有 | 假象 | Testcontainers 集成测试 |
mockStatic 不关流 | 泄漏污染其他用例 | try-with-resources 包住 |
卡点急救
when(...)要求参数必须全是匹配器或全是裸值:报InvalidUseOfMatchersException时,把裸值换成eq(x),一个不留。@InjectMocks字段为 null:确认被测类依赖走构造器(Lombok@RequiredArgsConstructor),且@Mock类型与构造器参数类型完全一致(泛型也算)。- 静态调用测试抛 sa-token 上下文异常:说明
StpUtil没被 mockStatic 拦到——检查 try 块是否覆盖了整个调用段、import 的StpUtil是否与生产代码同包同名。
面试常问
-
什么时候该 mock,什么时候不该? 该 mock:跨进程 I/O(数据库、Redis、HTTP、消息)、不稳定因素(时钟、随机)。不该 mock:被测类内的纯逻辑、值对象、以及你要验证的 SQL 本身。判断标准——mock 之后结论还可信吗?可信就 mock,不可信就上集成测试。
-
stub 和 verify 的区别?只做一个会漏什么? stub 是「输入侧」:规定依赖怎么响应;verify 是「输出侧」:检查被测类对依赖的调用。只 stub 不 verify,会漏「多调/漏调/调错依赖」类 bug(如误发邮件);只 verify 不 assert,会漏返回值加工逻辑的错误。
-
静态方法怎么测? 首选重构:把静态调用包成可注入的组件(如
Clock、限流器接口)。退而求其次用 Mockito 5 的mockStatic,配 try-with-resources 圈定作用域。面试时先说重构优先,再给 mockStatic 兜底,是加分答法。
自测三问
- stub 与 verify 各解决什么问题?只做其中一个会漏什么?
- 什么情况下不该使用 Mockito?
ArgumentCaptor比verify(insert(any()))强在哪?
小结一句话
Mock 替身、Stub 返回值、Verify 行为、Captor 抓参数、mockStatic 圈静态——Service 层单元测试的五件套。
延伸
- Mockito 官方文档
- JUnit 5 · 扩展模型
- 下一课:test-spring 进入 Spring 容器,用
@WebMvcTest切片与@MockitoBean测 Bean 装配与 Web 层。