Spring Boot实战:MockMvc高效测试GET与POST接口
接口测试到底该不该启动真实服务这个问题我纠结了很久。在Spring Boot项目里用MockMvc测GET、POST接口已经快三年了最开始我也不太信任这套方案总觉得不启动Tomcat、不走网络就算不上真测试。直到有一次CI环境里跑全量测试8080端口被占、测试库连不上、环境变量缺失连续炸了好几个晚上我才下定决心把所有Controller测试都迁到MockMvc上。这篇文章就把我在实战里整理出来的MockMvc测试GET和POST接口的方法完整写一遍重点拆解单个请求参数和多个请求参数两个场景的写法差异、背后的参数绑定原理以及那些官方文档不会写、只有真跑过才会发现的坑。不管你是要搭新项目的测试体系还是想把接口的Postman手工测试换成自动化回归这篇都适用。1. 为什么用MockMvc测接口不启动Tomcat也能跑通的测试思路1.1 MockMvc到底做了什么先说清楚最基本的问题。MockMvc是Spring Test框架里专门用来测Web层的组件它不会像Postman那样真的把请求发到网络端口上而是用MockHttpServletRequest和MockHttpServletResponse这对模拟请求/模拟响应对象把请求直接交给DispatcherServlet去处理。之后的HandlerMapping、HandlerAdapter、参数解析、数据绑定、消息转换、Controller方法执行、异常处理这一整条MVC链路都会真实走一遍只是从走网络变成了走内存。打个比方真实HTTP请求像是你去柜台办业务MockMvc则是你把单子直接递到柜员手上业务逻辑一件不少只是跳过排队和叫号。Controller里的代码感知不到这两种方式有什么区别所以它能覆盖的测试范围非常广参数绑定对没对、Valid校验生效没生效、返回的JSON结构对不对、状态码给得对不对全都能验。1.2 不启动真实服务换来的是三样东西第一是快。没有线程池创建、端口监听、连接建立的开销一个Controller测试跑下来通常几百毫秒整个测试套件可以从分钟级压到秒级。这对开发期频繁跑测试的人来说很关键测试跑得慢人就不想跑最后测试就形同虚设。第二是稳。CI环境里跑测试最怕环境依赖8080端口被占、测试库没起来、某个中间件连不上测试就整片飘红。MockMvc不占端口、不碰网络只要应用上下文能加载起来就能跑稳定性完全不在一个量级。第三是可控。用Postman你能改header、body、query但改不了Spring的Session、SecurityContext、请求属性这些内部状态MockMvc可以直接注入Session、设置认证信息、指定当前登录用户这对测需要登录态的接口非常方便。1.3 什么时候该用什么时候别用MockMvc适合Controller层以内的测试也就是从请求进来、参数解析、业务方法调用、到响应出去这一整段。如果你要验证的是跨服务调用、网关路由、负载均衡或者就是想知道真实服务器上这个接口通不通那MockMvc不合适。那种场景应该用TestRestTemplate配合真实启动的应用或者干脆启动项目后用curl、Postman做冒烟。提示MockMvc不会触发真实的Filter链之外的Servlet容器行为比如连接池、线程模型这些它管不着。所以它的定位是控制器级测试而不是端到端测试。定位清楚了后面写测试时思路就不会乱。2. 环境准备starter依赖、注解组合与MockMvc实例化的三种方式2.1 spring-boot-starter-test里已经帮你配好了什么Spring Boot工程要跑MockMvc其实只需要一个依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency这个starter是测试全家桶里面带了JUnit 5、Spring Test、Mockito、AssertJ、Hamcrest、JSONassert、JsonPath。MockMvc本身在spring-test模块里不需要额外引入。有一点提醒别在pom里再手动引一个低版本的spring-test覆盖掉Boot的依赖管理你会在运行时报奇怪的版本不兼容。我见过不止一个人干过这事排查到最后就是classpath里出现了两个junit版本。2.2 三种初始化方式我建议这么选我实际用过三种方式来创建MockMvc实例各有适用场景。初始化方式加载范围优点缺点我推荐的使用场景SpringBootTest AutoConfigureMockMvc完整应用上下文最接近真实环境能用真实Bean启动慢依赖多集成验证团队主力方案WebMvcTest MockBean只加载Web层启动快隔离Service容易缺Bean配置不熟会卡住只想快速测Controller逻辑MockMvcBuilders.standaloneSetup()手动指定Controller极轻量适合单类快测脱离Spring容器很多注解不生效老项目遗留代码补测试先说最常用的SpringBootTest AutoConfigureMockMvc。它加载完整上下文你Controller里Autowired的Service、Repository、配置属性都是真的测出来的结果最接近生产行为。如果你把持久层也换成内存库配合Transactional回滚一套集成测试就起来了这是我这几年主力采用的方式。WebMvcTest只扫描Controller、ControllerAdvice、Web相关的配置Service这些会被跳过。这种模式下Service通常要用MockBean打出Mock实现否则注入会失败。它的启动速度快但如果项目里Web配置比较杂比如自定义了ArgumentResolver、拦截器新手经常被启动失败卡住所以我不建议刚上手就选它。standaloneSetup是我给老项目补测试时偶尔用的直接new一个Controller塞进去不经过Spring容器。对绕过容器偶发的配置问题很有用但RequestParam之外的容器能力会缺失只适合极简单的场景。2.3 一个可以直接抄的测试类骨架package com.example.demo; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; SpringBootTest AutoConfigureMockMvc class UserControllerTest { Autowired private MockMvc mockMvc; Test void healthCheck_shouldReturnOk() throws Exception { mockMvc.perform(get(/api/health)) .andExpect(status().isOk()); } }把MockMvc直接Autowired进来测试类上标SpringBootTest AutoConfigureMockMvc这就是最小可运行骨架。注意perform方法会抛Exception测试方法要么throws Exception要么try-catch否则编译直接报错。很多人第一次写MockMvc就挂在方法签名上记住了就不亏。3. GET接口测试单个参数与多个参数场景全梳理这一节是本文的重头戏。GET接口的参数形态至少有六种无参、单个查询参数、多个查询参数、同名多值参数、路径变量、多个参数绑定成对象。我一个个说每种都给Controller示例和测试写法。3.1 无参GET最基础的入口最简单的GET就是没参数比如健康检查、获取配置列表RestController RequestMapping(/api/app) public class AppController { GetMapping(/version) public Result version() { return Result.success(1.0.0); } }对应测试mockMvc.perform(get(/api/app/version)) .andExpect(status().isOk()) .andExpect(jsonPath($.code).value(200)) .andExpect(jsonPath($.data).value(1.0.0));无参场景主要验两点状态码是不是200、返回结构是不是符合约定。这里顺便说下jsonPath($.data).value(1.0.0)如果返回值是数字会强对比类型字符串和数字不能混用否则断言会失败。你以为它只是值相等实际它是类型加值一起比。3.2 单个查询参数.param()的用法与内部机制最常见的是按ID查详情GetMapping(/detail) public Result getById(RequestParam(id) Long id) { User user userService.getById(id); return Result.success(user); }测试写法mockMvc.perform(get(/api/user/detail) .param(id, 1001)) .andExpect(status().isOk()) .andExpect(jsonPath($.data.id).value(1001));.param(id, 1001)这个方法我建议从一开始就用。它的逻辑是往MockHttpServletRequest的参数表里塞一个键值对如果当前请求有queryString它也会参与拼接。比起直接在URL里拼字符串.param()更安全它会对特殊字符做URL编码比如中文、空格、符号你手写在URL里很容易出编码问题稍后我会用中文参数举例。3.3 多个查询参数链式param与直接拼URL的差别分页列表接口是最典型的多参数场景GetMapping(/list) public Result list(RequestParam(page) int page, RequestParam(size) int size, RequestParam(value keyword, required false) String keyword) { return userService.page(page, size, keyword); }测试写法多个参数就用链式parammockMvc.perform(get(/api/user/list) .param(page, 1) .param(size, 10) .param(keyword, 张三)) .andExpect(status().isOk()) .andExpect(jsonPath($.data.total).exists());这里有个容易踩的细节keyword里是中文如果你图省事直接这样写mockMvc.perform(get(/api/user/list?page1size10keyword张三))除非你手动转码否则queryString里的中文在部分环境里会出编码问题而.param()会自动处理。另外直接拼URL时如果忘了对做转义Spring解析时会把后面的参数吃掉。所以多人协作的项目里我强烈建议一律用.param()而不是字符串拼接URL。3.4 同名多值参数RequestParam List 场景还有一种多个参数容易被忽略就是同一个参数名传多个值比如批量查询GetMapping(/batch) public Result batch(RequestParam(ids) ListLong ids) { return userService.batch(ids); }测试写法mockMvc.perform(get(/api/user/batch) .param(ids, 1) .param(ids, 2) .param(ids, 3)) .andExpect(status().isOk()) .andExpect(jsonPath($.data).isArray()) .andExpect(jsonPath($.data.length()).value(3));同一个参数名重复调用.param()即可MockMvc会把它们聚合成一个多值参数列表Spring按顺序绑定到List上。这个场景在真实的HTTP请求里对应的是ids1ids2ids3这样的形式很多人会写成ids1,2,3那是另一种约定需要Controller配合RequestParam(required false) ListString加String拆分才能处理和这里不是一回事。3.5 路径变量另一种参数的测试方式路径变量不是query参数但它也是GET接口里绕不开的参数形态GetMapping(/{id}) public Result getByPath(PathVariable(id) Long id) { return userService.getById(id); }对应测试有两种写法// 写法一直接拼到路径里 mockMvc.perform(get(/api/user/1001)); // 写法二用占位符传参参数多时可读性更好 mockMvc.perform(get(/api/user/{id}, 1001L));推荐写法二。当路径里有多个变量时比如/api/user/{userId}/order/{orderId}用占位符传参能清晰看出每个位置对应什么值代码评审时一眼就能看懂。3.6 多个query参数绑定成对象ModelAttribute场景接口参数一多Controller方法签名就难看。很多项目会用一个查询对象来接Data public class UserQuery { private String name; private String status; private int pageNum; private int pageSize; } GetMapping(/search) public Result search(UserQuery query) { return userService.search(query); }没有ModelAttribute注解时Spring也会把query参数绑定到对象字段上前提是参数名和字段名一致mockMvc.perform(get(/api/user/search) .param(name, 张三) .param(status, ACTIVE) .param(pageNum, 1) .param(pageSize, 20)) .andExpect(status().isOk());这种场景下.param()传的值全部是字符串Spring拿到对象后按类型去转String转int、转Long、转Date转不动就抛MethodArgumentTypeMismatchException最终表现为400。所以测试时故意传一个非法类型比如pageNumabc再用状态码断言验证接口确实返回400是个非常好的回归用例。3.7 缺参数与类型错误负向用例也要测正向往返只是及格线接口测试的价值很大一部分在负向用例上。比如// 缺必填参数 page mockMvc.perform(get(/api/user/list) .param(size, 10)) .andExpect(status().isBadRequest()); // page 传了非数字 mockMvc.perform(get(/api/user/list) .param(page, abc) .param(size, 10)) .andExpect(status().isBadRequest());Spring框架对缺参和类型错误都处理成了400不用你自己去拼异常。这个特点拿来写参数校验的回归测试非常方便。我在团队里推过一个规矩每个多参数接口至少补两条故意传错的用例三个月下来接口参数相关的线上故障基本绝迹。4. POST接口测试表单参数、JSON请求体与文件上传POST的复杂程度比GET高不少因为请求参数可能出现在Form Data里也可能出现在JSON请求体里还可能混合文件。我按参数形态拆开讲。4.1 单个表单参数Content-Type必须显式声明表单提交是最基础的POST场景PostMapping(/create) public Result create(RequestParam(name) String name) { return userService.create(name); }对应测试mockMvc.perform(post(/api/user/create) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param(name, 张三)) .andExpect(status().isOk());不设contentType时MockMvc大多也能把.param()里的值解析出来因为Spring参数解析读的是请求参数表不太受contentType影响。但我还是要求团队显式声明APPLICATION_FORM_URLENCODED原因有两个一是调试打印出来的请求才像一个真实的浏览器表单提交二是接口如果配了consumes约束contentType不对会直接415你只调.param()而不设contentType会踩到隐藏的边界问题。所以从一开始就养成立场POST表单必须显式声明Content-Type。4.2 多个表单参数绑定对象与同名数组多个表单参数和GET里的多参逻辑一致可以链式.param()也可以绑定对象。例如注册接口Data public class RegisterForm { private String username; private String password; private String email; } PostMapping(/register) public Result register(RegisterForm form) { return userService.register(form); }测试mockMvc.perform(post(/api/user/register) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param(username, zhangsan) .param(password, 123456) .param(email, zhangsanexample.com)) .andExpect(status().isOk());POST表单里同名多值参数的写法与GET相同仍然用同一个参数名重复.param()来实现。另外一个差别POST表单绑定对象时Spring支持嵌套属性比如address.city这样的参数名MockMvc的.param(address.city, 北京)同样能绑定到嵌套字段这个细节在测复杂表单时很实用。4.3 JSON请求体配合RequestBody与ObjectMapper现在大部分前后端分离项目POST传参都用JSONController写法是PostMapping(/save) public Result save(RequestBody Valid UserDTO dto) { return userService.save(dto); }对应测试mockMvc.perform(post(/api/user/save) .contentType(MediaType.APPLICATION_JSON) .content({\username\:\zhangsan\,\age\:25})) .andExpect(status().isOk());这里有个我强烈建议的做法别手写JSON字符串尤其是当DTO有多个字段、嵌套子对象、包含日期类型时手写JSON特别容易出错比如少一个引号、类型给错接口里拿到null还毫无察觉。正确方式是注入Spring Boot配置好的ObjectMapper用它把对象序列化成字符串Autowired private ObjectMapper objectMapper; Test void save_shouldWork() throws Exception { UserDTO dto new UserDTO(); dto.setUsername(zhangsan); dto.setAge(25); mockMvc.perform(post(/api/user/save) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(dto))) .andExpect(status().isOk()); }为什么强调注入而不是new ObjectMapper()因为Spring Boot在自动配置里已经帮你处理好了Java 8日期时间类型的序列化器jsr310以及常见的全局配置。你手动new一个LocalDate字段很可能序列化成数组或者直接报错。这属于那种本地没测出来、上测试环境才发现的经典坑趁早避开。4.4 multipart文件上传POST的特殊形态文件上传是POST的另一种常见形态Controller一般长这样PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file, RequestParam(bucket) String bucket) { return fileService.upload(file, bucket); }测试时要用MockMultipartFile来构造文件MockMultipartFile file new MockMultipartFile( file, test.txt, text/plain, hello mockmvc.getBytes(StandardCharsets.UTF_8)); mockMvc.perform(multipart(/api/file/upload) .file(file) .param(bucket, images)) .andExpect(status().isOk());注意multipart()这个builder是MockMvcRequestBuilders里专门为multipart场景提供的它本身会把Content-Type设置成multipart/form-data你不需要像表单那样手动设Content-Type。MockMultipartFile的五个参数依次是参数名、文件名、Content-Type、文件内容字节数组文件名那一个经常有人传空或不传导致上传后拿不到原始文件名复现问题时会很头疼。4.5 contentType写错时你会遇到什么POST测试里报错最多的就是Content-Type和参数形式不匹配。我整理成表方便对照请求体形式接口接收方式你正确要设的Content-Type设错后的报错Form表单参数RequestParam / 表单对象application/x-www-form-urlencoded部分场景出现400JSON字符串RequestBodyapplication/json415 Unsupported Media Type文件与字段MultipartFilemultipart/form-data用multipart()自动设置400或绑定失败看到415时别慌优先检查contentType是不是写成了APPLICATION_FORM_URLENCODED却传了JSON体。这种错误在你手动copy代码时最容易出现因为perform(post(...))那一行俩写法长得一模一样只有contentType参数在变。5. 断言与排查状态码、jsonPath和失败时的调试链路请求发出去了怎么验对结果是MockMvc用得好不好的分水岭。很多人只会status().isOk()一旦接口返回200但数据不对完全没发现测试形同虚设。5.1 状态码断言别只会isOk状态码断言覆盖面比你想的广.andExpect(status().isOk()) // 200 .andExpect(status().isCreated()) // 201新增资源常用 .andExpect(status().isBadRequest()) // 400参数错误 .andExpect(status().isUnauthorized()) // 401未登录 .andExpect(status().isForbidden()) // 403无权限 .andExpect(status().is(422)) // 自定义状态码直接传数字我特别想提醒的是422这种业务状态码。很多团队会把参数校验失败统一封装成200 业务code但从接口设计角度我更推荐用标准HTTP状态码表达错误类别然后测试里精确断言对应的状态码。这样客户端处理逻辑能省很多判断测试意图也更清晰。5.2 jsonPath断言验证返回结构的基本功大多数项目的响应都有一层统一包装比如{code, message, data}。断言时就要先剥外壳、再查内层。常用的jsonPath写法// 断言code字段等于200 .andExpect(jsonPath($.code).value(200)) // 断言data下的list数组长度是3 .andExpect(jsonPath($.data.list.length()).value(3)) // 断言数组第一个元素的name字段 .andExpect(jsonPath($.data.list[0].name).value(张三)) // 断言字段存在 .andExpect(jsonPath($.data.total).exists()) // 断言字段不存在比如删除后时间戳不该出现 .andExpect(jsonPath($.data.deletedAt).doesNotExist())jsonPath的根节点是$不是响应体的类名这一点刚用的人总搞混。另外length()对数组有效单独一个字段没有length数组也可以用hasSize(n)来自org.hamcrest.Matchers.hasSize需要额外import我一般优先用length()减少import。如果要断言整个响应体长得和我期望的一模一样可以用content().json(...).andExpect(content().json({\code\:200,\data\:{\id\:1001}}, false))第二个参数false的意思是忽略小数点的精度差异比如100和100.0视作相等实际用起来很顺手。5.3 排查链路.andDo(print())和getResolvedException()断言失败时第一步永远是在链路上加.andDo(print())mockMvc.perform(post(/api/user/save) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(dto))) .andDo(print()) .andExpect(status().isOk());print()会把MockHttpServletRequest和MockHttpServletResponse的完整信息打到控制台包括请求体、响应体、状态码。绝大多数断言失败扫一眼print输出就能定位。如果还想拿到Controller抛出的异常用MvcResult result mockMvc.perform(...) .andReturn(); Throwable exception result.getResolvedException();getResolvedException()返回Spring MVC在解析过程中捕获的原始异常比看响应体更直接。我排查过一次诡异的500响应体是通用错误页全靠这个方法抓到真正的NullPointerException栈。当然测试里拿到异常对象后还可以直接断言异常类型但这种写法要求精细一般只在调试时用。6. 踩坑实录MockMvc测试常见的五个报错与解决最后把我这几年最常被问到的、自己也踩过的坑集中列一遍每个都给了根因和解决办法。6.1 415 Unsupported Media TypeContent-Type和参数形式不匹配症状是请求发出去返回415。根因通常有两个接口用RequestBody接收JSON但你请求里没设APPLICATION_JSON或者你设了APPLICATION_JSON但传的body不是合法JSON。解决方法是按本文第4.5节那张表严格对齐请求形式。如果确认请求形式没错去看一下接口方法上的consumes是不是声明了特定类型比如consumes application/json;charsetUTF-8那你的contentType必须完全匹配缺了charset都不行。6.2 中文乱码getContentAsString的字符集陷阱断言或调试时很多人会调getContentAsString()发现中文变成乱码。原因是MockHttpServletResponse.getContentAsString()在没设置响应字符集时默认按ISO-8859-1解码。解决办法是显式指定UTF-8String responseBody result.getResponse() .getContentAsString(StandardCharsets.UTF_8);另一种思路是写断言前先确认Controller返回的Content-Type里带charsetUTF-8。Spring Boot对JSON响应默认配置了UTF-8理论上不会乱码但如果项目里改过字符集配置、或者手写了HttpServletResponse返回String乱码就会冒出来。遇到乱码别慌先按上面的方式取字符串再决定是不是要修接口配置。6.3 403Spring Security与CSRF项目集成了Spring Security之后POST接口测试很常见的一个报错是403。Security默认对POST开启CSRF校验MockMvc发出去的请求默认不带CSRF Token于是被拦截。解决方式有两种我按推荐顺序写import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; // 方式一带上CSRF Token mockMvc.perform(post(/api/user/save) .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(...)) .andExpect(status().isOk());// 方式二测试环境下禁用CSRF在安全配置里 http.csrf(csrf - csrf.disable());如果Controller里还接了Spring Security的权限注解比如PreAuthorize(hasRole(ADMIN))测试时记得用with(user(admin).roles(ADMIN))带上用户上下文否则照样403。这两个配合使用测登录态接口基本就通了。6.4 WebMvcTest启动失败缺Bean用WebMvcTest时频繁出现Parameter X required a bean of type Y that could not be found。原因是WebMvcTest只装载Web层组件Service、Repository、自定义组件默认不装配。解决方法是给缺失的依赖打MockWebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockBean private UserService userService; Test void getById_shouldReturnOk() throws Exception { when(userService.getById(1001L)).thenReturn(...); mockMvc.perform(get(/api/user/detail).param(id, 1001)) .andExpect(status().isOk()); } }MockBean的底层是把一个Mockito Mock对象塞进容器替换掉真实Bean。注意从Spring Boot 3.4开始MockBean被标记为过时新项目可以关注MockitoBean的迁移但老项目继续用也没问题。如果你发现补了Mock还是起不来最大值直接回归SpringBootTest别在一个测试注解上死磕。6.5 手写JSON字符串带来的低级错误最后一个是我不太想提但实在太多人犯的坑手写JSON。明明能用ObjectMapper序列化偏要手拼.content({\username\:\zhangsan\,\age\:25})手拼带来的问题转义符号容易漏、日期格式写成yyyy-MM-dd HH:mm:ss而DTO里是LocalDateTime时反序列化直接失败、字段名大小写搞错导致Controller收到null却测试通过。我自己就吃过一次亏手写JSON少了个逗号JSON变成非法MockMvc返回400我对着代码看了十分钟才发现是拼写问题。从那以后凡是请求体复杂的POST测试一律用ObjectMapper序列化这个习惯能帮你省下一大半排查时间。6.6 测试之间的数据污染Transactional与独立数据准备再补一个容易忽略的坑。如果测试里真调了Service而且Service写库那么用例之间可能互相污染。比如先跑创建用户用例再跑查询用户列表后者断言数量1结果因为前一个用例的数据残留直接变2。解决办法是给测试方法或测试类加TransactionalSpringBootTest AutoConfigureMockMvc Transactional class UserControllerTest { // ... }Spring会在每个测试方法执行后回滚事务相当于给测试套了个后悔药。不过要注意如果被测代码自己开启了新事务尤其REQUIRES_NEW传播级别回滚会失效这种情况就得改用独立的测试数据管理方案比如每次执行前清理表数据。最后一个建议写了这么多其实就想传达一件事MockMvc这套东西门槛不高但能不能用好取决于你对参数绑定和请求转发这两个机制的理解深度。我在实际项目里最后的习惯是所有GET、POST接口都至少覆盖正向全参数、缺必填参数、错误类型参数三条用例POST额外补一条Content-Type错误用例业务返回结构变化时第一个要改的就是jsonPath断言。测试代码和时间都是花给自己的接口回归越省心后面改代码才越敢动手。希望这篇踩坑总结能让你少走点弯路。