Mockito 与 Mock
mock、when/then、verify。
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 层单元测试的五件套。
延伸