经常有这种场景:Controller里就加了一个分页参数,本地跑一次测试,Spring上下文要拉几十秒,去倒了杯水回来还没启动完。翻日志一看,测试类用着@SpringBootTest,把数据库连接、消息队列、Redis全给拉起来了,只为了验证一个入参解析。这就是典型的“大炮打蚊子”。
Spring Boot官方早就考虑到了这个问题,给出的方案就是测试切片(Test Slicing),而@WebMvcTest就是专门盯MVC层的那把手术刀。它不会去加载Service、Repository,更不会初始化数据源,只把Spring MVC处理一次HTTP请求所需的最小装配拉起来,再用MockMvc把请求打到Controller上做断言。这套玩法适合每一个写Controller接口的人,不管你是刚接触Spring Boot的新手,还是维护过好几个老项目的资深开发,只要你想让接口测试跑得又快又稳,@WebMvcTest都值得你花半小时掌握。
下面我从原理、手写示例、踩坑记录、进阶用法和排查清单五个部分,把@WebMvcTest聊透。
1. 为什么Controller测试一定要用测试切片
1.1 全量启动的笨重体验
很多人写接口测试,第一反应就是@SpringBootTest一把梭。这个注解本身没问题,它做的是“真实环境”测试——启动完整的ApplicationContext,读取所有配置,加载所有自动配置,构建整个Bean容器。听起来很靠谱,但代价相当大。
一个中等规模的业务系统,Bean数量轻松上百,启动时要经历配置绑定、Bean实例化、依赖注入、初始化方法回调,再加上MyBatis或JPA等持久层框架的初始化,整个过程几秒到几十秒都很正常。更难受的是,这种测试强依赖外部基础设施:数据库连不上直接报错,Redis没起就启动失败,消息队列没配好同样崩。你只是改了个接口的返回格式,跑个测试却要“环境就绪”作为前提,这在本地开发时相当折磨人。
我见过最夸张的情况,是某个老项目的一个Controller测试,启动上下文要一分多钟。之后大家养成了一种坏习惯:写完代码不跑测试,攒到提交前一起跑一次,失败了再慢慢查。测试反馈链路拉得越长,调试成本就越高。
1.2 测试切片的本质
切片这个概念听着玄乎,其实核心就一句话:把一个大型应用拆成多个可以独立启动的迷你应用。
Spring Boot内置了多种测试切片注解,各有分工:
- @WebMvcTest:只拉起Web MVC相关的那一套东西
- @DataJpaTest:只拉起JPA、数据库相关组件
- @JsonTest:只拉起JSON序列化相关配置
- @RestClientTest:只拉起RestTemplate或WebClient相关的东西
它们背后的原理,是Spring Boot Test框架提供了一套条件过滤装配机制。@WebMvcTest会通过ComponentScan.Filter把不在MVC职责范围内的Bean排除掉,同时基于专用的AutoConfiguration子集来构建一个“只有Web层”的上下文。被排除在外的Service、Repository等缺失依赖,则通过Mockito mock对象来补齐。
平时我们总说“模块化”“关注点分离”,测试切片实际上就是这个思想在测试领域的落地:测Controller就只关心HTTP层,别让数据库、缓存那些变量干扰判断。
我用一个表格把两种方案放在一起对比,你感受会更直接:
| 对比维度 | @SpringBootTest | @WebMvcTest |
|---|---|---|
| 上下文范围 | 全量加载所有Bean | 只加载MVC最小集 |
| 启动速度 | 慢,按秒起算 | 快,秒级甚至更短 |
| 外部依赖 | 数据库、MQ、Redis等全要 | 全部mock掉 |
| 测试视角 | 集成链路、端到端 | Controller逻辑、参数校验 |
| 稳定程度 | 受环境影响较大 | 只依赖测试代码本身 |
| 典型场景 | 多模块联调、数据库事务测试 | 接口入参、响应结构、状态码 |
1.3 快和稳其实是一回事
可能有人觉得:“测试慢点无所谓,反正一次启动后面都会复用上下文。”这话有一定道理,JVM里同一个测试类的ApplicationContext确实会缓存复用,但问题是同一个测试上下文只要被任何一个用例“弄脏”,或者配置不匹配,就会重新构建。更关键的是,全量上下文里任何一个Bean初始化失败,整个测试套件跟着挂掉,排查起来极其痛苦。
切片测试的快,不只是“启动快”,更是“失败快”。你只测Web层,Service逻辑就是mock出来的,Service内部出了bug,Controller测试不会受影响;反过来,Controller测试失败,也基本可以圈定问题在Web层。这种隔离能力,比单纯的性能优势更值钱。
2. 手把手:搭一个能跑的@WebMvcTest
2.1 引入测试依赖
要写切片测试,先确保pom里有spring-boot-starter-test,它会帮你把JUnit 5、Mockito、AssertJ、Hamcrest、JSONAssert这些常用测试库都带齐:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>如果你是刚开始建Spring Boot工程的,顺手把web依赖也加上,Controller测试的前提是先有一个Controller可测。
2.2 写一个目标Controller
网上能找到各种“第一个Spring Boot程序”的教程,这里我就不再重复建项目的步骤了。直接进入有业务感的例子。假设我们有一个用户查询接口:
@RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{id}") public Result<UserVO> getUser(@PathVariable Long id) { UserVO userVO = userService.getUserById(id); return Result.success(userVO); } }UserService实现类里大概率要去查数据库,这在@WebMvcTest环境下根本就不会加载。不过没关系,这正是mock要解决的问题。
2.3 测试类的完整骨架
下面是一个标准的@WebMvcTest测试类,我先把完整代码贴出来,后面再逐行说明:
import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean private UserService userService; @Test void whenUserIdExists_thenReturnUserInfo() throws Exception { UserVO mockUser = new UserVO(); mockUser.setId(1L); mockUser.setName("张三"); when(userService.getUserById(anyLong())).thenReturn(mockUser); mockMvc.perform(get("/api/users/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.name").value("张三")); } @Test void whenUserIdNotExists_thenReturnDefaultResult() throws Exception { when(userService.getUserById(anyLong())).thenReturn(null); mockMvc.perform(get("/api/users/999")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").doesNotExist()); } }@WebMvcTest(UserController.class)指定了目标Controller,这是推荐做法。如果只写@WebMvcTest不指定类,Spring会去扫描所有Controller,启动范围变大,还会因为带了不相关的Controller而增加不必要的依赖。一个切片测试类只关注一个Controller,职责最清晰。
2.4 核心断言三板斧
MockMvc的perform和andExpect链式调用是这套测试的核心写法,日常使用中最常用的断言基本可以归为三类。
第一,状态码断言。接口返回200、400、404、500,直接对应status().isOk()、isBadRequest()、isNotFound()、isInternalServerError()。状态码是Web层最基础的信息,优先断言它。
第二,响应体JSON断言。Spring Boot默认集成了JsonPath,你可以像用XPath查XML一样查JSON结构。比如jsonPath("$.code").value(200),$.data.name拿嵌套字段。它支持几种方法:value()比较精确值,exists()检查字段是否存在,doesNotExist()检查字段是否不存在。
第三,请求和Mock之间的交互断言。这类要配合Mockito的verify方法,比如verify(userService, times(1)).getUserById(1L),确认接口真的调用了依赖。这个对排查“接口返回了,但业务逻辑没走”的情况特别有用。
3. 那些年我踩过的坑:@WebMvcTest使用细节
3.1 没被加载的Bean:从NPE说起
新手第一次用@WebMvcTest,最常见的就是一启动直接报错,或者一调用就空指针。原因很简单:@WebMvcTest不加载Service、Repository、RedisTemplate这些非Web层组件,而你的Controller构造器里注入了UserService,Spring容器里根本没有这个Bean,启动时自然报UnsatisfiedDependencyException。
如果你的Controller用了@Autowired字段注入,启动可能不报错,但运行时userService是null,一调用接口就NPE。这在排查时更迷惑人。
解决办法是给缺失的依赖创建mock对象,也就是上面代码中的@MockitoBean。它会让Spring在测试上下文里注册一个Mockito mock替身,替代原来的UserService。配合when(...).thenReturn(...)指定行为,你的Controller测试就能完全脱离真实业务逻辑独立运转。
3.2 从@MockBean到@MockitoBean
如果你在网上搜教程,看到很多文章还在用@MockBean。这个注解在Spring Boot 3.4之前确实是主流,但从Spring Framework 6.2开始,官方推荐用@MockitoBean。区别在哪?
@MockBean是Spring Boot引入的,它通过修改BeanDefinition来替换容器中的Bean,所有用到这个类型的注入点都会被替换成mock。但它在处理某些复杂场景时,比如同一个类型有多个Bean实例,或者和@Primary共用时,会有预期之外的替换行为。
@MockitoBean是Spring Framework 6.2新引入的,语义上更严谨,它是基于spring-test的override机制来做替换,生命周期管理更清晰。所以如果你用的是Spring Boot 3.4及以上版本,建议直接用@MockitoBean;如果是老项目还在3.2或3.3,用@MockBean也没问题,不用急着升。
3.3 安全配置处理:403、401、重定向的迷茫
如果你pom里引入了spring-boot-starter-security,测试时很快会遇到一个“诡异”的现象:接口明明写得没问题,但MockMvc请求返回的不是401就是302,要么就是403。这不是你的代码错了,是Spring Security的默认安全过滤链在起作用。
处理方案有三种,按场景选:
- 测试纯粹的接口功能,不关心权限:在测试类上加@AutoConfigureMockMvc(addFilters = false),彻底关掉过滤器,让请求直达Controller。
- 测试需要登录用户访问的接口:用@WithMockUser注解,比如@WithMockUser(username = "admin", roles = {"ADMIN"}),模拟一个已认证用户去请求。
- 测试自定义的安全规则:把项目里的SecurityConfig用@Import加载,并在测试里模拟不同角色的用户,验证权限拦截逻辑。
我个人的习惯是:大多数接口测试禁掉过滤器,专注业务;单独为权限设计几个用例,用@WithMockUser模拟角色,验证Security配置是否生效。这样做,职责分得清楚,也不会出现“测了半天发现是权限拦截”浪费时间的情况。
3.4 全局异常处理器和过滤器怎么进测试
项目里通常会有@RestControllerAdvice定义全局异常处理,比如业务异常返回特定错误码。在@WebMvcTest环境下,如果这个类在主应用扫描的包下,一般会被自动加载。但实际项目里,全局异常处理类经常写在独立的common模块,包路径和主启动类不一致,那就扫描不到了。
这种情况下,接口抛出异常后测试拿到的响应体,可能就不是你预期的格式。解决方式是在测试类上显式import进来:
@WebMvcTest(UserController.class) @Import(GlobalExceptionHandler.class) class UserControllerTest { }同样的情况也适用于自定义Filter和拦截器。通过@Import可以补充加载,但要注意,如果这些Filter依赖了数据库或Redis等资源,你又回到了“外部依赖”的泥潭,这时候反而要想办法mock或者剥离。
4. 进阶操作:把真实的接口测试写好看
4.1 文件上传接口怎么测
文件上传是Web项目的高频场景。MockMvc里对文件上传的支持很成熟,用MockMultipartFile模拟一个文件对象,再用multipart请求方式发起调用即可:
@Test void whenUploadFile_thenReturnSuccess() throws Exception { MockMultipartFile file = new MockMultipartFile( "file", "test.txt", "text/plain", "hello spring mvc".getBytes(StandardCharsets.UTF_8) ); mockMvc.perform(multipart("/api/files/upload") .file(file) .param("description", "测试文件")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); }这里有个细节,MockMultipartFile构造器的第一个参数是表单字段名,必须和Controller里@RequestParam("file")或@RequestPart("file")指定的名字一致,否则会报MissingServletRequestPartException。
4.2 复杂JSON结构的分层断言
接口返回的往往是嵌套结构,比如分页查询:
{ "code": 0, "message": "success", "data": { "total": 1, "list": [ { "id": 1, "name": "张三" } ] } }这种结构用JsonPath一层层断言写起来很清晰:
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.total").value(1)) .andExpect(jsonPath("$.data.list[0].id").value(1)) .andExpect(jsonPath("$.data.list[0].name").value("张三")) .andExpect(jsonPath("$.data.list[*].id").isArray());$开头的路径表示根节点,[*]表示数组通配,Integer类型用value(1)要当心,JSON解析后可能是Integer,也可能是Long或Double,如果类型不一致,用value(1)可能匹配不上。稳妥一点,可以用value(is(1)),或者直接用jsonPath("$.data.total").isNumber()。
4.3 参数校验联动测试
Controller里的参数校验一般通过@Valid注解配合DTO完成:
@PostMapping public Result<Void> createUser(@Valid @RequestBody UserCreateRequest request) { userService.createUser(request); return Result.success(); }UserCreateRequest里的字段加了@NotBlank、@NotNull、@Min等约束。测试时构造一个不合法的请求体,断言Spring MVC返回400:
@Test void whenParamInvalid_thenReturnBadRequest() throws Exception { String invalidJson = "{\"name\":\"\"}"; mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content(invalidJson)) .andExpect(status().isBadRequest()); }如果你的项目里还配了MethodArgumentNotValidException的全局异常处理器,能把校验错误信息包装成特定结构,那别忘了用@Import把异常处理器加进来,否则拿到的还是Spring默认的错误格式。
4.4 上下文复用和测试整洁度
测试类的ApplicationContext在同一个配置下会被缓存复用,这样多个测试方法跑起来不会重复启动。但也带来一个隐患:你在一个测试里给mock对象设置的行为,如果不清理,可能影响后面的测试。Mockito的mock对象默认是不保留跨测试状态的,但当你说“长跑项目里测试越写越乱”时,通常就是上下文缓存+不同测试类共享同一批mock导致的。
应对方式:
- 每个测试类只测一个Controller,mock的对象只和当前类相关。
- 如果确实需要修改上下文、主动销毁缓存,用@DirtiesContext标注。
- 不要在一个类里混用@WebMvcTest和@SpringBootTest,这会搞乱上下文配置,也会让启动时间失去意义。
5. 常见问题与排查速查表
我在几个项目里帮同事排查过@WebMvcTest相关问题,这里整理一张高频问题速查表,建议收藏:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 测试启动报UnsatisfiedDependencyException | Controller依赖的Service等Bean未加载 | 补@MockitoBean并声明好类型 |
| 请求路径对但返回404 | Controller不在扫描包路径下,或@RequestMapping前缀不一致 | 确认@WebMvcTest指定的类存在,检查URL前缀 |
| 返回403/401/302 | Spring Security过滤链拦截 | 加@AutoConfigureMockMvc(addFilters = false)或@WithMockUser |
| 响应体结构和预期不一致 | 全局异常处理器未加载 | 用@Import导入@RestControllerAdvice |
| 无法注入MockMvc | 测试类没加@WebMvcTest,或没有web场景依赖 | 确认注解和spring-boot-starter-web依赖 |
| 文件上传请求报Missing param | 表单字段名不匹配 | 核对MockMultipartFile第一个参数与@RequestParam名字 |
| JSON断言类型不匹配 | Integer/Long/Double类型解析差异 | 用isNumber()或is(1),避免Value类型硬碰 |
| 启动时间突然变长 | 某个测试类混入了@SpringBootTest | 检查测试类注释,单独拆分切片测试 |
5.1 排查实录:一个404的下午
有一个同事很困惑地来找我,说自己的接口用浏览器访问没问题,但测试里一直404。我看了他的测试代码,Controller路径是/api/users,测试请求也是/api/users,没毛病。再看@WebMvcTest,他写的是@WebMvcTest(UserController.class),但这个UserController在另一个模块的com.company.app.controller包里,而测试类在com.company.app.user包下。
Spring Boot的自动配置在扫描组件时,默认跟着主启动类的包路径走。当@WebMvcTest指定了具体的Controller时,它会尝试从主启动类的包路径去扫描这个类型,如果你的类型本身在扫描范围外,即使指定了类也加载不到。这种情况非常隐蔽,因为它不报错,只是默默地把测试跑成404。
解决办法也不复杂:让@WebMvcTest指定的Controller在主启动类能扫描到的包下,或者直接用@Import(UserController.class + 它依赖的异常处理器等)强制加载。
5.2 排查实录:上下文为什么又要重启
还有一次,我发现在同一个测试套件里,前面几个@WebMvcTest类跑完,到第三个类时居然又花了好几秒重新启动上下文。翻日志发现,第三个类里多了一个注解@AutoConfigureMockMvc(addFilters = false)。
前面两个类没这个注解,Spring会基于“默认WebMvc配置”构建上下文缓存;第三个类的配置不同,必须重建上下文。所以你的测试类之间,凡是涉及自动配置的差异,都可能导致上下文缓存失效。这不是bug,是特性,但了解这个机制后,写测试类时尽量保证同一批测试配置一致,能省下不少启动时间。
5.3 循环引用导致的序列化异常
当你的Controller直接返回实体对象,而实体对象之间存在双向关联时,比如User里有个List ,Role里又有个User,JSON序列化时会抛出JsonMappingException,提示Infinite recursion。这种问题在浏览器访问时反而可能正常,因为懒加载拿到的是代理对象,但在切片测试里mock出来的对象,序列化时就会无情暴露。
遇到这种问题,别在测试里花时间绕来绕去,去改代码结构:Controller返回VO或DTO,别直接暴露实体;或者在一方的实体字段上标@JsonIgnore。好的接口设计本来就是分层清晰的,测试把这个坑提前暴露出来反而是件好事。
我的日常使用体会
写了这么多年测试,我现在的习惯是:Controller层的新增测试一律用@WebMvcTest,只有涉及数据库事务回滚、多模块真实调用的场景,才退回去用@SpringBootTest。这样单测和集成测试的边界就划出来了,跑起来快,出问题也能快速定位。
最后再分享一个小细节:我习惯给每个测试方法加上@DisplayName,比如@DisplayName("用户ID存在时返回用户信息")。这样跑完测试生成的报告,读起来像一份可执行的需求文档。时间久了,测试报告比注释更真实,比文档更能反映系统当前的行为。
代码不停在改,测试就是你给系统拍下的快照。拍得清楚一点,后面的人翻起来也省力。