☰
Spring Boot Web层测试实战:MockMvc与TestRestTemplate选型指南
2026/10/21 19:23:20 网站建设 项目流程

做了几年Spring Boot的后端开发,我越来越觉得Web层测试是很多人最不愿意碰、但又绕不开的一环。项目跑得好好的,接口一改就崩,测试用例要么写得太重跑一次喝杯咖啡,要么浮于表面只验证状态码是200。今天想借着SpringBootTest这个主题,把我用MockMvc和TestRestTemplate做Web层测试的完整经验梳理一遍,从工具选型、代码示例到踩过的坑,一次性讲透。

这篇文章适合谁?如果你是刚接触Spring Boot测试、想让Controller层测试既快又稳的开发者,或者已经在写测试但总感觉隔靴搔痒、不知道两种测试工具边界在哪的人,那这篇内容基本就是为你准备的。我会尽量用实际代码说话,把那些文档里不写的细节也一并补齐。

1. 先理清楚:MockMvc和TestRestTemplate到底在测什么

很多人一开始搞不清这两者的区别,以为都是发HTTP请求、断言响应,选一个就行。实际上它们解决的问题完全不同,背后是两套测试哲学:一个追求“快而准”的单元式验证,另一个追求“真而全”的端到端验证。

1.1 两种工具的底层运行方式

MockMvc并不是真的启动一个Tomcat服务器,也没有走真实的网络协议栈。它是在Spring MVC的测试框架里,把请求直接“塞”给DispatcherServlet,由它去匹配HandlerMapping、执行拦截器、调用Controller方法、完成参数绑定、再做消息转换器序列化,整套流程在同一个JVM内跑完。好处是速度极快、上下文加载范围可控,坏处是Servlet容器那一层(比如真正的Filter链、网络超时、连接池)并不会被覆盖到。

TestRestTemplate则完全相反。配合@SpringBootTest(webEnvironment = RANDOM_PORT)使用时,框架会真实地启动一个内嵌Web服务器,分配一个随机可用端口,然后TestRestTemplate作为一个HTTP客户端,向这个真实端口发起请求。请求会经过网络协议栈、Servlet容器、过滤器、拦截器、Controller,一直打到数据库或外部服务。这是个完整的集成链路,慢是慢一点,但测出来的结果更接近线上真实行为。

所以,这两者的关系不是“谁替代谁”,而是“分别在哪个抽象层次上给你兜底”。MockMvc适合在开发阶段快速迭代,TestRestTemplate适合在CI/CD里守门把关。

1.2 一张表看清两者的边界

对比项MockMvcTestRestTemplate
运行方式模拟请求,不启动真实服务器启动内嵌服务器,走真实HTTP
测试速度快,毫秒级慢,秒级起步
Spring上下文加载@WebMvcTest时只加载Web层@SpringBootTest加载全量上下文
覆盖范围Controller映射、参数绑定、校验、消息转换全链路,含容器、过滤器、网络
Service依赖要Mock掉可用真实Bean或Mock掉
适用阶段单元测试、切片测试集成测试、端到端冒烟
代码可读性perform().andExpect()链式断言传统REST客户端风格,getForEntity等

我见过不少团队把MockMvc用在集成测试里,结果漏掉了Filter和容器的真实行为,线上出了跨域问题才反应过来;也有人拿TestRestTemplate写单元测试,一个Controller的测试跑出几十秒,CI越来越慢。搞清楚边界,选型就成功了一半。

2. MockMvc实战:从切片到全量,把Controller层测透

MockMvc用得最多、也最推荐的是配合@WebMvcTest做切片测试。所谓切片,就是Spring Boot只会加载Web层相关的组件,比如@Controller、@ControllerAdvice、Filter、WebMvcConfigurer等,那些@Service、@Repository、@Component统统不会放进上下文。

2.1 @WebMvcTest切片测试的正确打开方式

先看一个典型的Controller测试长什么样:

@WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean private OrderService orderService; @Test void shouldReturnOrderDetailWhenOrderExists() throws Exception { OrderDetailVO vo = new OrderDetailVO(); vo.setOrderNo("ORD-20240601-001"); vo.setStatus("PAID"); Mockito.when(orderService.getDetail(1001L)) .thenReturn(Optional.of(vo)); mockMvc.perform(MockMvcRequestBuilders.get("/api/orders/{id}", 1001L) .header("Authorization", "Bearer mock-token")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.jsonPath("$.orderNo").value("ORD-20240601-001")) .andExpect(MockMvcResultMatchers.jsonPath("$.status").value("PAID")); } }

这里有几个容易出错的点要特别提醒。

第一,@WebMvcTest只扫描Web层,你的Service、Repository不在上下文里。如果你不写@MockitoBean,注入OrderService这一步就会直接报错,测试根本起不来。反过来,如果你的Controller构造方法里注入了三个依赖,那你就要挨个Mock掉,少一个都不行。这也是为什么很多老开发者宁可不用构造器注入也要让字段注入更“方便”测试,但我不建议这么做,测试写起来麻烦一点,总比生产代码被字段注入坑了强。

第二,JSONPath断言是切片测试的精髓。它可以深入响应体内部,精确验证某个字段的值、数组长度、嵌套对象结构,而不需要把整个JSON反序列化成对象再逐个比较。常用的语法包括:

.andExpect(jsonPath("$.items[*].sku").value(hasSize(2))) .andExpect(jsonPath("$.items[0].price").value(29.9)) .andExpect(jsonPath("$.items[1].name").exists()) .andExpect(jsonPath("$.page.totalElements").value(100))

jsonPath里的通配符、数组下标、嵌套表达式都是它的强项。刚开始可能觉得反序列化成对象更直观,但一旦响应结构复杂,或者你想验证一个字段在错误分支下“不存在”,jsonPath就比反序列化高效得多。

2.2 参数校验、异常处理、JSON序列化的几种典型断言

Web层最常见的职责之一是参数校验。Spring Boot里通常用@Validated + @Valid + 注解(如@NotNull、@Size)完成校验,校验不通过时Controller方法根本不会执行,而是抛出MethodArgumentNotValidException,由全局异常处理器兜底。切片测试时可以这样验证:

mockMvc.perform(MockMvcRequestBuilders.post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .content("{\"productId\": null, \"quantity\": -1}")) .andExpect(MockMvcResultMatchers.status().isBadRequest()) .andExpect(MockMvcResultMatchers.jsonPath("$.code").value(400)) .andExpect(MockMvcResultMatchers.jsonPath("$.message").value("参数校验失败")) .andExpect(MockMvcResultMatchers.jsonPath("$.fields[*].field").value(containsInAnyOrder("productId", "quantity")));

这种写法直接把“参数校验规则是否正确”作为测试对象,一旦有人把@NotNull改成@NotBlank,或者调整了错误码,测试就会立刻报红,比人工看代码可靠得多。

序列化方面的坑也很常见。如果有字段被@JsonIgnore注解标记,你希望它不出现在响应里;有字段用@JsonFormat格式化日期,你希望它输出成“yyyy-MM-dd HH:mm:ss”。这些都可以用jsonPath断言来锁死格式:

.andExpect(jsonPath("$.createdAt").value("2024-06-01 12:30:00")) .andExpect(jsonPath("$.secretField").doesNotExist())

注意,如果全局Jackson配置了时间格式,比如spring.jackson.date-format指定了全局默认,那么在MockMvc测试里拿到的序列化结果会和实际运行环境一致,所以这种断言是有效的。

2.3 切片测试的陷阱:拦截器、过滤器、消息转换器要单独确认

很多团队在MockMvc切片测试里栽过同一个跟头:自定义拦截器不生效。

原因是@WebMvcTest会自动加载Spring Boot内置的Filter(如HiddenHttpMethodFilter、CharacterEncodingFilter),但不会加载你自己注册的WebMvcConfigurer。你在配置类里写的addInterceptors、addCorsMappings这类逻辑,需要手动声明或者额外被扫描进上下文才行。

我常用的做法是把自定义拦截器也做进切片测试:

@WebMvcTest(controllers = OrderController.class) @Import(MyWebMvcConfig.class) class OrderControllerTest { // ... }

或者更彻底一点,单独为拦截器写测试,用MockHttpServletRequestBuilder直接往MockMvcRequest里塞自定义Header,验证拦截器是否阻止了非法请求:

mockMvc.perform(MockMvcRequestBuilders.get("/api/orders") .header("X-Client-Version", "1.0")) .andExpect(MockMvcResultMatchers.status().isPreconditionRequired());

消息转换器也一样。如果你在项目里自定义了MappingJackson2HttpMessageConverter,比如添加了自定义ObjectMapper模块,切片测试时必须保证这个Converter也被构建进上下文,否则序列化结果和真实环境不一致,调试时会很迷惑。

2.4 Spring Boot版本差异:@MockBean已废,改用@MockitoBean

这里必须专门提一个版本坑。Spring Boot 3.4.0开始,@MockBean和@SpyBean被标记为废弃,官方推荐改用@MockitoBean和@MockitoSpyBean。这两个新注解来自Spring Framework 6.2,语义上基本兼容,但包路径变了,代码里如果还在用org.springframework.boot.test.mock.mockito.MockBean,升级的时候一定注意替换成org.springframework.mockito.beans.MockitoBean。

这个坑我踩过一次。当时项目从Spring Boot 3.3升到3.4,跑测试发现一堆编译错误,看IDE提示才知道注解废弃了。虽然旧的还能用,但新项目直接上@MockitoBean就行,省得以后迁移。还有人说@MockitoBean不支持生命周期回调,实测下来常规用法完全没影响,细节上注意就好。

3. TestRestTemplate实战:起真实端口,跑全链路

如果说MockMvc是“快而准”的显微镜,那TestRestTemplate就是“真而全”的探测车。它最适合做集成测试,尤其是验证多模块之间的真实协作。

3.1 webEnvironment到底怎么选

@SpringBootTest的webEnvironment有四个可选项:MOCK、RANDOM_PORT、DEFINED_PORT、NONE。

  • MOCK:默认值,不会启动真实服务器,和@TestRestTemplate搭配时其实走的是MockMvc机制,不算真正集成;
  • RANDOM_PORT:随机端口,避免和本机正在跑的实际服务冲突,适合并行测试;
  • DEFINED_PORT:使用application.yml里配置的server.port,好处是方便你手动打开浏览器调试,坏处是并行跑测试时可能端口冲突;
  • NONE:不启动Web环境,适合纯Service层、Repository层的集成测试。

我的建议很简单:写TestRestTemplate集成测试,就用RANDOM_PORT。随机端口在CI环境里最稳,多个测试实例并行也不打架。DEFINED_PORT只在你本地debug集成链路时临时用一下,别写进长期测试里。

3.2 测试数据的准备与测试隔离

集成测试最大的敌人是数据污染。如果你在测试方法里往数据库插了一条订单数据,下一个测试方法或者下一次跑CI时,这条数据还在,可能让断言失效。

我常用的手段是事务回滚:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Transactional class OrderApiIntegrationTest { // 测试方法内产生的数据库变更在方法结束后自动回滚 }

注意,@Transactional用在集成测试上是“测试结束后回滚”,不是“每个操作都自动提交”。这意味着你可以在方法内插入数据,测试环境里真实查询到了,方法结束再撤销。但要注意,如果Controller里有异步逻辑、消息队列生产者或者调用了外部的队列,那这些副作用是无法回滚的,需要额外清理。

3.3 从GET到POST:常用API脚手架

TestRestTemplate用法和RestTemplate很像,但有几个便利点:它会自动带上相对路径(不需要写全URL)、自动处理相对重定向、并且默认配置了HttpClient和连接池。

一个典型的GET测试:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class OrderApiIntegrationTest { @Autowired private TestRestTemplate restTemplate; @Test void shouldFetchOrderPage() { ResponseEntity<String> response = restTemplate.getForEntity("/api/orders?page=1&size=10", String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).contains("ORD-20240601-001"); } }

POST请求+对象反序列化是更常见的场景:

class CreateOrderRequest { Long productId; Integer quantity; // getter/setter省略 } class CreateOrderResponse { String orderNo; BigDecimal totalAmount; } @Test void shouldCreateOrder() { CreateOrderRequest request = new CreateOrderRequest(); request.setProductId(1L); request.setQuantity(2); ResponseEntity<CreateOrderResponse> response = restTemplate.postForEntity("/api/orders", request, CreateOrderResponse.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().getOrderNo()).startsWith("ORD-"); assertThat(response.getBody().getTotalAmount()).isEqualByComparingTo("39.80"); }

这里我特别强调,金额对比一定要用isEqualByComparingTo,不要用isEqualTo。BigDecimal的equals方法会连精度一起比,39.8和39.80都会被判为不相等,这在断言上是个经典翻车点。

3.4 设置请求头、携带Cookie、文件上传等实战细节

真实项目里,接口不可能都能用getForEntity/postForEntity一把梭。需要自定义Header、Cookie、超时时间时,就得用exchange方法:

HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth("mock-jwt-token"); HttpEntity<CreateOrderRequest> entity = new HttpEntity<>(request, headers); ResponseEntity<CreateOrderResponse> response = restTemplate.exchange("/api/orders", HttpMethod.POST, entity, CreateOrderResponse.class);

文件上传是另一个高频需求。TestRestTemplate上传文件需要借助LinkedMultiValueMap模拟multipart请求:

LinkedMultiValueMap<String, Object> parts = new LinkedMultiValueMap<>(); ByteArrayResource fileResource = new ByteArrayResource("binary-content".getBytes()) { @Override public String getFilename() { return "test-file.bin"; } }; parts.add("file", fileResource); parts.add("description", "upload test"); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); HttpEntity<LinkedMultiValueMap<String, Object>> entity = new HttpEntity<>(parts, headers); ResponseEntity<String> response = restTemplate.postForEntity("/api/files/upload", entity, String.class);

这里有个必须记住的点:ByteArrayResource如果不重写getFilename,multipart请求里文件名为空,后端MultipartFile拿到的是null文件名,很容易报错。这正是文档不会写、但实际跑一次就报错的地方。

4. 两类测试如何搭配,才能既快又稳

讲了这么多工具细节,核心问题其实还是:一个项目里,MockMvc和TestRestTemplate到底怎么分配比例才算合理?

4.1 分层质量策略:切片测试保底,集成测试守门

我个人的习惯是金字塔形分配。底座是大量的MockMvc切片测试,覆盖每个Controller的正常路径、异常路径、参数校验、权限控制、json结构完整性;顶部是一小部分TestRestTemplate集成测试,只覆盖核心链路——比如“下单-支付-查单”这种跨越多层、涉及事务和状态机的核心业务流程,以及几个最容易出问题的系统边界。

MockMvc测试跑得快,开发者愿意天天跑;TestRestTemplate测试跑得慢,适合挂在CI里每天跑几次。如果全部用TestRestTemplate,单模块测试时间可能从1分钟变成10分钟,开发时根本不想跑,到最后上线前才跑一次,发现一堆问题,又得返工。

4.2 什么时候必须升级到TestRestTemplate

这里给一个决策表,帮助判断该用哪个:

场景推荐工具
验证@RequestMapping路径映射是否正确MockMvc
验证@RequestBody参数绑定和JSON序列化MockMvc
验证鉴权注解@PreAuthorize是否生效MockMvc
验证Filter链、跨域配置、自定义拦截器TestRestTemplate
验证事务回滚、数据库真实读写TestRestTemplate
验证多服务调用、消息队列消费、异步任务TestRestTemplate
验证网关、反向代理层的路径改写TestRestTemplate(但要连真实网关)

有个容易被忽略的点是,如果一个接口逻辑很简单,但你的Service是真连数据库、真调外部API的,那MockMvc测试就得把Service整个Mock掉,测试内容和Service层的真实行为完全脱钩,这时候MockMvc测的只是“壳子”,意义打折。相反,如果一个接口逻辑复杂,但依赖全是内部方法,用MockMvc把Service Mock掉,测Controller本身才是核心价值。

4.3 我常用的包结构规范

为了让两类测试井水不犯河水,我一般这样组织测试目录:

src/test/java/com/example/demo/ ├── controller/ │ ├── OrderControllerSliceTest.java │ └── UserControllerSliceTest.java ├── integration/ │ ├── OrderFlowIntegrationTest.java │ └── AuthIntegrationTest.java ├── service/ │ └── OrderServiceUnitTest.java

Controller层专门放切片测试,名字里带SliceTest;Integration放真实端口的集成测试;Service层用普通JUnit + Mockito写单元测试,不启动Spring。分类的好处是,CI可以分阶段跑:先跑单元测试和切片测试,快速反馈;再跑集成测试,作为发布前的最后一道闸门。

5. 常见问题与排查技巧实录

最后放一批我实测中遇到的高频问题,每一条背后都是一次真实的踩坑经历。

5.1 切片测试里Service注入报错

报错信息通常是No qualifying bean of type 'OrderService' available。这不是Spring出了bug,而是@WebMvcTest只加载了Web层,Service不在上下文里。解决方案就是用@MockitoBean把Service替身加进去。如果多个测试类都Mock同一个Service,建议把这些Mock逻辑抽到一个BaseTest类里,减少重复代码。

5.2 MockMvc返回中文乱码

经常看到jsonPath断言报错,但手动看响应内容时中文变成了问号。原因是MockMvc默认字符集处理在某些版本下没有完全适配UTF-8。最简单的处理方式是显式设置Accept头:

mockMvc.perform(get("/api/orders") .accept(MediaType.APPLICATION_JSON)) .andExpect(content().encoding("UTF-8"));

如果响应体断言仍然有问题,先andDo(print())看真实输出,再决定是不是配置问题。我的经验是,最好在测试里不要依赖中文内容做核心断言,用英文code字段、状态字段来锁定逻辑,中文message只做参考,这样能避免环境差异带来的脆弱测试。

5.3 TestRestTemplate默认的ErrorHandler会掩盖错误响应体

用TestRestTemplate调接口,如果返回4xx/5xx,默认的DefaultResponseErrorHandler会直接抛HttpClientErrorException/HttpServerErrorException,但异常里拿到的body经常是空或者被吞掉的。如果你想知道错误响应体里具体的错误码和message,需要捕获异常并解析:

try { restTemplate.postForEntity("/api/orders", request, String.class); } catch (HttpClientErrorException e) { String errorBody = e.getResponseBodyAsString(); // 解析errorBody中的code和message }

或者更优雅的做法,为TestRestTemplate配置一个不抛异常的ErrorHandler,把错误响应当成正常响应返回,再在断言里统一判断status code和body结构。这个方法在测试“错误分支结构是否规范”时特别好用,能避免一大串try-catch。

5.4 集成测试跑得很慢?先检查上下文缓存

Spring TestContext框架会缓存已经加载的ApplicationContext,同类配置的测试类会复用同一个上下文,不会每个测试类重新启动一次。但如果你每个测试类里都写了不同的@SpringBootTest属性(比如不同的properties、不同的ActiveProfiles),缓存就会失效,每类测试都重新加载一次上下文,CI时间暴涨。

优化方法是:集成测试类尽量共用一个BaseIntegrationTest基类,把@SpringBootTest、@ActiveProfiles("test")、@AutoConfigureMockMvc这些公共注解都放到基类上,子类只在需要时补充差异配置。实测下来,这个改动能把整套集成测试时间从十几分钟压到几分钟——上下文加载复用是关键。

5.5 测试方法之间的数据残留导致断言失灵

前面提到@Transactional可以回滚,但有个前提:测试方法本身必须在事务里执行。如果你用的测试框架或者配置导致方法级没有事务,那插入的数据会真实落库。我用过一个比较稳的组合:集成测试类上加@Transactional,测试方法内部如果调用了真实Controller接口,Controller方法也会在同一个事务里执行;如果不加,多个测试方法之间可能产生相互影响。如果项目里有Caching、消息队列消费者这类会绕开事务的组件,那测试末尾要做显式清理,或者把断言设计成对重复数据不敏感的形式。

5.6 MockMvc断言嵌套JSON太繁琐

当响应体很深,比如三层嵌套对象+两层数组,写一连串jsonPath判断确实痛苦。我的做法是先用ObjectMapper把响应体反序列化成对应VO,再对VO做领域断言。前提是当前测试上下文里能拿到ObjectMapper并正确反序列化:

MvcResult result = mockMvc.perform(get("/api/orders/{id}", 1001L)) .andExpect(status().isOk()) .andReturn(); String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8); OrderDetailVO detail = objectMapper.readValue(json, OrderDetailVO.class); assertThat(detail.getItems()).hasSize(2); assertThat(detail.getItems().get(0).getSku()).startsWith("SKU-");

两种断言方式各有用武之地:要验证结构细节(字段是否存在,值是否精确)时用jsonPath;要验证领域逻辑(商品数量、总额、状态流转)时反序列化更好。两者结合,才是完整的Web层测试能力。

最后分享一个我个人的小习惯:写完一个Controller,我会先为它的正常路径写一个MockMvc切片测试,再为它的核心业务流转写一个TestRestTemplate集成测试,最后把错误分支的jsonPath断言补上。这三步做完,这个接口基本就敢放心上线了。Web层测试这件事,功夫在选型和细节里,跑起来快、断言得准、隔离得干净,你就能从“被测试折磨”变成“靠测试兜底”。

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

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

立即咨询