Spring Boot 测试
这一课你将能做到
- 区分纯 Mockito 单测、
@WebMvcTest 切片、@SpringBootTest 全上下文三档成本,按问题选档位
- 用
@MockitoBean 在容器内替换依赖,把 Controller 切片测跑起来
- 说清 Spring 上下文缓存为什么让测试「越跑越快」,以及什么会破坏缓存
为什么要学
JUnit + Mockito 再快,也测不到「Bean 有没有装配对、全局异常处理器有没有把 BusinessException 翻成 400、Jackson 序列化出来的 JSON 字段名对不对」。这些恰恰是 CodeForge 这类项目最容易回归的地方:GlobalExceptionHandler 改一行,全站错误响应都可能变形。
Spring 测试模块在 JUnit 之上加载应用上下文:全量或切片。人话:这是给 Spring 装配与 Web 层做的体检——比纯单测贵,比真浏览器便宜得多。
版本注意:Spring Boot 3.4 起旧的 @MockBean 已标记废弃,现行写法是 @MockitoBean(来自 org.springframework.test.context.bean.override.mockito)。网上旧教程写 @MockBean 的,知道是同一意图即可,新代码统一用 @MockitoBean。
先建立图像
纯单元: JUnit → new Service(@Mock Mapper) 无 Spring,毫秒级
切片: @WebMvcTest → 只装配 MVC + Jackson 等 启一点 Spring,亚秒级
全上下文: @SpringBootTest → 几乎整站 Bean 最接近生产,秒级起
@Mock 只在测试类里存在的假对象(配 @InjectMocks)
@MockitoBean 放进 Spring 容器,替换同类型 Bean
| 注解 | 加载范围 | 适合 | 不适合 |
|---|
@ExtendWith(MockitoExtension.class) | 无容器 | Service 业务分支 | 装配、序列化 |
@WebMvcTest(XxxController.class) | MVC + Jackson + 异常处理 | HTTP 映射、校验、JSON | Mapper/SQL |
@SpringBootTest(webEnvironment = NONE) | 完整上下文 | 多 Bean 协作、装配体检 | 日常改一行跑一次 |
@WebMvcTest + @AutoConfigureMockMvc | 全上下文 + MockMvc | 需要真实过滤器链时 | 追求速度 |
关键机制:上下文缓存。相同配置(同样注解、同样 profile、同样 @MockitoBean 集合)的测试类共享同一个 Spring 上下文,第二个类起跳几乎零成本。所以「同类测试集中配置」是性能习惯,不是洁癖。
跟着做
步骤 1:给 SubmissionController 写一个 Web 切片测
import cn.dev33.satoken.stp.StpUtil;
import com.codeforge.controller.SubmissionController;
import com.codeforge.entity.Submission;
import com.codeforge.service.SubmissionService;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.mockStatic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(SubmissionController.class)
class SubmissionControllerSliceTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
SubmissionService submissionService;
@Test
void 提交判题接口返回记录JSON() throws Exception {
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
stp.when(StpUtil::getLoginIdAsLong).thenReturn(3L);
Submission s = new Submission();
s.setId(10L);
s.setUserId(3L);
s.setStatus("pending");
when(submissionService.submit(3L, 5L, "java", "class A {}"))
.thenReturn(s);
mockMvc.perform(post("/submissions")
.contentType("application/json")
.content("""
{"lessonId":5,"language":"java","code":"class A {}"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(10))
.andExpect(jsonPath("$.status").value("pending"));
}
}
}
应看到:上下文启动远快于全站;MyBatis-Flex、Redis、DataSource 这些 Bean 根本没被加载。Controller 直接调用 StpUtil 静态方法,所以静态替身仍要 mock——切片解决的是「装配」,不是「一切」。
步骤 2:体验「切片缺 Bean」并用 @MockitoBean 逐个补
给 AuthController 写切片,你会立刻撞上缺 Bean:
@WebMvcTest(AuthController.class)
class AuthControllerSliceTest {
@Autowired MockMvc mockMvc;
@MockitoBean AuthService authService;
@MockitoBean CaptchaService captchaService;
@MockitoBean AuthCookieService authCookieService;
@MockitoBean MemberService memberService;
@Test
void 注册接口返回token() throws Exception {
when(captchaService.consume(any())).thenReturn(true);
when(authService.register("neo", "neo@example.com", "123456"))
.thenReturn("token-1");
when(authService.getCurrentUser()).thenReturn(new User());
mockMvc.perform(post("/auth/register")
.contentType("application/json")
.content("""
{"username":"neo","email":"neo@example.com",
"password":"123456","captchaToken":"ok"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("token-1"));
}
}
报 NoSuchBeanDefinitionException 不是故障,是特性:切片只装 Web 相关组件,Controller 依赖的每个 Service 都要显式给替身。这逼你想清楚「这个测试到底证明什么」。
步骤 3:对比全上下文,亲眼看缓存
@SpringBootTest
class AppContextSmokeTest {
@Autowired TrackService trackService; // 能注入 = 装配成功
@Test
void 整站上下文能启动() {
assertThat(trackService).isNotNull();
}
}
本地分别跑切片与全上下文,看日志里 Started CodeForgeApplication in X seconds。再把两个 @SpringBootTest 类连跑:第二个类明显快——那就是上下文缓存在生效。
步骤 4:理解 @MockitoBean 的容器语义
同一需求「Service 抛业务异常」的两种写法:
- 不测 HTTP:
@Mock UserMapper + @InjectMocks AuthService(上一课)
- 要测 HTTP 状态码与 JSON 错误体:
@MockitoBean AuthService + MockMvc(本课)
人话:@MockitoBean 是「跟容器说:这个类型改用我的假货」。漏写它,容器会注入真 Service → 真打库 → 测试变慢甚至起不来。
步骤 5:test profile 与外部依赖隔离
建 src/test/resources/application-test.yml(示意):
spring:
data:
redis:
repositories:
enabled: false
sa-token:
is-read-cookie: false
原则:切片测试不该连任何外部中间件;需要真 Redis/MySQL 的场景交给第 6 课的 Testcontainers,而不是让单测依赖本机环境。
讲清楚要点
- 全上下文贵,能切片就切片:先问「我要证明装配,还是证明 HTTP 契约」,答案决定注解。
- 切片缺 Bean 是特性不是 bug:用
@MockitoBean / @Import 补最小集合,而不是一缺就升全上下文。
@MockitoBean 影响容器,意图要单一:同一类型在同一上下文里只应有一个替换方案,否则配置打架、缓存失效。
- Security/过滤器会改状态码:401 先分清「没登录」还是「Controller 坏了」。本站 sa-token 的未登录异常由
GlobalExceptionHandler 翻成 401,切片里可用 mockStatic 抛对应异常来驱动这条路径。
- 上下文缓存是性能底牌:注解组合、profile、
@MockitoBean 集合、properties 任一不同都会生成新上下文。同类测试保持配置一致。
- Spring 测聚焦映射、校验、序列化、异常翻译:业务 if 分支已在 Mockito 层测过,别重复断言同一行代码。
你可能会懵的地方
-
为什么 @WebMvcTest 起不来,报缺 Bean?
切片只加载 Controller、Advice、Filter 一类 Web 组件;Controller 依赖的 Service 默认不存在,需要 @MockitoBean。这是设计,不是损坏。
-
@SpringBootTest 一定要起端口吗?
默认 MOCK 环境不监听真实端口,MockMvc 直接进 DispatcherServlet;要真 HTTP 客户端时才用 RANDOM_PORT + TestRestTemplate/WebTestClient。日常契约测 MockMvc 足够。
-
和上一课的 Mockito 测试重复吗?
不重复。上一课测「Service 算法对不对」,本课测「请求怎么进到 Service、响应怎么写出去」。两层都要,比例上单元多、Spring 测薄。
-
MyBatis-Flex 有没有像 @DataJpaTest 那样的切片?
JPA 有 @DataJpaTest;MyBatis-Flex 没有官方等价切片,数据层验证用 @SpringBootTest + Testcontainers 更直接(下一课)。
-
上下文缓存怎么「坏」的?
同一类型测试里混用不同注解组合、不同 application-test.yml 值、或 @MockitoBean 数量不一致,每个变体都起新上下文,套件时间成倍涨。排查方法:看日志出现了几次 Started CodeForgeApplication。
错法对照
| 错法 | 问题 | 正解 |
|---|
凡事 @SpringBootTest | 套件变慢没人愿跑 | 优先切片 / 纯 Mockito |
忘写 @MockitoBean 打到真依赖 | 慢或环境报错 | 显式替换每个外部依赖 |
| 401 就以为 Controller 坏了 | 误诊 | 先分清 sa-token 未登录与业务异常 |
| 每个类一套独特注解组合 | 缓存全失效 | 统一配置,集中同类测试 |
| 切片里连真 MySQL/Redis | 环境依赖、并行互踩 | 外部依赖交给 Testcontainers |
卡点急救
- 切片报
NoSuchBeanDefinitionException:逐个把 Controller 构造器依赖加 @MockitoBean;仍缺的自定义组件用 @Import(Xxx.class) 或 @TestConfiguration 补。
- 测试里 sa-token 报上下文不存在:Controller 调了
StpUtil 静态方法,用 mockStatic(StpUtil.class) 提供登录 ID 或让它抛 NotLoginException。
- 套件突然变慢:数日志里
Started CodeForgeApplication 出现次数;超过预期就找「新加的注解组合」,把它与同类测试对齐。
面试常问
-
@WebMvcTest 和 @SpringBootTest 怎么选?
看要证明什么:HTTP 契约(状态码、JSON、异常翻译)用切片,快且聚焦;多 Bean 装配、跨层协作、过滤器链用全上下文。成本从低到高,能低不高。
-
上下文缓存是什么?对测试套件意味着什么?
Spring Boot 按「配置指纹」(注解组合、profile、Bean 替换集合)缓存应用上下文,相同指纹的测试类复用同一份。意味着同类测试应保持配置一致——配置多样性直接等于启动次数乘上启动成本。
-
@MockitoBean 和 @Mock 的本质区别?
@Mock 是 Mockito 层的对象,不进容器,靠 @InjectMocks 手工装配;@MockitoBean 是 Spring 测试模块的 Bean 覆盖机制,替换容器内同类型 Bean,Boot 3.4 起取代废弃的 @MockBean。一个管「类的装配」,一个管「容器的装配」。
自测三问
- 什么情况下你会选
@WebMvcTest 而不是纯 Mockito?反过来呢?
@Mock 与 @MockitoBean 的关键差别是什么?
- 套件里
Started CodeForgeApplication 出现了 8 次,说明什么?
小结一句话
切片测一层、全测验装配;@MockitoBean 在容器里换假 Bean——按问题选成本,靠上下文缓存省时间。
延伸