☰
Spring Boot Controller测试提速:@WebMvcTest测试切片实战指南
2026/10/1 12:21:36 网站建设 项目流程

经常有这种场景: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相关问题,这里整理一张高频问题速查表,建议收藏:

症状可能原因排查方向
测试启动报UnsatisfiedDependencyExceptionController依赖的Service等Bean未加载补@MockitoBean并声明好类型
请求路径对但返回404Controller不在扫描包路径下,或@RequestMapping前缀不一致确认@WebMvcTest指定的类存在,检查URL前缀
返回403/401/302Spring 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存在时返回用户信息")。这样跑完测试生成的报告,读起来像一份可执行的需求文档。时间久了,测试报告比注释更真实,比文档更能反映系统当前的行为。

代码不停在改,测试就是你给系统拍下的快照。拍得清楚一点,后面的人翻起来也省力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询