写接口的业务开发,大多数时间都花在怎么证明接口能正常工作上。以前我写完一个 GET 或者 POST,习惯把项目启动起来,再用 Postman 手填 URL、Header、Body,点一下 Send,看到 200 就收工。这个习惯本身没毛病,但接口数量和场景一旦上来,你会很快发现两个问题:一是回归成本太高,每次改动都要重新打开浏览器或者客户端;二是漏分支很容易,只测了“正常路径”,参数校验、异常返回、字段缺失这些场景基本靠运气。MockMvc 正是在这个背景下进入我日常开发流程的。
如果你正在用 Springboot 写接口,想让 controller 层的测试跑在 CI 里、每次提交代码都能自动验证一遍 GET、POST 的返回结果,那 MockMvc 是绕不开的工具。这篇文章会用实际代码把单个请求参数和多个请求参数的场景都拆一遍,包括路径参数、query 参数、JSON 请求体、表单参数、集合参数,以及我在测试过程中踩过的几个坑。内容适合已经能写简单 Springboot 接口、但对自动化测试还不够熟的开发者。
1. 不启服务不跑端口:MockMvc 的测试逻辑和适用边界
1.1 它到底在模拟什么
很多刚开始接触 MockMvc 的人会有一个困惑:这工具是不是把服务起在了一个随机端口上?并不是。Tomcat 没有启动,端口没有监听,HTTP 协议栈也没有走。MockMvc 是 spring-test 模块提供的能力,它在测试上下文里构造了一个虚拟的 DispatcherServlet,请求在内存里完成路由分发的全过程。
我一般这样理解这条链路:一个真实请求进入 Springboot 应用后,经过 Tomcat 接收、Filter 链、DispatcherServlet 分发、HandlerMapping 找到对应 Controller、HandlerAdapter 调用方法、异常处理器兜底。MockMvc 把中间的“Tomcat 接收”换成了在测试中直接构造 MockHttpServletRequest,“Filter 链、分发、拦截器、Controller 调用”这些环节还是真实存在的。所以它对 Spring MVC 注解的验证很可靠,@RequestParam、@PathVariable、@RequestBody、@Valid、@ExceptionHandler这些行为都和在浏览器里请求是一样的。
这个设计决定了它的一个天然优势:快。没有进程启动、没有端口占用、没有网络延迟,一个测试用例从发出到断言完成,通常在毫秒级。跑完mvn test也不会在机器上残留一个占用端口的进程,对 CI 来说非常稳定。
1.2 什么时候用 MockMvc,什么时候别硬上
我在团队里划分的方式很直接:如果测试目标是“这个 Controller 在当前注解、过滤器、参数绑定下能不能按预期处理请求”,用 MockMvc。比如验证 GET 的多个 query 参数能否正确绑定、POST 的 JSON body 能否被@RequestBody正确反序列化,这类场景它是第一选择。
如果测试目标是“连数据库、缓存、消息队列、第三方系统一起跑”,那就别硬用 MockMvc,改用@SpringBootTest(webEnvironment = RANDOM_PORT)配合 TestRestTemplate 更合适。因为 MockMvc 不经过真实网络栈,测不了网络超时、负载均衡、网关转发这类问题。它可以 mock Service 层,但测不了 Service 里真正访问的 Redis 或 MySQL。
还有一个边界问题:MockMvc 对 Servlet 容器特性的模拟是有限的。如果你依赖了某些定制化的容器能力,比如特有的 connector 配置、自定义协议解析,那必须在真实容器里做集成测试。不过对于绝大多数业务接口来说,MockMvc 覆盖 controller 层已经足够。
2. 测试脚手架:依赖版本、启动注解和第一版 GET 冒烟用例
2.1 spring-boot-starter-test 里有什么
MockMvc 并不需要额外引入一个很大的依赖,它就在 spring-test 里面。Springboot 项目直接在 pom 中加上:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>这一个 starter 默认带了 JUnit 5、Spring Test、AssertJ、Mockito、JSONPath、JsonAssert、XMLUnit 等。有这些基本够了,后面的断言和 mock 都不需要再单独加包。
如果你用的是 Spring Boot 2.x,Java 版本最好保持在 8 或 11;Spring Boot 3.x 要求 Java 17 起步。选版本的时候留意一点,网上很多旧博客还在写org.junit.Test这种 JUnit 4 的导入,Spring Boot 2.2 之后默认测试引擎已经切换到 JUnit 5,正确写法是org.junit.jupiter.api.Test。少数老项目引了 JUnit 4,会看到测试能跑但注解是灰色,最好统一成 JUnit 5。
2.2 测试类怎么组织
假设我们有一个用户接口的 Controller,负责按 ID 查询用户:
@RestController @RequestMapping("/api/v1/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{id}") public Result<UserVO> getUser(@PathVariable Long id, @RequestParam(defaultValue = "false") boolean withDetail) { return Result.success(userService.getById(id, withDetail)); } }这里Result只是一个统一返回包装,UserVO是返回值对象,UserService是业务层。为了只测 controller 层,测试类上我推荐用@WebMvcTest,它只加载 MVC 相关配置,不会把整个 Spring 容器里的 Bean 全启动一遍。具体写法:
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.Mockito.when; 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; @MockitoBean private UserService userService; @Test void getUser_不传递额外参数_返回200() throws Exception { mockMvc.perform(get("/api/v1/users/1")) .andExpect(status().isOk()); } }注意@MockitoBean是 Spring Boot 3.4 时代推荐的写法,老一点的版本里写@MockBean也没有问题。它的作用是把UserService替换成一个 Mock 对象,避免真正触发数据库访问。先跑通这条用例,你的测试环境基本就准备好了。
2.3 第一个断言别急着写太细
第一次接触 MockMvc 的人容易犯一个错:一上来就把 JSON 字段断言写得很全,结果中文乱码、字段名写错、返回值结构对不上,反而失去信心。我建议第一版只断言状态码和返回类型,先把“请求能被 Spring MVC 正确路由”这件事验证了,再逐层加字段断言。
mockMvc.perform(get("/api/v1/users/1")) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON));这一步跑通后,再处理具体参数场景也算是有一个稳定的基线。
3. GET 接口测试:单参数、多参数、List 参数的真正写法
3.1 路径参数和单个 query 参数
上一节的getUser已经包含了非常典型的两类 GET 参数:路径参数{id}和 query 参数withDetail。测试中如果想传路径参数,最优雅的方式是把值作为get方法的第二个参数传进去:
mockMvc.perform(get("/api/v1/users/{id}", 1L) .param("withDetail", "true")) .andExpect(status().isOk());/api/v1/users/{id}里的{id}是占位符,MockMvc 会把1L直接绑定到@PathVariable Long id。这里有个很实用的细节:不要在 URL 字符串里手动拼 ID,比如get("/api/v1/users/" + id)。手动拼遇到特殊字符、转义问题时会很头疼,占位符方式由框架处理,干净利落。
单个 query 参数的场景相对简单,.param("withDetail", "true")就是在发送?withDetail=true。如果你的业务要求某个参数允许重复出现多个值,单个param方法就不够用了,需要看 3.2 节的多个参数写法。
3.2 多参数组合与 List 收集
项目里最逃不掉的 GET 接口是带筛选条件的分页查询。下面这个 Controller 就集齐了单个可选参数、默认值参数、List 参数三种情况:
@GetMapping("/filter") public Result<PageResult<UserVO>> filterUsers( @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(value = "roles", required = false) List<String> roles) { return Result.success(userService.filter(keyword, page, size, roles)); }测试时,多个请求参数只需要连续写多个.param(...),MockMvc 会自动组装成?keyword=张&page=2&size=20&roles=admin&roles=normal:
mockMvc.perform(get("/api/v1/users/filter") .param("keyword", "张") .param("page", "2") .param("size", "20") .param("roles", "admin", "normal")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.list").isArray());这里要特别注意的是roles参数:同一个 key 传多个值,Spring 才能把它正确绑定到List<String>上。.param("roles", "admin", "normal")的底层效果是添加两个roles参数项。如果你写成.param("roles", "admin,normal"),那收到的是只有一个元素"admin,normal"的 List,这是很多人容易踩的坑。
另外,如果你已经有了一组参数在 Map 或者MultiValueMap里,可以用:
MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); params.add("keyword", "张"); params.add("page", "2"); params.add("roles", "admin"); params.add("roles", "normal"); mockMvc.perform(get("/api/v1/users/filter").params(params)) .andExpect(status().isOk());这种方式在测试数据由公共方法统一构造时很实用,尤其是参数多到三四个以上时,代码看起来会清爽不少。
3.3 断言到字段,别只停留在状态码
状态码 200 只能说明请求没被框架拒掉,不代表业务结果一定符合预期。对 GET 接口,我在实际项目里至少会断言两层:一层是响应包装的标识字段,另一层是返回数据里的核心字段。
mockMvc.perform(get("/api/v1/users/1") .param("withDetail", "true")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.id").value(1L)) .andExpect(jsonPath("$.data.name").value("张三"));如果返回结构里有数组,还要进一步确认数组长度和元素顺序。比如分页查询的list字段:
.andExpect(jsonPath("$.data.list", hasSize(2))) .andExpect(jsonPath("$.data.list[0].name").value("张三"));开发阶段建议在断言链后面加一句.andDo(print()),测试结果里会打印完整的请求信息和响应信息,定位问题时非常好用。上线前把 print 去掉或者改成打印专用日志,避免 CI 日志太吵。
4. POST 接口测试:JSON 请求体、表单参数和集合对象的三种姿势
4.1 最常见的单对象 JSON 请求体
POST 接口里出现频率最高的是@RequestBody接收一个 JSON 对象。先看 Controller:
@PostMapping public Result<Long> createUser(@Valid @RequestBody UserCreateRequest body) { return Result.success(userService.create(body)); }MockMvc 发 POST JSON 请求时,核心是三件事:指定POST方法、设置Content-Type为application/json、把 JSON 字符串放进content:
mockMvc.perform(post("/api/v1/users") .contentType(MediaType.APPLICATION_JSON) .characterEncoding(StandardCharsets.UTF_8) .content("{\"name\":\"张三\",\"age\":18}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").value(1001L));这里少了contentType会怎样?Spring 会用默认的请求头去判断,大概率返回 415 Unsupported Media Type,因为@RequestBody明确要求请求体能被 JSON 解析。如果Content-Type和 body 内容不匹配,同样会在参数解析阶段直接报错。
characterEncoding(StandardCharsets.UTF_8)是我个人习惯加上的,尤其当 JSON 里有中文时,它能避免很多莫名其妙的乱码问题。
4.2 多个请求参数:表单提交用 @RequestParam 或 @ModelAttribute
不是所有 POST 都传 JSON,很多老一点的项目或者内部管理后台,仍习惯用表单方式传多个请求参数。对应的是@RequestParam逐字段接收,或者@ModelAttribute绑到一个对象上。Controller 里可能是这样:
@PostMapping("/check") public Result<Boolean> checkUser( @RequestParam("name") String name, @RequestParam("age") Integer age, @RequestParam(value = "tags", required = false) List<String> tags) { return Result.success(userService.check(name, age, tags)); }MockMvc 里发表单参数的写法,跟 GET query 参数的写法是相同的,只不过请求方法变成了 POST,并且要指定表单的Content-Type:
mockMvc.perform(post("/api/v1/users/check") .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param("name", "张三") .param("age", "18") .param("tags", "spring", "mockmvc")) .andExpect(status().isOk());这里很多人有个误解:以为.param()只能用在 GET 上。实际上 MockMvc 的.param()是把参数放进模拟请求的参数集合里,请求方法是什么并不会限制参数集合的使用。对于表单 POST,Spring 的测试机制会自动把这些参数编码在请求体里,所以不需要你手动拼name=张三&age=18这样的字符串。
如果 Controller 用的是@ModelAttribute UserCheckForm form,测试写法没有任何区别,因为表单绑定本来就是按参数名一个个匹配的。.param("age", "18")里的字符串会被 Spring 自动转换成Integer,转换失败时会触发类型转换异常,这也是一个值得专门写用例验证的输入。
4.3 集合对象、嵌套对象和“路径参数 + Body”的混合场景
业务稍微复杂一点,POST 的请求体就不只是单对象了。批量创建用户的接口会把一组对象放在 JSON 数组里:
@PostMapping("/batch") public Result<Integer> batchCreate(@RequestBody List<UserCreateRequest> users) { return Result.success(userService.batchCreate(users)); }对应的测试要构造一个 JSON 数组字符串:
String body = """ [ {"name":"张三","age":18}, {"name":"李四","age":20} ] """; mockMvc.perform(post("/api/v1/users/batch") .contentType(MediaType.APPLICATION_JSON) .characterEncoding(StandardCharsets.UTF_8) .content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").value(2));手写这种多元素数组很容易漏逗号或者少括号,所以我在实际项目里更推荐用ObjectMapper生成 JSON,这一点后面专门讲。
另一种高频场景是“路径参数 + JSON body 混合”,比如给某个用户分配角色:
@PostMapping("/{id}/roles") public Result<Void> assignRoles(@PathVariable Long id, @RequestBody List<Long> roleIds) { userService.assignRoles(id, roleIds); return Result.success(null); }测试时同时照顾到路径和 body:
mockMvc.perform(post("/api/v1/users/{id}/roles", 1L) .contentType(MediaType.APPLICATION_JSON) .characterEncoding(StandardCharsets.UTF_8) .content("[1, 2, 3]")) .andExpect(status().isOk());可以看到,多个参数的场景本质上是同一个 MockMvc 语义的组合,路径参数放post方法的地址里,请求体放content里,参数名对应的数据用param设置。把这三个入口理清楚,绝大多数业务接口的测试构造问题就解决了。
5. 实测中绕不开的四个坑:中文乱码、CSRF、校验失败与 JSON 序列化
5.1 中文断言总失败,先检查字符集
在测试里写jsonPath("$.data.name").value("张三"),明明浏览器里返回的是“张三”,测试却报响应字符串变成了乱码,这种情况我遇到不止一次。原因通常是响应头里的charset没有被 MockMvc 正确识别,中文 content 解码时用了默认字符集。
我现在的固定做法是:凡是请求或响应里可能包含中文,都在 perform 的 builder 链上显式加.characterEncoding(StandardCharsets.UTF_8),同时确保 Controller 的返回值通过消息转换器输出时带上 UTF-8。如果项目里用的是 Spring Boot 的默认server.servlet.encoding配置,可以再检查一下配置文件:
server.servlet.encoding.enabled=true server.servlet.encoding.charset=UTF-8 server.servlet.encoding.force=true不过这个配置主要影响真实容器,MockMvc 测试环境里不一定完全生效。所以最稳妥的还是测试代码里显式指定字符集,不要依赖环境默认值。
5.2 Security 环境下的 POST 会莫名收到 403
Spring Security 在 classpath 里时,@WebMvcTest通常也会把安全配置加载进来。这时候直接发 POST JSON 请求,你会看到 Controller 明明没问题,但测试返回 403。这不是参数写错了,是请求里没有携带 CSRF token,Spring Security 默认会拦截带状态变化的请求。
解决方式有两种。第一种是在请求里明确加一个模拟的 CSRF token,前提是测试依赖里有spring-security-test:
<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency>测试代码变成:
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; mockMvc.perform(post("/api/v1/users") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"张三\",\"age\":18}")) .andExpect(status().isOk());如果你连“当前用户”这个上下文都需要模拟,还可以用.with(user("admin").roles("USER"))。第二种是如果这个测试类根本就不想验证安全链路,可以在注解上加@AutoConfigureMockMvc(addFilters = false),把过滤器链关掉。做 Login 和权限相关测试的时候建议保留安全过滤器,做普通业务接口测试时关掉会更快,看你的测试目标来选。
5.3 @Valid 校验不通过时,怎么断言才对
加了@Valid之后的 POST 接口,漏写必填参数是高频场景,也是测试里最有价值的一部分。比如UserCreateRequest里name是必填项,我们构造一个缺少 name 的非法 body:
mockMvc.perform(post("/api/v1/users") .contentType(MediaType.APPLICATION_JSON) .characterEncoding(StandardCharsets.UTF_8) .content("{\"age\":18}")) .andExpect(status().isBadRequest());这里有一个重要前提:你得知道项目里有没有全局统一异常处理器。如果只是 Spring Boot 默认行为,校验失败通常返回 400 和默认错误结构。如果项目里用@RestControllerAdvice做了统一包装,那响应结构可能是{"code":400,"message":"name不能为空"},这时候断言就要跟着改:
.andExpect(jsonPath("$.code").value(400)) .andExpect(jsonPath("$.message").value("name不能为空"));所以写这类测试前,先看一眼异常处理器是怎么包装的,不然很容易出现“接口在 Postman 里能返回错误信息,但测试断言就是不对”的诡异局面。
5.4 手拼 JSON 到序列化:ObjectMapper 的正确打开方式
前面几个例子为了直观,都直接写了 JSON 字符串。但手拼字符串在真实项目里维护成本很高,字段一多、结构一嵌套,一个引号错位就够你排查半天。我的习惯是测试里注入ObjectMapper,用对象转 JSON:
@Autowired private ObjectMapper objectMapper; @Test void createUser_正常参数_创建成功() throws Exception { UserCreateRequest request = new UserCreateRequest("张三", 18); String body = objectMapper.writeValueAsString(request); mockMvc.perform(post("/api/v1/users") .contentType(MediaType.APPLICATION_JSON) .characterEncoding(StandardCharsets.UTF_8) .content(body)) .andExpect(status().isOk()); }这样做的好处是,以后UserCreateRequest字段变化了,只要对象属性跟着改,测试 JSON 会自动保持一致,不会出现“手写的 JSON 里字段叫name,对象里已经改成nickname”这类错误。
如果你的字段里有LocalDateTime、LocalDate这类 Java 8 时间类型,要确保 test 里拿到的ObjectMapper或 Controller 使用的序列化配置注册了JavaTimeModule。Spring Boot 默认会配置好,但如果测试里new ObjectMapper()自己 new 了一个,经常会发现时间字段序列化异常。遇到这种情况可以:
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();这也是一个很隐蔽但出现率极高的坑。
6. 从“能测”变成“好用”的三个建议
6.1 断言要查到业务字段,不要只查状态码
状态码 200 只是最基础的第一层保障。我在 code review 时有一个习惯:如果测试代码里只有status().isOk(),我会要求补充至少一个业务字段断言。因为没有字段断言,就测不出返回的数据是否真的符合预期,万一 Service 层被 mock 后返回了 null,接口照样可能是 200,但前端拿到的数据是完全不对的。
对 GET 接口,核心字段和数组长度是关键;对 POST 接口,返回的资源 ID、创建数量这类业务结果是关键。把这些写进断言,测试才有实际保护价值。
6.2 用 MvcResult 把请求结果留出来继续用
有些场景是“先 POST 创建资源,再 GET 验证资源”,MockMvc 里可以通过MvcResult拿到完整响应,再供下一步使用:
MvcResult result = mockMvc.perform(post("/api/v1/users") .contentType(MediaType.APPLICATION_JSON) .characterEncoding(StandardCharsets.UTF_8) .content(body)) .andExpect(status().isOk()) .andReturn(); String responseBody = result.getResponse().getContentAsString(StandardCharsets.UTF_8); Long userId = JsonPath.parse(responseBody).read("$.data", Long.class); mockMvc.perform(get("/api/v1/users/{id}", userId)) .andExpect(status().isOk());这种写法比把$data硬编码成固定值要实用得多,因为测试数据可以动态变化,尤其是批量创建后再查询的场景。
6.3 别把所有接口都塞进一个全量上下文
@SpringBootTest会加载整个应用上下文,接口一多、依赖一多,跑一次测试的时间会成倍增长。单测 Controller 时,我优先用@WebMvcTest加 mock Service 的写法。只有真正需要验证@Transactional事务边界、数据库 Repository、或者跨模块的配置装配时,才升级到全量上下文测试。
我还习惯给测试类按业务模块分文件,一个模块一个测试类,一个场景一个测试方法。这样跑挂了,光看测试方法名就能定位到具体接口和参数组合,比如createUser_缺少必填name_返回参数校验错误。测试方法的命名看起来是小细节,在团队协作里救场概率极高。
最后说一点个人体会:MockMvc 的上手曲线不算陡,但真正用得好的人并不多,差别往往就在于这些参数细节和异常场景有没有被覆盖到。我建议你把今天这几个示例贴到自己项目里,新建一个最简单的 Controller 试跑一下,跑通了再逐步往上叠加真实接口。等你在一次回归里靠它抓出某个数据结构被改坏的问题,就会觉得当初搭这套测试脚手架花的时间完全值了。