ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot MockMvc实战:GET/POST接口测试与参数绑定全解析

Spring Boot MockMvc实战:GET/POST接口测试与参数绑定全解析 1. MockMvc在测什么不启动Tomcat也能走完整个MVC链路1.1 MockMvc的工作边界到底在哪先回答很多人第一次接触MockMvc时的疑问我在浏览器和Postman里已经能调接口了为什么还要在项目里写一套MockMvc测试原因在于Postman验证的是接口当前是否正常而MockMvc验证的是接口以后会不会被改坏。它不需要启动真实的Tomcat容器也不占用端口而是通过MockHttpServletRequest和MockHttpServletResponse把一次HTTP请求直接送到Spring MVC的DispatcherServlet里让Controller、过滤器、拦截器、参数解析、HttpMessageConverter、Valid校验、异常处理全部走一遍。换句话说MockMvc测的是Spring MVC内部处理请求的整条行为链路而不是真实的网络链路。网络超时、Nginx转发、网关路由这些问题它管不了但它能精准地告诉你这个GET接口传这样的参数返回的JSON字段是否符合预期这个POST接口用这样的请求体状态码和数据结构对不对。对做接口回归测试来说这恰好是价值密度最高的部分。基于这个边界MockMvc适合下面几类人正在给Spring Boot项目补测试想把Controller层的参数绑定、校验、响应结果沉淀成自动化用例项目里接口越来越多提PR前想快速验证老接口有没有被改坏面试前想搞清楚MockMvc与真实HTTP请求、与Postman验证之间的本质区别。本文所有示例都围绕GET和POST接口、单个与多个请求参数展开对应的Spring Boot版本我按3.x来写2.x也基本通用遇到版本差异我会单独标注。1.2 最小依赖和测试类骨架先把地基打好要让MockMvc跑起来最省事的做法是引入spring-boot-starter-test它已经聚合了JUnit Jupiter、Spring Test、Jackson、JsonPath、Hamcrest、Mockito等MockMvc测试需要的组件不需要再手动补一堆依赖。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency测试类的基础写法是这样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; SpringBootTest AutoConfigureMockMvc class UserControllerTest { Autowired private MockMvc mockMvc; }SpringBootTest会加载完整的Spring应用上下文AutoConfigureMockMvc负责注入MockMvc实例。这里我多说一句SpringBootTest默认的webEnvironment是MOCK也就是不启动真实服务器只模拟一个Servlet环境。千万不要手动改成RANDOM_PORT或DEFINED_PORT去测MockMvc那等于绕远路没必要。如果你用的是JUnit4还需要在类上加RunWith(SpringRunner.class)JUnit5则不需要SpringBootTest已经集成了SpringExtension的能力。另外有个很容易踩的坑测试类的位置要放在主启动类所在包或者它的子包下。如果测试类放到一个独立的包路径里Spring Boot扫描不到SpringBootConfiguration测试启动会直接报错。项目结构不规范的团队经常遇到这个我建议新建项目时就把controller、service、test的包路径提前规划好。再提一个取舍WebMvcTest只加载Controller层相关的切片不加载Service和数据源配合MockBean可以跑得更快。但它需要额外mock掉所有依赖适合纯Controller单元测试。如果你只是想验证接口的完整行为SpringBootTest AutoConfigureMockMvc这种全上下文方式更省心。两种方案没有谁绝对更好看测试目的。2. GET接口测试单个参数、多个参数与路径参数的三种写法2.1 一个用于演示的UserController为了让后面的测试代码可以直接抄我准备了一个精简版UserController涵盖GET和POST的常见参数形态。真实项目里的Controller会调Service这里我先用接口注入测试时用MockBean打桩避免把文章重心带到数据库和事务上。RestController RequestMapping(/api/users) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } GetMapping(/{id}) public User getUserById(PathVariable Long id) { return userService.getById(id); } GetMapping(/byName) public User getByName(RequestParam(name) String name) { return userService.getByName(name); } GetMapping public PageResultUser queryUsers(UserQuery query) { return userService.queryUsers(query); } GetMapping(/byIds) public ListUser getByIds(RequestParam(ids) ListLong ids) { return userService.getByIds(ids); } PostMapping ResponseStatus(HttpStatus.CREATED) public User createUser(RequestBody Valid User user) { return userService.create(user); } PostMapping(/import) public ListUser importUsers(RequestBody ListUser users) { return userService.batchSave(users); } PostMapping(/form) public User createByForm(UserForm form) { return userService.create(form.toUser()); } }UserService这里不做完整定义你只需要知道它的方法签名和Controller一一对应即可。测试时用MockBean替换成桩方法就可以完全控制返回结果专心测Controller层的参数绑定和响应结构。User类我按常规写Long id、String name、Integer age、String email必须有getter/setter和无参构造器否则Jackson序列化和Spring参数绑定都会出问题。2.2 单参数RequestParam与param()的对应关系先看单个查询参数怎么测。Controller里用RequestParam(name)接收nameMockMvc侧就用param(name, 张三)import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.hamcrest.Matchers.is; Test void getByName_shouldReturnUser() throws Exception { when(userService.getByName(张三)).thenReturn(new User(1L, 张三, 20, zhangexample.com)); mockMvc.perform(get(/api/users/byName) .param(name, 张三)) .andExpect(status().isOk()) .andExpect(jsonPath($.name).value(张三)); }param()这个方法会把参数拼到请求的query string上最终等价于访问/api/users/byName?name张三。它的底层行为是往MockHttpServletRequest的参数Map里放值DispatcherServlet再按照参数名去匹配Controller方法里的RequestParam。这里有一个新手特别容易忽略的细节RequestParam(name)这种写法param()里的key必须和注解里的value一致。如果Controller里写的是RequestParam(userName)测试里传param(name, ...)参数就会变成null接口能走到方法里但拿到的值不对。而如果你用RequestParam String name这种不指名的方式Spring需要依赖编译期保留的parameter name信息有些Maven配置没开-parameters参数时也会绑定失败。最稳妥的做法是Controller侧和测试侧都显式把参数名写清楚不要依赖隐式规则。再来看路径参数。Controller里是GetMapping(/{id})MockMvc的写法是用花括号占位符而不是自己拼字符串Test void getUserById_shouldReturnUser() throws Exception { when(userService.getById(1L)).thenReturn(new User(1L, 张三, 20, zhangexample.com)); mockMvc.perform(get(/api/users/{id}, 1L)) .andExpect(status().isOk()) .andExpect(jsonPath($.id).value(1)) .andExpect(jsonPath($.name).value(张三)); }get(/api/users/{id}, 1L)里的1L会被UriTemplate自动填充到路径中。这样做的好处是避免手写字符串拼接出错还可以让MockMvc对路径变量做类型匹配检查。路径参数看起来简单但它和RequestParam在请求中的位置完全不同测试代码也对应两种风格不要混用。2.3 多参数对象绑定、多个同名参数与List接收接口参数一多很多人就不知道怎么测了。最常见的场景是查询列表条件有page、size、name。在Controller里直接声明一个UserQuery对象接收Spring会把query string里的参数按名称绑定到对象属性上public class UserQuery { private Integer page; private Integer size; private String name; // getter/setter }MockMvc侧的做法就是连续多个param()Test void queryUsers_withMultipleParams_shouldReturnPage() throws Exception { PageResultUser pageResult new PageResult(); pageResult.setContent(Arrays.asList( new User(1L, 张三, 20, zhangexample.com), new User(2L, 李四, 25, liexample.com) )); pageResult.setTotal(2); when(userService.queryUsers(any(UserQuery.class))).thenReturn(pageResult); mockMvc.perform(get(/api/users) .param(page, 0) .param(size, 10) .param(name, 张)) .andExpect(status().isOk()) .andExpect(jsonPath($.content.length()).value(2)) .andExpect(jsonPath($.content[0].name).value(张三)); }这个例子里参数绑定的原理是多个param()最终拼成?page0size10name张Spring的WebDataBinder按属性名反射绑定到UserQuery。所以UserQuery必须有setter方法字段名要和param()的key完全对应。另外如果某个整型字段传了非数字字符串类型转换失败会抛TypeMismatchException接口返回400这在回归测试里是个很有价值的断言点。再一个场景多个同名参数绑到List。比如根据一批ID查询用户GetMapping(/byIds) public ListUser getByIds(RequestParam(ids) ListLong ids) { ... }MockMvc的正确写法是mockMvc.perform(get(/api/users/byIds) .param(ids, 1) .param(ids, 2) .param(ids, 3)) .andExpect(status().isOk()) .andExpect(jsonPath($.length()).value(3));param这个方法签名是param(String name, String... values)它支持同一个name传入多个value最终在Servlet层面形成多个同名query参数Controller侧再用List 接收。这里我要特别提醒有人习惯写成param(ids, 1,2,3)想让Spring按逗号分隔自动转成List。这个行为依赖Spring内置的StringToCollectionConverter确实有可能拆分成功但它把多个值和一个逗号字符串混为一谈当你在做多值参数测试时最符合HTTP协议语义的写法永远是多个同名参数而不是逗号拼接。这个坑在后面的排查实录里我还会详细讲。3. POST接口测试JSON请求体、集合请求体与表单提交3.1 单个对象请求体ObjectMapper与content配合POST接口的核心区别在于请求数据通常在请求体里而不是query string里。最常见的写法是RequestBody接收JSON对象PostMapping ResponseStatus(HttpStatus.CREATED) public User createUser(RequestBody Valid User user) { return userService.create(user); }测试代码Autowired private ObjectMapper objectMapper; Test void createUser_withJsonBody_shouldCreateUser() throws Exception { User newUser new User(null, 王五, 28, wangexample.com); when(userService.create(any(User.class))).thenReturn(newUser); String json objectMapper.writeValueAsString(newUser); mockMvc.perform(post(/api/users) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isCreated()); }这里有一个很容易踩的坑POST接口用RequestBody接收参数但测试里只调param()不写content()。因为RequestBody的数据来源于HTTP body输入流而param()只会往参数Map里塞值body永远是空的。即使接口返回200Controller里收到的User对象也全是null。反过来如果Controller方法没有RequestBody只是普通对象参数你传JSON body它也不会自动绑定。另一个关键点是contentType。RequestBody的数据解析依赖HttpMessageConverter而Converter是根据Content-Type选择消息转换器的。如果你不设置contentTypeMockMvc默认没有这个请求头DispatcherServlet找不到合适的Converter就会抛HttpMediaTypeNotSupportedException最终返回415。我第一次写JSON接口测试时就在这里卡了半小时印象极其深刻。我建议所有POST的JSON请求体都用ObjectMapper序列化而不是手写JSON字符串。原因有两个第一手写字符串很容易漏引号或转义错误犯错率极高第二项目中配置了LocalDateTime格式、字段命名规则等Jackson特性后用ObjectMapper生成的JSON和真实请求完全一致不会出现测试通过但联调挂掉的偏差。如果你在Spring Boot 3.4的项目里看到MockBean标了deprecated不用慌把MockBean换成MockitoBean就行语义完全一样旧写法依然能跑。代码里的any(User.class)来自Mockito的静态导入断言时如果还希望覆盖Bean Validation的校验分支可以构造一个缺字段的对象比如{name:,age:200}预期返回400这也值得单独写一条用例。3.2 多个对象请求体RequestBody List 的序列化当接口需要一次接收一批数据时Controller通常写成PostMapping(/import) public ListUser importUsers(RequestBody ListUser users) { ... }测试时关键一步是把List 序列化成JSON数组Test void importUsers_withJsonArray_shouldReturnImportedList() throws Exception { ListUser users Arrays.asList( new User(null, 赵一, 22, zhaoexample.com), new User(null, 钱二, 30, qianexample.com) ); when(userService.batchSave(anyList())).thenReturn(users); String json objectMapper.writeValueAsString(users); mockMvc.perform(post(/api/users/import) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) .andExpect(jsonPath($.length()).value(2)) .andExpect(jsonPath($[0].name).value(赵一)); }$在JsonPath里代表整个JSON根节点对于JSON数组$.length()就是数组长度$[0].name表示第一个元素的name字段。新增数据这种接口通常会带Valid逐个校验列表元素如果你把某个元素构造为非法数据响应就会带上校验错误信息同样可以用jsonPath断言出来。这里我想多说一下集合反序列化的底层逻辑。RequestBody List 的泛型信息是公开的Jackson可以根据方法签名推断出目标类型是List 然后调用相应的集合解析逻辑。但如果你把参数类型写成RequestBody Object bodyJackson就只能按默认规则反序列化成一个List edhashmap 后续再手动转换类型就会很痛苦。所以接口参数该明确类型就明确类型这对MockMvc测试的断言也有直接好处。3.3 表单请求体别让Content-Type骗了你不是所有POST都走JSON。传统表单提交的场景Controller用普通表单对象接收public class UserForm { private String username; private Integer age; private String email; // getter/setter public User toUser() { return new User(null, this.username, this.age, this.email); } } PostMapping(/form) public User createByForm(UserForm form) { return userService.create(form.toUser()); }表单测试的正确写法是显式设置Content-Type为application/x-www-form-urlencoded然后用param()传表单字段Test void createUser_withFormData_shouldCreateUser() throws Exception { User created new User(3L, 孙七, 35, sunexample.com); when(userService.create(any(User.class))).thenReturn(created); mockMvc.perform(post(/api/users/form) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param(username, 孙七) .param(age, 35)) .andExpect(status().isOk()) .andExpect(jsonPath($.name).value(孙七)); }注意表单字段的key要和UserForm的属性名一致。这里的username字段映射到JavaBean的username属性如果你测试里写param(name, ...)绑定就会失败。另外表单场景下Content-Type决定了Spring如何解析请求体如果你把Content-Type设为application/json但Controller侧又是普通表单对象参数就很容易出现参数绑不上或者直接报错的情况。POST接口的数据位置和Content-Type组合决定了参数能否正确到达方法写测试前先把Controller方法签名看明白能省下大量排查时间。如果你遇到了POST表单多个同名参数的场景比如多选爱好正确的写法是mockMvc.perform(post(/api/users/form) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param(hobbies, 阅读) .param(hobbies, 跑步))这种写法对应Servlet层的getParameterValues()Spring会把它装配成List 。很多初学MockMvc的人习惯把多个值拼成一个字符串传过去那样拿到的是一个包含单一元素阅读,跑步的List后面业务逻辑算错都找不到原因。4. 排查实录POST请求参数为什么进不了Controller4.1 现象测试返回415而不是预期结果有一次我带新人做代码练习他写了一个POST接口用于保存用户Controller签名是标准的RequestBody Valid User。他的MockMvc测试这样写mockMvc.perform(post(/api/users/save) .param(name, 张三) .param(age, 20)) .andExpect(status().isOk());结果测试直接爆红。控制台抛出的异常是HttpMediaTypeNotSupportedExceptionMockMvcResultMatchers给出的是415状态码。他第一反应是接口是不是写错了然后打开Postman手动请求了一遍又确认接口本身没问题。这就出现了典型的Postman能通MockMvc不通的悖论。这个现象在团队里出现过多次根本原因基本都和请求参数的位置有关系。Postman里大家习惯手动切换到Body tab输入JSON自动带上Content-Type但写MockMvc测试时人的大脑还停留在我要传参数这件事上不自觉就用了param()。param()确实把参数传进去了但传的是query parameters而不是request body。4.2 从print()日志中找证据Headers为空Body为null遇到MockMvc断言失败不要急着改代码第一步就是把请求和响应的完整信息打出来。在perform后面加.andDo(print())测试运行时控制台会输出MockHttpServletRequest和MockHttpServletResponse的完整结构。那次新人加了print()之后我们看到的关键信息是这样的MockHttpServletRequest: HTTP Method POST Request URI /api/users/save Parameters {name[张三], age[20]} Headers [] Body null两行信息直接击中问题本质Headers是空的说明没有Content-TypeBody是null说明请求体为空。RequestBody的解析依赖HttpMessageConverter而Converter需要从Content-Type判断用什么解析器Body是null则意味着即使有Converter也没有数据可读。一句话总结这个场景param()只负责填充请求参数Map而RequestBody需要请求体输入流。这两条数据线在Servlet层是完全分开的MockMvc虽然不经过真实网络端口但它的MockHttpServletRequest同样遵守这个行为模型。4.3 修复JSON场景必须contentcontentType组合修复方法就是把param()换成content()并显式声明Content-TypeUser newUser new User(null, 张三, 20, zhangexample.com); String json objectMapper.writeValueAsString(newUser); mockMvc.perform(post(/api/users/save) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) .andExpect(jsonPath($.name).value(张三));改完之后再跑测试通过。这个排查链路可以沉淀成一个判断规则当你准备用MockMvc测POST接口时先看Controller方法签名里有没有RequestBody。有就用content()contentType(APPLICATION_JSON)没有才考虑param()表单绑定。这个规则几乎能覆盖90%的MockMvc参数测试问题。4.4 延伸多个同名参数时代码里到底传成了几个值和上面这个坑并列的是多个请求参数场景里的逗号拼接问题。有位同事在做用户资料接口测试时发现接口返回正常但进入Service后hobbies参数始终只有一段文字前面搭的页面显示也不对。他测试里写的是mockMvc.perform(post(/api/users/hobbies) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param(hobbies, 阅读,跑步))Controller对应参数是RequestParam(hobbies) ListString hobbies趁print()的机会我们注意到MockHttpServletRequest的Parameters输出是hobbies[阅读,跑步]看起来像是一个字符串值。而正确模拟多个同参数值的写法应该是.param(hobbies, 阅读) .param(hobbies, 跑步)print()之后Parameters会显示hobbies[阅读,跑步]注意这里的方括号是Collections.toString的效果代表这个key对应两个valuesController拿到的List是[阅读, 跑步]。这两种写法的区别很细微但结果差异很大。Spring的StringToCollectionConverter确实支持把逗号分隔的字符串拆成集合但它是拆完再包装成List和Servlet协议中真正的多值参数存在语义差。为了测试代码准确反映HTTP请求的真实形态遇到List类型参数一律用多个param()。这一条也建议写进团队的MockMvc测试规范。5. 让MockMvc测试稳定落地回滚、安全、编码与工具分工5.1 测试数据回滚Transactional的正确姿势如果你的测试不是用MockBean打桩而是跑了完整Service链路POST接口每执行一次就会往数据库写一条数据。测试跑完后数据库越来越脏是很多团队的痛点。Spring Test提供了一个很轻量的方案测试类上加Transactional。SpringBootTest AutoConfigureMockMvc Transactional class UserControllerTest { ... }加了Transactional之后Spring会把每个测试方法包在一个事务里测试结束自动回滚数据库保持测试前的状态。这个方案我用了很久基本不用再写繁琐的清理数据逻辑。但它也有一个隐藏坑如果Service方法用了Transactional(propagation Propagation.REQUIRES_NEW)Service内部会新开一个独立事务这个事务不再跟随测试事务回滚。同理Async异步方法执行时可能已经跨了线程回滚也覆盖不到。遇到这种情况要么在测试里手动清理关联数据要么用MockBean把这类方法替换成桩。写完整链路测试前先扫一眼Service层的Transaction注解配置能避免很多测试跑完数据库多了垃圾数据的问题。5.2 项目里加了Spring SecurityMockMvc怎么配Spring Boot项目一旦引入spring-boot-starter-securityMockMvc测试默认会加载安全过滤器链。这时你的GET接口可能直接返回401或302跳到登录页POST接口还可能因为CSRF防护被403拦截。很多团队第一次把MockMvc测试加到已有项目里第一个红色用例就是被安全策略拦下来的。解决思路要看你的测试目标。如果目的是验证业务接口逻辑不想被安全配置干扰有两种选择一是用Spring Security测试支持库给请求加上用户身份和CSRF令牌二是通过AutoConfigureMockMvc(addFilters false)关闭过滤器链。我推荐优先用前一种因为过滤器行为本身就是接口行为的一部分。先在pom里引入依赖dependency groupIdorg.springframework.security/groupId artifactIdspring-security-test/artifactId scopetest/scope /dependency然后配合PostProcessor调用链import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; mockMvc.perform(post(/api/users) .with(csrf()) .with(user(tester).roles(ADMIN)) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isCreated());with(csrf())是给POST请求补充CSRF Tokenwith(user(tester).roles(...))是伪造一个已登录用户。如果你用的是JWT、OAuth2这类自定义安全方案需要另外配置对应的用户解析器。addFilters false这个方案虽然方便但会让测试绕开真实请求链路我建议只把它当临时应急手段不应该灌满整个测试仓库。5.3 中文、日期与类型转换这些边界条件最容易炸MockMvc默认字符集是UTF-8多数情况下中文没有乱码问题但如果你遇到返回JSON里的中文在断言时对不上第一反应应该是检查响应编码。在请求构造器上强制指定编码可以排除干扰mockMvc.perform(post(/api/users/form) .characterEncoding(StandardCharsets.UTF_8) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .param(username, 孙七));日期字段是另一个高频雷区。如果你在测试里自己new了一个ObjectMapper然后序列化一个含有LocalDateTime的User对象很有可能会报InvalidDefinitionException提示Java 8 date/time typejava.time.LocalDateTimenot supported by default。原因是这个ObjectMapper没有注册jackson.datatype.jsr310模块。而如果你直接Autowired Spring Boot管理的ObjectMapper它默认就带了JavaTimeModule并且还会遵循application.yml里的spring.jackson配置。所以我的建议是测试代码里序列化和反序列化对象都用Spring容器里的ObjectMapper不要自己new。类型转换边界也值得专门写用例。比如GET接口里param(age, abc)Spring在把字符串转换为Integer时会失败接口返回400这属于正常的参数校验边界如果你的接口在参数不合法时返回了500就说明Controller缺少必要的容错或全局异常处理。MockMvc很适合把这些边界条件沉淀下来作为接口健壮性的回归基线。5.4 与Postman/curl的分工和一个小工具封装我们团队现在的工作流是接口调试和联调用Postman接口回归靠MockMvc。Postman的优势是即点即用、能直观看到响应体适合开发阶段快速验证MockMvc的优势是随代码走、能进CI、每次mvn test都能跑。两者不是替代关系而是不同阶段的不同工具。MockMvc测试写多了之后你会发现很多POST用例就是对象转JSON、POST、断言。这部分完全可以封装成一个私有方法减少重复代码private ResultActions postJson(String uri, Object body) throws Exception { return mockMvc.perform(post(uri) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(body))); }之后写用例就变成一行postJson(/api/users/import, users) .andExpect(status().isOk()) .andExpect(jsonPath($.length()).value(2));这套封装我用了很久是我觉得MockMvc测试里性价比最高的提效手段。它把最容易写错的contentType和content组合固定住了新人接手代码时也不容易再掉进参数进不了Controller的坑。如果你团队里MockMvc测试的代码量已经很多还可以考虑在此基础上封装断言工具把成功响应和指定code响应的通用结构统一断言这样接口返回结构的演进也会被测试及时感知到。
返回列表