☰
Spring Boot接口测试实战:MockMvc从GET到POST的完整用法
2026/10/12 10:38:39 网站建设 项目流程

1. 为什么是MockMvc,而不是直接拿Postman点两下?

做Spring Boot开发的人,十有八九都习惯打开Postman或者Apifox,填好URL和参数,点一下“Send”,看到200就完事。但真正到了要验证控制器逻辑、参数绑定、JSON序列化、拦截器行为的时候,Postman就显得不够用了——它不是不能测,而是没法融入到自动化和回归测试体系里。这也是我要写这篇内容的核心原因:MockMvc能在不启动真实Servlet容器的情况下,完整模拟HTTP请求打到Spring MVC的DispatcherServlet上,把控制器、参数解析、校验、拦截器、异常处理都跑一遍,最后给你一个可以用来断言的MockHttpServletResponse。

这里最关键的一点是,MockMvc并不是真实起一个Tomcat、Jetty去监听端口,而是直接在应用上下文内部走一遍MVC的核心流程。它和集成测试(比如真正起Spring Boot的服务再发HTTP请求)是有本质区别的——MockMvc更快、更轻、更适合单元级别和控制器级别的测试;而真正的端到端测试得靠TestRestTemplate或者WebTestClient去访问真实端口。两者各有用武之地,但针对“测接口逻辑”这个事情,MockMvc是效率最高、定位最准的那把刀。

适合谁来学这篇内容呢?刚接触Spring Boot测试脚手架的人、在写毕业设计想补测试覆盖率的人、或者工作中需要给接口加个快速自测场景的开发者,都能从中拿到可直接抄走用的代码片段。这篇内容我把GET和POST两种最常见的请求方式拆开讲,单个参数、多个参数、路径参数、JSON body、表单提交、文件上传都会覆盖到,配合一些我踩过的坑和排查经验,尽量让内容落地,而不是浮在理论表面。

2. 环境准备与测试骨架搭建

2.1 引入依赖,注意starter-test的隐藏能力

Spring Boot测试的第一步,是在pom.xml里加上spring-boot-starter-test。这个依赖包的东西比很多人以为的要多——它不只包含JUnit和Mockito,还打包了AssertJ、Hamcrest、JSONPath、JsonUnit这些工具。也就是说,你写的断言可以既有JUnit的assertEquals习惯,也可以用AssertJ的链式风格,还可以在andExpect里用jsonPath直接写JSONPath表达式去校验响应body。这就是为什么很多时候MockMvc测试代码里不需要额外引入JSON解析库的原因。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>

要注意的一个点是版本对应。如果你用的是Spring Boot 2.x系列,JUnit默认是5.x,也就是用org.junit.jupiter.api.Test这个包名的注解;如果是很老的1.x项目,走的是JUnit 4。大部分教程默认讲的都是Jupiter,所以你在写测试类的时候,导入的@Test必须是org.junit.jupiter.api.Test,这个包名和JUnit 4的那个org.junit.Test长得几乎一样,特别容易导错,一旦导错,整个测试类的注解全部不生效,直接报空指针或者根本跑不起来。

2.2 @WebMvcTest和@SpringBootTest怎么选

搭建测试骨架之前,需要先想清楚切入方式。Spring Boot提供了两种主流姿势:@WebMvcTest和@SpringBootTest配合@AutoConfigureMockMvc。

@WebMvcTest(YourController.class)是切片测试,它只会加载Web层的相关组件——Controller、ControllerAdvice、WebMvcConfigurer、Filter等,Service和Repository不会装进上下文里。这套机制的好处是启动飞快,测试范围聚焦,坏处是Service层需要你自己用@MockBean或者@MockitoBean去模拟。如果你的控制器依赖的Service逻辑非常复杂,爬数据、缓存、消息队列全在里面,那么@WebMvcTest配合Mock模拟是性价比最高的组合,它只关心“Controller怎么接收参数、怎么调用Service、怎么把返回结果组装成JSON”,不会因为数据库连接失败导致测试崩掉。

@SpringBootTest则是全量加载应用上下文,所有Bean都真实装配。适合那种需要CompleteIntegration风格测试的场景,比如你在测事务、测Repository和Controller的联动。代价就是启动慢,而且一旦有外部依赖(数据库、MQ、Redis)不可用,测试直接红。所以我的建议很明确:只测Controller层逻辑,用@WebMvcTest;要测整条链路,再用@SpringBootTest。本篇的代码示例以@WebMvcTest为主,因为这样能突出MockMvc本身的用法,不被环境因素干扰。

2.3 一个能直接跑的最小测试骨架

先给一个最朴素的骨架,后面所有场景的测试都可以在这个基础上扩展:

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.web.servlet.MockMvc; import org.springframework.boot.test.mock.mockito.MockBean; import com.example.demo.controller.UserController; import com.example.demo.service.UserService; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void testGetUserById() throws Exception { mockMvc.perform(get("/api/user/list")) .andExpect(status().isOk()); } }

这里有个很容易踩的细节:MockMvc本身不是让你自己new出来的对象,而是由Spring Boot的自动配置在测试上下文里准备好的。如果你是老手,以前在Spring MVC时代手动MockMvcBuilders.standaloneSetup(controller).build(),那是另一个路数——它不需要Spring上下文,只把目标控制器单独拉起来测,速度极快,但拦截器、过滤器、全局异常处理这些就都需要自己手动模拟。而@WebMvcTest下注入的MockMvc,是装配了整个Web环境的,ControllerAdvice、过滤器链全都会生效,这也是为什么我推荐用自动注入而不是手动构建。

还有一个骨架阶段就要提醒自己的事:@WebMvcTest默认会扫描@ControllerAdvice、@RestControllerAdvice这些全局异常类并加载进去,所以如果你项目里有全局异常处理器,测试中触发异常路径时,返回的JSON结构会被它影响。这是件好事,但不懂的人会在排查时懵——明明控制器抛了异常,响应码却是200,因为异常处理器把所有异常都兜底转成了200。所以写测试断言时,先看看项目里有没有全局异常处理,再决定期望的HTTP状态码。

3. GET接口测试:单个参数、多个参数与路径参数

3.1 单个请求参数:param()的底层逻辑

先从一个最常见但也最容易出错的写法开始。假设有一个接口定义是@GetMapping("/api/user/detail"),参数叫id,类型是Long:

@RestController @RequestMapping("/api/user") public class UserController { @GetMapping("/detail") public Result<UserVO> detail(@RequestParam("id") Long id) { // 业务逻辑 return Result.success(userService.getById(id)); } }

MockMvc测试这个接口,最简单的写法是用param():

mockMvc.perform(get("/api/user/detail") .param("id", "1001")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.name").value("张三"));

param()方法接收的是String类型的键值对。你也许会觉得“这不就是把URL拼一下吗”?我可以直接写成get("/api/user/detail?id=1001")。但两种写法有一个重要差异:param("id", "1001")会对参数做URL编码处理,而直接拼URL不会。比如参数值里带中文、空格、特殊符号,直接拼URL很容易在后续andExpect断言时拿到一串乱码或者被过滤掉。param()则会把参数交给Servlet API的参数解析机制,按application/x-www-form-urlencoded的规范处理,中文、%号、+号都能正确还原。所以我的建议是写MockMvc测试时,尽量使用.param(),不要自己手拼QueryString。

3.2 多个GET参数:链式param()与Map批量传参

多个参数场景也常见,比如列表接口带了关键词、页码、页大小:

@GetMapping("/list") public Result<PageResult<UserVO>> list(@RequestParam("keyword") String keyword, @RequestParam("page") Integer page, @RequestParam("size") Integer size) { return Result.success(userService.page(keyword, page, size)); }

MockMvc写多参数有几种写法。第一种就是链式调用,直来直去:

mockMvc.perform(get("/api/user/list") .param("keyword", "张") .param("page", "1") .param("size", "10")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.total").isNotEmpty());

第二种姿势是把参数塞进MultiValueMap再丢进params()方法。这个适合参数特别多、或者你在测试里要写一组条件动态拼接的场景:

LinkedMultiValueMap<String, String> params = new LinkedMultiValueMap<>(); params.add("keyword", "张"); params.add("page", "1"); params.add("size", "10"); params.add("sort", "create_time"); params.add("order", "desc"); mockMvc.perform(get("/api/user/list") .params(params)) .andExpect(status().isOk());

MultiValueMap支持同一个key绑定多个值,这正好对应HTTP里面一个参数名带多个值的场景,比如?ids=1&ids=2&ids=3。如果你的接口参数定义是@RequestParam("ids") List<Long> ids,用这个Map来传值是最稳的,链式param()虽然也能追加同名参数,但维护起来没有Map清爽。

在实际项目中,GET多个参数经常还伴随着校验注解,比如@Min、@Max、@NotBlank。测试时得把边界值也打进去:

mockMvc.perform(get("/api/user/list") .param("keyword", "张") .param("page", "0") // 非法,page最小值应为1 .param("size", "10")) .andExpect(status().isBadRequest()); // 命中 @Min(1) 时返回400

这个断言思路在写测试时特别实用:传合法参数断言业务结果正常,传非法参数断言被校验拦截。一套测试用例就能把参数校验逻辑覆盖住,这也是MockMvc相对Postman测试最有优势的地方——它能让校验规则的检验变成随代码提交的自动化资产。

3.3 路径参数:@PathVariable场景的测试写法

路径参数和Query参数在MockMvc里的写法完全不同。接口定义如果是:

@GetMapping("/detail/{id}") public Result<UserVO> detail(@PathVariable("id") Long id) { return Result.success(userService.getById(id)); }

测试的时候,参数是直接写在URI模板里的:

mockMvc.perform(get("/api/user/detail/{id}", 1001L)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.id").value(1001));

这里有个细节需要注意:get(String uriTemplate, Object... uriVariables)方法接受URI模板和可变参数,框架会自动把1001L填充到{id}占位符里。你也可以直接用写死的字符串get("/api/user/detail/1001"),效果一样。但如果你要测的是非法路径参数,比如传入一个非数字:

mockMvc.perform(get("/api/user/detail/{id}", "abc")) .andExpect(status().isBadRequest());

这个场景下,Spring MVC在参数转换阶段就会把String转Long的异常捕获住,返回400。用URI模板方式的好处就在这里——传参和断言逻辑一目了然,要测什么类型转换错误,字符串一换就行。

路径参数还有一个易错点:占位符数量和可变参数数量必须一致。写了两个{}只传一个值,或者传的值顺序搞错,会把路径拼成一个完全不可达的URL,测试结果就是404。排查这种问题的时候,先别怀疑MockMvc,去数一下占位符。

3.4 GET测试中的中文乱码与编码断言

中文参数是GET测试的重灾区。假设接口返回的是带中文的JSON数据,比如{"name": "张三"},andExpect(jsonPath("$.data.name").value("张三"))挂掉,报一个编码或者比较失败的错误,大概率不是业务逻辑问题,而是响应编码没对齐。Spring Boot默认的server.servlet.encoding配置是UTF-8,正常情况没问题,但如果你配了旧项目的StringHttpMessageConverter,或者测试环境里CharacterEncodingFilter没有生效,JSON返回的中文可能变成乱码。

处理办法比较粗暴,但很有效——显式声明响应格式和编码:

mockMvc.perform(get("/api/user/detail").param("id", "1001")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(jsonPath("$.data.name").value("张三"));

如果jsonPath解析中文一直失败,可以先把响应内容打出来看,MockMvc提供了一个很实用的调试手段:

String responseBody = mockMvc.perform(get("/api/user/detail").param("id", "1001")) .andReturn() .getResponse() .getContentAsString(StandardCharsets.UTF_8); System.out.println(responseBody);

拿到原始响应字符串,是乱码还是结构不对,一眼就能定位。这一点在实际排查时非常省时间——很多问题其实是JSON结构层级和预想的不一样,而不是中文编码问题。

4. POST接口测试:单参数、多参数与JSON Body

4.1 单个POST参数:RequestParam方式

POST接口最容易被误解的地方是,很多人一看到POST就默认要传JSON。实际上Spring MVC的POST支持好几种Content-Type,最基础的其实是application/x-www-form-urlencoded,用法和GET的Query参数几乎一致。

假设接口定义是:

@PostMapping("/search") public Result<List<UserVO>> search(@RequestParam("keyword") String keyword) { return Result.success(userService.search(keyword)); }

MockMvc的写法:

mockMvc.perform(post("/api/user/search") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param("keyword", "Java")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isArray());

这里contentType(MediaType.APPLICATION_FORM_URLENCODED)可以加也可以不加,但建议加上——因为如果你后面参数复杂了,或者接口逻辑里对Content-Type有判断,不加这个就可能导致分支走不对。Form表单方式传单个参数,本质就是body里的key=value串,param()方法往request的parameter map里塞数据,Spring会把这部分数据解析成@RequestParam。

实际项目里,单个参数的POST接口比较少,更多是那种“表单里就一个关键字搜索框”的场景。测试代码很简单,但有一条经验值得记住:如果你的接口方法是public Result<?> search(String keyword),方法参数上没写@RequestParam,那么POST form和GET query都能匹配到参数,MockMvc写法不变。而如果加了@RequestParam,那么参数来源受限,传错方式直接400。测试的目的之一,正是验证这些注解有没有按预期生效。

4.2 多个POST参数:表单提交方式的完整写法

多个参数的form表单POST,是最接近网页表单提交的场景。接口定义比如:

@PostMapping("/add") public Result<Long> add(@RequestParam("name") String name, @RequestParam("age") Integer age, @RequestParam("email") String email) { return Result.success(userService.add(name, age, email)); }

MockMvc可以这样测:

mockMvc.perform(post("/api/user/add") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param("name", "李四") .param("age", "25") .param("email", "lisi@example.com")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isNumber());

这个写法看起来和GET多参数一模一样,区别在于请求方法变成了post(),并且显式设置了表单的Content-Type。当你在测试里用了.param()以后,MockMvc实际上会把参数构建成MultiValueMap<String, String>,然后根据Content-Type决定是拼到URL上还是塞进请求body。

有一点很重要:对于POST表单提交,如果你的参数是数组或者List,比如接口接收@RequestParam("tag") List<String> tag,那就在同一参数名下面add多个值:

mockMvc.perform(post("/api/user/batchTag") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param("id", "1001") .param("tag", "开发") .param("tag", "后端") .param("tag", "Java")) .andExpect(status().isOk());

这种用param()重复追加同一个key的写法,在HTTP语义上就是提交多个同名表单字段,Spring MVC能正确解析成List。实际测试时,多参数场景建议把每个参数的边界值都列出来,比如年龄传-1、邮箱传abc,配合参数校验注解一起做断言。这样一套用例下来,基本把接口的参数容错能力都覆盖了。

4.3 JSON Body方式:Controller层最常用的数据交换格式

REST接口的POST绝大多数是JSON。接口定义:

@PostMapping("/save") public Result<Long> save(@RequestBody @Valid UserCreateDTO dto) { return Result.success(userService.save(dto)); }

MockMvc传JSON需要两步:设置Content-Type为application/json,然后把JSON字符串塞进content():

mockMvc.perform(post("/api/user/save") .contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"王五\",\"age\":30,\"email\":\"wangwu@example.com\"}") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data").value(1L));

这里.content()接收的是字符串,而且它不会自动帮你把对象序列化成JSON。很多人一开始会问:“能不能传个对象进去?”答案是不行——content()的重载方法只接受String和byte[]。所以要么手动拼JSON字符串,要么用ObjectMapper序列化再传:

ObjectMapper objectMapper = new ObjectMapper(); String json = objectMapper.writeValueAsString(userCreateDTO); mockMvc.perform(post("/api/user/save") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk());

用ObjectMapper的好处是,如果DTO字段特别多,手写JSON字符串容易漏字段、写错类型名。尤其在测试代码里维护一堆字符串,可读性和复用性都很差。我在实际项目里更推荐写一个工具方法,专门做对象转JSON,或者直接用@Test方法内配合ObjectMapper转。反正核心思想就一个:测试代码和业务代码一样,也讲究可读性。

4.4 多个POST参数之JSON嵌套与List字段

多参数的POST接口,如果参数不是一个扁平的DTO,而是嵌套了另一个对象、或者带了一个对象数组,这时候JSON body的优势才真正体现出来。比如:

@PostMapping("/submitOrder") public Result<Long> submitOrder(@RequestBody OrderCreateDTO dto) { return Result.success(orderService.submit(dto)); }

OrderCreateDTO里包含用户信息、收货地址和商品列表。构造测试数据时,手写JSON会非常痛苦,这时候用Map或DTO再序列化,显著更合理:

Map<String, Object> address = new HashMap<>(); address.put("province", "浙江省"); address.put("city", "杭州市"); address.put("detail", "文一西路100号"); List<Map<String, Object>> items = new ArrayList<>(); Map<String, Object> item1 = new HashMap<>(); item1.put("skuId", 10001L); item1.put("quantity", 2); items.add(item1); Map<String, Object> order = new HashMap<>(); order.put("userId", 888L); order.put("address", address); order.put("items", items); String json = objectMapper.writeValueAsString(order); mockMvc.perform(post("/api/order/submit") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isNumber());

这种构造方式比拼接字符串安全得多,而且ObjectMapper序列化Map时,数字会自动按数字类型输出,不会出现"quantity": 2被写成"quantity": "2"的尴尬。测试通过jsonPath("$.data.xxx")校验返回结果时,也要注意返回结构里的字段类型——如果接口把Long序列化成了字符串(Jackson的ToStringSerializer),那么jsonPath(...).value(10001)就会匹配失败,你得用value("10001")。这个细节我在项目里碰到过好几次,排查起来特别容易头大,但归根结底是接口序列化策略的问题,不是MockMvc的问题。

4.5 POST接口的文件上传与Multipart参数

有些POST接口需要同时携带普通字段和文件,比如用户头像上传:

@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file, @RequestParam("dir") String dir) { return Result.success(fileService.upload(file, dir)); }

MockMvc对multipart/form-data这种格式的支持很完整:

MockMultipartFile file = new MockMultipartFile( "file", "test.png", MediaType.IMAGE_PNG_VALUE, "fake-image-content".getBytes(StandardCharsets.UTF_8) ); mockMvc.perform(multipart("/api/user/upload") .file(file) .param("dir", "avatar")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isNotEmpty());

这里multipart("/api/user/upload")本身就是POST,不用再显式调用post()。MockMultipartFile就是用来包装测试文件的,构造参数分别是字段名、原始文件名、Content-Type和文件内容字节数组。如果你要模拟空文件,传个空的byte[]就行;要模拟大文件,可以在字节数上做文章。

一个常见的错误是,文件字段名和接口定义的@RequestParam("file")不一致。MockMvc不会替你纠正,它会直接把“找不到文件字段”的信息以400的形式甩在脸上。排查这类问题,第一件事就是核对字段名。还有一点:如果你同时有文件和普通参数,普通参数继续用param(),它会自动作为Multipart参数提交。别用.content()去传普通参数,Multipart请求里content()会被忽略,参数根本到不了接口方法里。

5. 响应结果的深度断言:从状态码到JSONPath

5.1 断言HTTP状态码和响应头

写好请求之后,重心就转移到响应断言上。最基本的判断是HTTP状态码:

mockMvc.perform(post("/api/user/save") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()); // 200 // .andExpect(status().isBadRequest()); // 400 // .andExpect(status().isCreated()); // 201 // .andExpect(status().isInternalServerError()); // 500

除了状态码,响应头也是经常要校验的对象。比如接口需要返回自定义响应头:

.andExpect(header().string("X-Custom-Header", "custom-value"))

对于带token鉴权的接口,测试时可能还要断言Set-Cookie或者Authorization头:

.andExpect(header().exists("Authorization"))

响应内容类型也可以做精确校验:

.andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))

这里要注意,如果接口返回的Content-Type是application/json;charset=UTF-8,你直接用MediaType.APPLICATION_JSON_VALUE(不带charset)去匹配,MockMvc内部对Content-Type的比较是严格模式还是宽松模式取决于版本。实际碰到过的情况是,Spring Boot 2.x中content().contentType()的比较是支持带charset的,但如果服务端配置了自定义的MediaType,就很容易出现断言意外的失败。稳妥的做法是用com.jayway.jsonpath去解析JSON,而不是死磕Content-Type的字符串匹配。

5.2 jsonPath用法与常见的JSON结构断言

我们可以利用jsonPath对返回的JSON做非常深层次的校验。假设接口返回了一串分页数据:

{ "code": 200, "message": "SUCCESS", "data": { "total": 15, "records": [ {"id": 1, "name": "张三"}, {"id": 2, "name": "李四"} ], "hasNext": true } }

对应的断言可以这样写:

.andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.total").value(15)) .andExpect(jsonPath("$.data.records").isArray()) .andExpect(jsonPath("$.data.records[0].name").value("张三")) .andExpect(jsonPath("$.data.records.length()").value(2)) .andExpect(jsonPath("$.data.hasNext").value(true));

jsonPath的表达式能力足够覆盖90%的接口测试需求:

表达式含义示例
$.field取顶层字段$.code
$.a.b.c取嵌套层级字段$.data.total
$.arr[0]取数组第一个元素$.data.records[0]
$.arr.length()取数组长度$.data.records.length()
$.arr[*].name取出数组所有对象的name字段$.data.records[*].name
$.hasNext取布尔字段$.data.hasNext
@.name过滤条件中用一般配合filter使用

有一种很常见的断言需求是“数组里是否包含某个值”,可以直接配合Hamcrest的集合匹配器:

.andExpect(jsonPath("$.data.records[*].name", hasItem("李四")))

这里用到了hasItem,它来自org.hamcrest.Matchers。需要静态导入:

import static org.hamcrest.Matchers.hasItem;

这行断言的意思是:records数组里面所有对象的name字段组成的列表中,必须包含“李四”。对于接口查询列表这类功能,这是非常硬核的验证。

5.3 andReturn()与手动解析响应体

有些测试场景不是只用andExpect链式断言能覆盖的,比如需要拿到响应的body去做复杂的JSON解析、校验某个字段的唯一性、或者要把响应数据作为上下文传给下一个测试步骤。这时候用andReturn():

MvcResult result = mockMvc.perform(get("/api/user/list") .param("keyword", "张")) .andExpect(status().isOk()) .andReturn(); String responseBody = result.getResponse().getContentAsString(StandardCharsets.UTF_8); ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree(responseBody); JsonNode records = root.path("data").path("records"); for (JsonNode record : records) { System.out.println(record.get("name").asText()); }

实际项目里,我见过很多人把andReturn()和andExpect()混合使用,其实两者之间的关系很简单:andExpect本质是对MvcResult做断言,多了andReturn之后,你既保留了链式断言,又拿到了完整的响应对象做后续操作。这种方式特别适合“先断言接口能返回,再拿数据继续测试下一个接口”的场景,比如先创建订单拿到订单号,再用这个订单号去查详情:

MvcResult createResult = mockMvc.perform(post("/api/order/save") .contentType(MediaType.APPLICATION_JSON) .content(orderJson)) .andExpect(status().isOk()) .andReturn(); long orderId = mapper.readTree( createResult.getResponse().getContentAsString(StandardCharsets.UTF_8)) .path("data").asLong(); mockMvc.perform(get("/api/order/detail/{id}", orderId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.id").value(orderId));

这种“串联测试”让MockMvc的玩法又深了一层,也是接口回归测试里非常有用的模式。你不需要停下来用Postman手动去复制的路径参数,MockMvc已经把流程自动化了。

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

6.1 问题速查表:我踩过的那些坑

MockMvc用久了,会遇到一些很有规律的问题。这里整理一个排查速查表:

异常/现象最可能的原因解决办法
org.springframework.web.util.NestedServletException: Request processing failedController或Service抛出未捕获异常查Controller内部代码,看是否@ControllerAdvice没有覆盖该异常
java.lang.AssertionError: Status expected:400 but was:200参数校验注解未生效或参数传错了都没触发校验检查Controller类是否加了@Validated,@RequestParam上有没有校验注解
404URL路径拼写错误或@RequestMapping路径不对直接从Controller里复制路径,不要手敲
400且报MissingServletRequestParameterExceptionGET/POST参数名和@RequestParam里的名字不一致核对参数名,特别是拼写和大小写
Content type not supportedPOST接口期望JSON但测试里没设置Content-Type加上.contentType(MediaType.APPLICATION_JSON)
jsonPath断言报错No results for path: $['data']['name']返回JSON结构里根本没有这个字段先andReturn()把响应体打出来,检查实际结构
中文乱码响应编码或请求参数编码问题用getContentAsString(StandardCharsets.UTF_8)读取,检查编码过滤器
BeanInstantiationException@WebMvcTest里没Mock掉Service导致上下文加载失败给依赖的Service加@MockBean
MockMvc为null测试类没有加@AutoConfigureMockMvc或@WebMvcTest检查测试类上的注解,确保Spring上下文装载了MockMvc

仔细看这张表,其实大部分问题集中在三个方向:一是参数名对不上,二是Content-Type不对,三是JSON结构预期和实际不一致。这三个方向占了MockMvc排错的八成以上,所以排查思路基本是“先用andReturn()把响应打出来看,再对照Controller方法的参数定义和注解”,两步就能定位。

6.2 三个隐蔽的坑,新手基本都会踩

第一个坑是**@WebMvcTest下没有Mock Service**。很多新手照着一个教程写,测试类里用了@WebMvcTest(SomeController.class),但Controller里又注入了SomeService。启动测试直接报BeanInstantiationException或者NoSuchBeanDefinitionException,原因就是切片测试不加载Service。解决办法是给Service加@MockBean:

@MockBean private SomeService someService;

如果是Spring Boot 3.4开始,@MockBean被标记为弃用,新推荐的是@MockitoBean。如果你用的是Boot 3.4+,直接换成org.springframework.test.context.bean.override.mockito.MockitoBean即可。

第二个坑是**jsonPath().value()的类型比较**。接口返回的JSON数字,在Jackson默认配置下,小整数会被序列化成IntNode或者LongNode,但如果你在DTO里把数字声明成了String,又配了Jackson的ToStringSerializer,那么前端拿到的就是一个字符串。测试里写value(1001)必然失败。这不是MockMvc的错,而是测试预期没有和实际的序列化策略保持一致。应对办法是,发现数字断言失败,第一时间去查DTO字段类型和Jackson全局配置,别在MockMvc的写法上反复折腾。

第三个坑是过滤器或拦截器干扰了MockMvc的结果。比如项目里有一个登录拦截器,所有接口都得带token,测试时直接请求就返回401,Controller根本没进到。@WebMvcTest下,部分过滤器会被自动加载,如果你的过滤器是通过FilterRegistrationBean注册的,在测试中默认可能不加载,但拦截器是通过WebMvcConfigurer注册的,一定会加载。这时候要么在测试里排除拦截器(通过@WebMvcTest的excludeFilters属性或者排除配置类),要么干脆在测试请求里带上token。我更推荐带上token,这样测试能覆盖真实场景,能顺便验证拦截器的行为。

6.3 调试技巧:怎么把MockMvc测试失败的信息看得更清楚

排查MockMvc问题有一个必杀技:在perform之后不直接断言,而是先打印请求和响应信息。

MockHttpServletRequestBuilder requestBuilder = get("/api/user/detail") .param("id", "1001"); mockMvc.perform(requestBuilder) .andDo(print()) // 这行会把请求和响应完整打印出来 .andExpect(status().isOk());

andDo(print())是MockMvc最实用的调试API之一。它会输出请求头、请求参数、响应状态、响应头、响应body。当你写了一个复杂的POST接口测试,每次都失败但不知道失败原因时,先加上这一句,日志里就能看到完整的MockHttpServletRequest和MockHttpServletResponse的信息,问题基本就水落石出了。

我个人的调试习惯是:

  1. 先用andDo(print())跑一次,看到响应body和状态码。
  2. 看响应里的JSON结构是不是和预期的一样。
  3. 如果结构不对,检查Controller代码和DTO定义。
  4. 如果结构对但断言失败,检查jsonPath表达式和类型匹配。
  5. 最后删掉andDo(print()),保留干净的断言链。

这个流程效率非常高,能避免大多数无头苍蝇式的排查。

7. 关于MockMvc,我个人的一些实操体会

写到这里,其实MockMvc的核心用法都覆盖得差不多了。回头看,MockMvc真正强大的地方不只是能模拟GET和POST请求,而是它把接口测试变成了一种可以随代码提交、随CI运行的标准化动作。以前用Postman测接口,测完了就完了,没有留下任何可回归的资产。而MockMvc测试写在src/test/java里,每次构建都执行,任何一次改动让接口行为变了,测试立刻报警,这个价值对我来说比“能跑通一个接口”重要得多。

我在实际项目中的做法是:核心业务接口必须配有MockMvc测试,而且测试里不光覆盖正常路径,还要覆盖参数校验失败、鉴权失败、资源不存在这些异常分支。这套东西维护起来成本并不高,但每次重构Controller参数、调整响应结构的时候,它都能帮我省下大量手工回归的时间。特别是多人协作的项目里,接口改动常常有连锁反应——你以为改的是内部实现,结果把接口返回值结构改了,前端感知不到,等联调时才发现。有了MockMvc测试的守护,这种问题在提交代码前就暴露了。

如果你刚开始接触MockMvc,我建议从最简单的GET接口单参数测试入手,跑通了之后,依次尝试多参数、POST表单、JSON body、文件上传、异常断言。练完这一圈,你对Spring MVC参数绑定机制的理解会明显上一个台阶——因为MockMvc的每一个测试用例,本质上都是在对你写的接口定义做验证,它会强制你去审视参数注解、校验规则、响应结构这些平时容易忽略的细节。后面再遇到“为什么Postman能通但测试挂了”之类的问题,你的排查思路就会清晰很多:先看Context-Type,再看参数名,再看响应结构,三板斧下来基本都能解决。

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

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

立即咨询