写接口的业务开发大多数时间都花在怎么证明接口能正常工作上。以前我写完一个 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency这一个 starter 默认带了 JUnit 5、Spring Test、AssertJ、Mockito、JSONPath、JsonAssert、XMLUnit 等。有这些基本够了后面的断言和 mock 都不需要再单独加包。如果你用的是 Spring Boot 2.xJava 版本最好保持在 8 或 11Spring 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 ResultUserVO 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)就是在发送?withDetailtrue。如果你的业务要求某个参数允许重复出现多个值单个param方法就不够用了需要看 3.2 节的多个参数写法。3.2 多参数组合与 List 收集项目里最逃不掉的 GET 接口是带筛选条件的分页查询。下面这个 Controller 就集齐了单个可选参数、默认值参数、List 参数三种情况GetMapping(/filter) public ResultPageResultUserVO filterUsers( RequestParam(required false) String keyword, RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size, RequestParam(value roles, required false) ListString roles) { return Result.success(userService.filter(keyword, page, size, roles)); }测试时多个请求参数只需要连续写多个.param(...)MockMvc 会自动组装成?keyword张page2size20rolesadminrolesnormalmockMvc.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 才能把它正确绑定到ListString上。.param(roles, admin, normal)的底层效果是添加两个roles参数项。如果你写成.param(roles, admin,normal)那收到的是只有一个元素admin,normal的 List这是很多人容易踩的坑。另外如果你已经有了一组参数在 Map 或者MultiValueMap里可以用MultiValueMapString, 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 对象。先看 ControllerPostMapping public ResultLong createUser(Valid RequestBody UserCreateRequest body) { return Result.success(userService.create(body)); }MockMvc 发 POST JSON 请求时核心是三件事指定POST方法、设置Content-Type为application/json、把 JSON 字符串放进contentmockMvc.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 ResultBoolean checkUser( RequestParam(name) String name, RequestParam(age) Integer age, RequestParam(value tags, required false) ListString tags) { return Result.success(userService.check(name, age, tags)); }MockMvc 里发表单参数的写法跟 GET query 参数的写法是相同的只不过请求方法变成了 POST并且要指定表单的Content-TypemockMvc.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()是把参数放进模拟请求的参数集合里请求方法是什么并不会限制参数集合的使用。对于表单 POSTSpring 的测试机制会自动把这些参数编码在请求体里所以不需要你手动拼name张三age18这样的字符串。如果 Controller 用的是ModelAttribute UserCheckForm form测试写法没有任何区别因为表单绑定本来就是按参数名一个个匹配的。.param(age, 18)里的字符串会被 Spring 自动转换成Integer转换失败时会触发类型转换异常这也是一个值得专门写用例验证的输入。4.3 集合对象、嵌套对象和“路径参数 Body”的混合场景业务稍微复杂一点POST 的请求体就不只是单对象了。批量创建用户的接口会把一组对象放在 JSON 数组里PostMapping(/batch) public ResultInteger batchCreate(RequestBody ListUserCreateRequest 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 ResultVoid assignRoles(PathVariable Long id, RequestBody ListLong roleIds) { userService.assignRoles(id, roleIds); return Result.success(null); }测试时同时照顾到路径和 bodymockMvc.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.enabledtrue server.servlet.encoding.charsetUTF-8 server.servlet.encoding.forcetrue不过这个配置主要影响真实容器MockMvc 测试环境里不一定完全生效。所以最稳妥的还是测试代码里显式指定字符集不要依赖环境默认值。5.2 Security 环境下的 POST 会莫名收到 403Spring Security 在 classpath 里时WebMvcTest通常也会把安全配置加载进来。这时候直接发 POST JSON 请求你会看到 Controller 明明没问题但测试返回 403。这不是参数写错了是请求里没有携带 CSRF tokenSpring Security 默认会拦截带状态变化的请求。解决方式有两种。第一种是在请求里明确加一个模拟的 CSRF token前提是测试依赖里有spring-security-testdependency groupIdorg.springframework.security/groupId artifactIdspring-security-test/artifactId scopetest/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 的非法 bodymockMvc.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用对象转 JSONAutowired 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 试跑一下跑通了再逐步往上叠加真实接口。等你在一次回归里靠它抓出某个数据结构被改坏的问题就会觉得当初搭这套测试脚手架花的时间完全值了。
阅读完成 · 觉得有帮助?