写后端测试这几年,我越来越觉得REST API接口测试是“最容易上手、也最容易写废”的环节。MockMvc在Spring MVC项目里几乎是标配的测试工具,JUnit 5解决了很多JUnit 4时代的痛点,两者配合起来,既能单测Controller逻辑,又能验证路由、参数绑定、JSON序列化这些真实请求链路里的关键环节。如果你正在写接口测试但只停留在“跑通不报错”的阶段,或者被MockMvc的API搞得一头雾水,这篇内容应该能帮你把思路理清楚。这篇文章是JUnit实战系列的第十八章,重点放在“下”半场:REST API接口测试的MockMvc + JUnit 5完整实战。我会从测试环境搭建、GET/POST/PUT/DELETE各种请求的写法,到参数校验、全局异常处理、Mockito隔离外部依赖,再到我实际踩过的坑,逐层拆开讲。适合已经会写基础JUnit 5用例、但想把Controller层测试做得更系统和深入的开发者。1. 测试环境准备:最小依赖还是完整起步依赖?1.1 依赖导入与版本兼容先说依赖。Spring Boot项目里做MockMvc测试,最省事的做法是引入spring-boot-starter-test,这个starter会自动带进来JUnit 5、Mockito、AssertJ、JsonPath等一系列测试库,不用一个个手动加版本号。从Spring Boot 2.2开始,默认的JUnit平台就是JUnit 5,所以如果你的项目还在用JUnit 4跑测试,大概率是pom里显式排掉了junit-jupiter,或者用的是老版本Spring Boot。<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>这里有一个非常重要的细节:spring-boot-starter-test内部是帮你管理了依赖版本的,但你如果要手动引入junit-jupiter相关依赖,一定要保证版本号和Spring Boot对应的版本匹配,否则会出现NoSuchMethodError这类奇怪的问题。我见过一个项目,Spring Boot是2.7,但JUnit手动写了5.7.0,结果跑测试直接报junit.platform.launcher相关的类找不到。这种问题排查起来很费时间,最简单的办法就是只用starter自动管理的版本,别手动覆盖。再补充一点,如果你的项目是Spring Boot 3.x,那底层已经是Jakarta EE 9+了,包名从javax.servlet变成了jakarta.servlet,MockMvc的使用方式本身没变,但如果你在IDE里看到导入javax.servlet.http.HttpServletRequest报错,说明项目已经升级到Jakarta,别把旧包名混进去。1.2 测试基类与MockMvc实例化方式MockMvc有两种常见的装配方式,一种是把整个Spring上下文拉起来,一种是只切Web层。我给你看两种的完整写法。第一种:完整上下文测试(适合做集成测试,连数据库、消息队列一起验证)@SpringBootTest @AutoConfigureMockMvc class BookApiIntegrationTest { @Autowired private MockMvc mockMvc; @Test void contextLoads() { // 如果能跑到这里,说明上下文启动没有问题 } }第二种:只切Web层,Service用Mockito打桩(适合做纯Controller单元测试,速度极快)@WebMvcTest(BookController.class) class BookControllerSliceTest { @Autowired private MockMvc mockMvc; @MockBean private BookService bookService; @Test void shouldReturnBookWhenServiceReturnsData() { // ... } }很多人刚开始会纠结选哪种。我的建议很简单:如果你要测的接口不依赖外部系统,或者你可以用Testcontainers把依赖都准备好,那就用@SpringBootTest,它能验证到真实Bean装配、事务、安全配置这些;如果你只想快速验证Controller层的逻辑分支,比如参数解析、状态码、响应结构,那用@WebMvcTest省下的启动时间非常可观。我实测的项目里,一个中等规模的后端服务(几十个Controller),@SpringBootTest启动上下文大概要8到12秒,@WebMvcTest只需要1到2秒。如果在CI上跑全量测试,这个时间差距就很可观了。另外一个容易被忽略的点是:如果你用了@WebMvcTest,默认不会扫描@Component、@Service、@Repository这些Bean,所以你必须在测试类里把需要的依赖全部通过@MockBean或@Import补齐。Controller里如果注入了RestTemplate、WebClient这类客户端,也是需要手动mock的。1.3 测试基类的抽取设计我在多个项目里都建议建一个测试基类,把公共的MockMvc实例、ObjectMapper实例、通用的工具方法放在里面,每个模块的测试类去继承。这样做的好处是避免每个测试类都重复注入。@SpringBootTest @AutoConfigureMockMvc public abstract class BaseApiTest { @Autowired protected MockMvc mockMvc; @Autowired protected ObjectMapper objectMapper; protected String toJson(Object obj) throws JsonProcessingException { return objectMapper.writeValueAsString(obj); } }子类测试就变得很干净:class BookApiTest extends BaseApiTest { @Test void createBook_shouldReturnCreated() throws Exception { String json = toJson(new CreateBookRequest("Spring实战", "Marchi")); mockMvc.perform(post("/api/books") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isCreated()); } }基类里我特别推荐放一个objectMapper,因为写请求体时会频繁用到,每次手动new都会造成Jackson配置不一致,比如LocalDateTime的序列化格式可能和项目里不同,导致测试请求体和生产环境的格式对不上。最后定位半天才发现是ObjectMapper配置不一致,这种坑我踩过很多次。2. 第一个MockMvc测试:GET请求的三种常见玩法2.1 状态码、响应头、JSON体三层验证MockMvc和真实HTTP请求的差异在于:它不经过真实的Socket端口,而是在内部模拟请求分发到DispatcherServlet执行。所以你不能用它来验证网络层的问题,但它足够验证从路由匹配到Controller返回值这一整条链路。最基础的GET请求测试长这样:mockMvc.perform(get("/api/books/1")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.title").value("深入理解REST架构")) .andExpect(jsonPath("$.author").value("Marchi"));我把验证拆成三层来讲:第一层,status().isOk()验证HTTP状态码。这里要注意,isOk()对应200,isCreated()对应201,isNoContent()对应204。如果你的接口返回202 Accepted,那就得用status().isAccepted(),别用isOk()糊弄过去,否则测试对状态码的约束就失效了。第二层,content().contentType()验证响应头里的Content-Type。这个经常被忽略,但很有用。比如一个接口应该返回application/json,结果因为配置问题返回了text/plain,客户端会解析失败。加上这一层断言能提前发现响应头配置错误。第三层,jsonPath()验证JSON主体。这是MockMvc最强大的地方,它通过JsonPath表达式从响应体里提取数据,然后和你期望的值比对。2.2 jsonPath的常用表达式与动态字段处理JsonPath表达式刚开始接触会有点懵,其实就是类似XPath之于XML。我常用的有这些:// 取普通字段 jsonPath("$.title").value("某本书") // 取嵌套字段 jsonPath("$.author.name").value("Marchi") // 取数组长度 jsonPath("$.tags.length()").value(3) // 取数组中的元素 jsonPath("$.tags[0]").value("java") // 判断字段是否存在 jsonPath("$.id").exists() // 判断字段类型 jsonPath("$.price").isNumber() jsonPath("$.title").isString()关于动态字段的验证,比如创建接口返回的ID是数据库自增的,你再怎么精确预测,每次值都可能变。这时候就不要用value()去断言具体ID,用exists()只验证它存在,然后再单独查询一次确认数据真的落库了,这样测试既有约束力又不会因为ID变化而频繁失败。还有一类场景是分页接口,返回结构是{ "content": [], "totalPages": 2 }。你可以这样断言:jsonPath("$.content").isArray() jsonPath("$.content.length()").value(10) jsonPath("$.totalPages").value(2) jsonPath("$.totalElements").value(20)2.3 请求参数与路径变量的传参方式GET接口通常有两种传参方式,MockMvc对应也有两种写法。路径变量风格:mockMvc.perform(get("/api/books/{id}", 1L)) .andExpect(status().isOk());这种方式要注意,{id}是URI模板占位符,后面的参数会自动进行字符串转换。如果传入的值带特殊字符,比如/api/books/abc/def,那路径就乱了。所以占位符的值最好预先做URL编码。查询参数风格:mockMvc.perform(get("/api/books") .param("page", "0") .param("size", "10") .param("sort", "title,asc")) .andExpect(status().isOk());.param()方法很贴心,它会自动处理URL编码,你不用关心?和&的拼接。多个同名的参数也可以重复调用.param("tag", "java").param("tag", "spring"),模拟出来的效果和使用方在浏览器地址栏输入?tag=java&tag=spring是一样的。这一点对测试多值筛选接口特别有用。还有一个细节:参数里如果传的是数字,MockMvc默认会转成String再绑定到Controller的@RequestParam Integer page,一般情况下没问题。但如果你加了@Validated并且对参数做了@Min、@Max校验,要刻意测一下越界值,比如page=-1应该返回400,这样是对参数约束的一种回归保护。3. POST/PUT/DELETE:带请求体和鉴权的接口怎么测3.1 ObjectMapper序列化与@RequestBody验证POST接口和GET最大的区别在于请求体。我习惯把所有请求参数封装成独立的DTO,测试时用ObjectMapper把DTO序列化成JSON字符串,再放到请求体里。@Test void createBook_shouldReturn201() throws Exception { CreateBookRequest req = new CreateBookRequest("Spring实战", "Marchi", 59.0); mockMvc.perform(post("/api/books") .contentType(MediaType.APPLICATION_JSON) .content(toJson(req))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").exists()) .andExpect(jsonPath("$.title").value("Spring实战")); }这里有一个很关键的陷阱:contentType必须设置为APPLICATION_JSON,否则Spring MVC的@RequestBody参数解析可能不会触发,接口直接返回415 Unsupported Media Type。这个错误我见过太多人犯了,一看到415就懵,其实原因就是contentType没设置或者写成了MediaType.TEXT_PLAIN。另一个陷阱是ObjectMapper对日期和时间的序列化。如果项目里的LocalDateTime字段使用了@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")这种注解,你在测试里new一个ObjectMapper是感知不到这些注解的,序列化出来的格式可能完全不对。所以我才在前面强调,测试基类里一定要注入Spring容器里的ObjectMapper,它已经配置了项目里所有自定义的序列化器和注解。3.2 请求头与鉴权Token的注入很多接口有登录鉴权,测试时需要带上token。这时候用.header()方法直接设置请求头,简单粗暴:mockMvc.perform(get("/api/books/mine") .header(HttpHeaders.AUTHORIZATION, "Bearer test-token-12345")) .andExpect(status().isOk());在实际项目中,我更推荐把这些公共请求头封装成工具方法,避免每个测试都写一大串重复代码。比如在基类里加一个方法:protected RequestBuilder authorizedGet(String url, String token) { return get(url).header(HttpHeaders.AUTHORIZATION, "Bearer " + token); }但这里需要提醒一下:如果你启用了Spring Security,并且有自定义的OncePerRequestFilter解析token,那MockMvc测试跑的是完整的安全链路,token必须是真实可被解析的格式。有些项目会把token解析逻辑包装成返回用户信息的过滤器,这种情况下光是mock一个token字符串是不够的,如果SecurityContext没有认证,接口会返回401。两种解决思路:第一种是测试时放行安全配置,通过@WithMockUser来指定当前用户;第二种是在测试里构造一个真正的token。@WithMockUser用起来最方便:@Test @WithMockUser(username = "marchi", roles = "ADMIN") void deleteBook_shouldReturn204() throws Exception { mockMvc.perform(delete("/api/books/1")) .andExpect(status().isNoContent()); }但要注意,@WithMockUser设置的是Spring Security上下文里的认证信息,它并不会自动把用户信息注入到你的Controller参数里。如果你的Controller通过@AuthenticationPrincipal UserDetails user这种方式获取当前用户,那@WithMockUser的默认值是可以匹配的。3.3 嵌套JSON与集合类型的响应断言REST API返回嵌套JSON很常见,比如一个Book对象里有作者信息,作者里又有出版社信息。用JsonPath逐层取就非常方便:jsonPath("$.author.publisher.name").value("某出版社")当然也可以取整个嵌套对象返回Map,再通过equalTo进行比较。不过我更推荐JsonPath分字段断言,因为一旦整个嵌套对象序列化顺序或多余字段有变化,equalTo比对特别容易碎,分字段断言更稳定。集合类型和响应包装也是非常常见的场景。很多公司的接口规范都会定义统一的响应包装类,比如Result<T>、ApiResponse<T>之类。测试时不要只验证$.code和$.message,更要深入到$.data里的具体内容:mockMvc.perform(get("/api/books?size=2")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.content[0].title").value("Spring实战")) .andExpect(jsonPath("$.data.content[1].title").value("深入理解REST架构"));这里我要强调一个容易误判的点:Controller返回的如果是ResponseEntity<Result<List<Book>>>,MockMvc的JsonPath一样可以逐层穿透。你不需要在测试里去反序列化整个响应体,JsonPath天然就是为了做这类断言设计的。但如果你需要做更复杂的断言,比如比较整个列表的内容,ObjectMapper.readValue(...)抓取响应体转对象也是个办法。4. 参数校验与全局异常处理:错误路径也要覆盖4.1 @Valid触发校验失败时的响应断言接口测试只测正常路径是不够的,异常和边界路径恰恰是容易出Bug的地方。Spring MVC里参数校验最常见的写法是Controller方法参数上加@Valid,DTO字段上加各种校验注解。@PostMapping("/api/books") public ResponseEntity<Book> createBook(@Valid @RequestBody CreateBookRequest req) { // ... }测试时故意传一个非法请求体:CreateBookRequest invalidReq = new CreateBookRequest("", "Marchi", -10.0); mockMvc.perform(post("/api/books") .contentType(MediaType.APPLICATION_JSON) .content(toJson(invalidReq))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").exists());这里要注意,@Valid校验失败后Spring默认返回400 Bad Request,错误响应体结构是的DefaultHandlerExceptionResolver或ResponseEntityExceptionHandler决定的。如果你的项目里有全局异常处理器@RestControllerAdvice,那错误响应的格式可能完全不一样。要确认你使用的框架版本下校验失败时具体返回哪些字段,不要想当然以为$.message一定存在——如果全局异常处理器把错误信息放在$.error或$.errors[0].message里,你的断言就会失败。4.2 自定义异常和ControllerAdvice的测试衔接很多项目的错误处理会统一交到@RestControllerAdvice,比如抛出BusinessException时返回特定的错误码和HTTP状态码。测试这类逻辑时,我不建议真的在测试里制造业务异常,而是通过Mockito让Service层抛出异常,再验证Controller的响应:@Test void updateBook_whenBookNotFound_shouldReturn404() throws Exception { when(bookService.updateBook(eq(100L), any(UpdateBookRequest.class))) .thenThrow(new BookNotFoundException("book not found")); mockMvc.perform(put("/api/books/100") .contentType(MediaType.APPLICATION_JSON) .content(toJson(updateReq))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("BOOK_NOT_FOUND")); }这个测试验证的是从Controller抛出的异常到@RestControllerAdvice兜底处理这整条链路是否正常工作。如果全局异常处理器里忘记了对BookNotFoundException的映射,这个测试会直接暴露出来,非常实用。4.3 404、405、500等状态码模拟MockMvc不仅可以走Controller逻辑,还能模拟路由匹配不到的情况。当你请求一个不存在路径时:mockMvc.perform(get("/api/nonexistent")) .andExpect(status().isNotFound());如果路径拼错了但对上了别名,或者请求方法不对,就可能返回405。比如接口只支持POST,你用GET去请求:mockMvc.perform(get("/api/books")) .andExpect(status().isMethodNotAllowed()); // 405这种测试能发现一个实际问题:是否有人在设计接口时一个URL对应了多个不同的HTTP方法,而框架配置没有跟上。还有一个有意思的场景是500状态码的模拟。如果你想让接口真的抛一个未捕获的RuntimeException,最好克隆一个专门用来测试异常的Service实现,或者用Mockito的doThrow。注意不要为了制造500而在生产代码里埋雷,测试完就有意保留。我自己写错误路径测试的时候有一个原则:对4xx的断言尽量具体,对5xx的断言保持结构层面即可,不要断言过于具体的错误文案,因为异常文案很容易改动,但状态码和错误码通常是稳定的接口契约。5. MockMvc + Mockito:把外部服务隔离掉的测试方案5.1 对Service层打桩,controller测业务编排当Controller已经相对成熟,重点关注的是接口层的逻辑时,用Mockito打桩Service层,配合@WebMvcTest做Controller切片测试,是我最常用的一套组合。假设BookController依赖BookService:@WebMvcTest(BookController.class) class BookControllerSliceTest { @Autowired private MockMvc mockMvc; @MockBean private BookService bookService; @Test void getBook_shouldReturnBook() throws Exception { Book book = new Book(1L, "Spring实战", "Marchi", 59.0); when(bookService.findById(1L)).thenReturn(book); mockMvc.perform(get("/api/books/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.title").value("Spring实战")); } }这里有个必须要提醒的点:@MockBean是Spring Boot测试提供的注解,它会向ApplicationContext里注册一个mock版本的Bean,替代原有的真实Bean。所以在切片测试里,你打桩的是Service接口,Controller里注入的就是这个mock实例。如果你没有对某个依赖方法打桩,Mockito默认返回空值(null、空集合等),Controller里如果对这些空值做了不必要的处理,测试会马上暴露出来。5.2 verify验证调用次数,防止无效刷新除了打桩返回值,Mockito的verify经常用来验证交互行为。比如一个刷新接口,如果某个参数为空,刷新的方法就不应该被调用:@Test void refreshCache_whenParamMissing_shouldNotCallService() throws Exception { mockMvc.perform(post("/api/cache/refresh") .param("force", "false")) .andExpect(status().isAccepted()); verify(cacheService, never()).forceRefresh(); }verify还能用来确认调用参数是否符合预期:verify(bookService, times(1)).findById(1L);有时候我在测试结束前加一个verifyNoMoreInteractions来强制校验没有其他多余调用,这可以避免Controller悄悄多调了一个Service方法而没人发现。不过这个用法要克制,否则接口一增加新逻辑测试就碎,维护成本会直线上升。5.3 避免过度mock的实践建议Mockito虽好用,但过度mock会导致测试失去意义。我见过有人把REST API的测试做成了“全链路打桩”,连自己内部Service方法里简单的数据处理都mock掉,最后测试只证明了“Mockito会按你设置的方式返回”,整个测试几乎没有验证价值。我的实践原则是三层:第一,如果测试目标是Controller层的参数绑定、序列化、路由、异常处理,就mock掉Service层,专注验证HTTP契约。第二,如果测试目标是核心业务逻辑,就别用MockMvc了,直接写纯JUnit测试调用Service方法,用内存里的Repository实现或者真实数据库(Testcontainers),确保业务代码真正被执行。第三,如果测试目标是跨服务的流程编排,那用@SpringBootTest,把模块内部依赖都装配成真的,只mock外部RPC或者消息队列客户端。这三种测试分层的边界有时候会被混淆,但只要你明确当前测试“要守护什么”,mock的选择自然就清晰了。6. 排查实录:我在MockMvc测试中踩过的坑6.1NoSuchMethodError与版本冲突的定位思路MockMvc + JUnit 5的版本冲突问题非常经典。现象是运行时抛java.lang.NoSuchMethodError,或者java.lang.NoClassDefFoundError。原因多半是Spring框架版本和Spring Boot版本不一致,或者spring-test的版本和spring-webmvc对不上。我的排查思路是这样的:先看spring-boot-starter-parent定义的版本,再执行mvn dependency:tree检查实际落地的依赖版本。常见的一个坑是项目里自己引了老版本的spring-web,导致MockHttpServletRequest或MockHttpServletResponse里缺失某些方法。6.2 jsonPath取值一直失败JsonPath报错最常见的原因是路径写错,比如响应体里字段名是first_name,你写成firstName;或者字段是data而不是data.list。我的经验是调试时先别加太细的jsonPath断言,先用简单的断言打印出响应体内容:mockMvc.perform(get("/api/books")) .andDo(print());andDo(print())会把整个请求和响应体打印出来,一眼就能看到实际返回的JSON结构,再对着结构写JsonPath表达式就清晰了。另外,有些接口返回的JSON里有$符号或者@符号的字段名,比如$ref,JsonPath会把$当作根路径处理,直接取会报错。必须用jsonPath("$['$ref']")这种写法转义。6.3 MockMvc请求返回404、406、415状态码这三个状态码在MockMvc测试里是最常出现的,我把它们的经典场景和排查方向列成一个速查表:状态码典型场景排查方向404路径不存在或Controller方法未匹配检查URL是否拼错、检查@RequestMapping的路径是否一致、检查测试类是否扫描到了Controller406Content-Type或Accept头不匹配检查.accept()设置的值,以及@ResponseBody序列化结果是否和Accept匹配415请求体格式和@RequestBody不匹配检查.contentType()是否设置为APPLICATION_JSON,以及JSON格式是否合法关于404还有一个坑,就是@WebMvcTest的场景。如果你用@WebMvcTest(BookController.class),但Controller内部通过@RequestMapping直接返回视图名而不是@ResponseBody,就可能出现404。Controller如果在类上加了@Controller而没有加@ResponseBody,方法的返回值会被当作视图名解析,测试接口路径时就会得到404或HttpMessageNotWritableException。解决办法是确保Controller返回的是ResponseEntity,或者类上加了@RestController。6.4 中文乱码和字符编码问题中文乱码在MockMvc测试里也经常遇到,但很多时候不是测试代码的问题,而是接口的Content-Type没有包含charset=UTF-8。如果接口返回的JSON是中文,但响应头的Content-Type是application/json而没有charset,MockMvc在解析响应体时可能按ISO-8859-1来处理,导致中文乱码。一个临时验证方法:在断言前用andDo(print())看打印出来的响应体会不会是乱码。解决方法是让接口侧的MappingJackson2HttpMessageConverter正确配置UTF-8,或者在测试断言时指定字符集:.andExpect(content().encoding("UTF-8"))在实际项目中,我会在基类里为所有测试统一配置characterEncoding,防止每个接口测试都重复处理中文编码问题。6.5 测试数据残留导致断言失败MockMvc测试如果连了真实数据库(比如H2或者Testcontainers),非常容易出现测试数据残留导致互相影响的问题。比如第一个测试插入了一个id=1的书,第二个测试查询时按id=1能查到,但后续测试断言数量时把之前的数据也算进去了,导致总数对不上。我长期实践下来的方案是:每个测试方法都开启事务并回滚,或者至少在每个测试之前清理相关数据。Spring的@Transactional用在测试方法上,默认情况下测试结束会回滚,这样测试之间数据不会互相污染。@SpringBootTest @AutoConfigureMockMvc @Transactional class BookApiIntegrationTest { // ... }但要注意,使用@Transactional回滚的前提是测试过程中Controller和Service层的数据库操作都在同一个事务里。如果项目中用了REQUIRES_NEW传播行为,或者异步线程里操作数据库,回滚就失效了。这种情况下就得用数据清理策略。关于这个话题,我在实际项目里还有一个常用的技巧:在测试基类里统一维护一套构造测试数据的工具方法,比如createBookInDb(...),然后在每个测试里用独立的ID范围或数据特征去隔离,断言时也只关心自己创建的数据。最后分享一点测试重构的经验MockMvc测试如果不好好控制粒度,项目一大会变得很脆弱。我见过最多的失败原因不是代码逻辑错了,而是测试断言写得过于具体,比如精确断言某个自增ID的具体值,或者断言一个可能因为配置变化而改变的日期格式。所以我的一个原则是:对核心契约(状态码、业务字段、关键业务值)做严格断言,对易变信息(自增ID、服务器时间、分页总数)做存在性和类型断言。另外,测试命名也值得投入一点精力。别用test1、test2这种名字,我用的是方法名_场景_预期结果的格式,比如createBook_titleTooLong_shouldReturn400。这样的命名在测试报告里一目了然,别人接手你的代码时也能快速知道这个测试在守护什么行为。REST API的MockMvc + JUnit 5测试说到底是一件需要不断打磨的事。环境配置、请求构造、响应断言,每一层都有细节。等你把前面这些点都跑通了,再回头看那些让新人抓狂的415、406、404,基本就是一眼定位,不会再被它们卡住。
写后端测试这几年,我越来越觉得REST API接口测试是“最容易上手、也最容易写废”的环节。MockMvc在Spring MVC项目里几乎是标配的测试工具,JUnit 5解决了很多JUnit 4时代的痛点,两者配合起来,既能单测Controller逻辑,又能验证路由、参数绑定、JSON序列化这些真实请求链路里的关键环节。如果你正在写接口测试但只停留在“跑通不报错”的阶段,或者被MockMvc的API搞得一头雾水,这篇内容应该能帮你把思路理清楚。这篇文章是JUnit实战系列的第十八章,重点放在“下”半场:REST API接口测试的MockMvc + JUnit 5完整实战。我会从测试环境搭建、GET/POST/PUT/DELETE各种请求的写法,到参数校验、全局异常处理、Mockito隔离外部依赖,再到我实际踩过的坑,逐层拆开讲。适合已经会写基础JUnit 5用例、但想把Controller层测试做得更系统和深入的开发者。1. 测试环境准备:最小依赖还是完整起步依赖?1.1 依赖导入与版本兼容先说依赖。Spring Boot项目里做MockMvc测试,最省事的做法是引入spring-boot-starter-test,这个starter会自动带进来JUnit 5、Mockito、AssertJ、JsonPath等一系列测试库,不用一个个手动加版本号。从Spring Boot 2.2开始,默认的JUnit平台就是JUnit 5,所以如果你的项目还在用JUnit 4跑测试,大概率是pom里显式排掉了junit-jupiter,或者用的是老版本Spring Boot。<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>这里有一个非常重要的细节:spring-boot-starter-test内部是帮你管理了依赖版本的,但你如果要手动引入junit-jupiter相关依赖,一定要保证版本号和Spring Boot对应的版本匹配,否则会出现NoSuchMethodError这类奇怪的问题。我见过一个项目,Spring Boot是2.7,但JUnit手动写了5.7.0,结果跑测试直接报junit.platform.launcher相关的类找不到。这种问题排查起来很费时间,最简单的办法就是只用starter自动管理的版本,别手动覆盖。再补充一点,如果你的项目是Spring Boot 3.x,那底层已经是Jakarta EE 9+了,包名从javax.servlet变成了jakarta.servlet,MockMvc的使用方式本身没变,但如果你在IDE里看到导入javax.servlet.http.HttpServletRequest报错,说明项目已经升级到Jakarta,别把旧包名混进去。1.2 测试基类与MockMvc实例化方式MockMvc有两种常见的装配方式,一种是把整个Spring上下文拉起来,一种是只切Web层。我给你看两种的完整写法。第一种:完整上下文测试(适合做集成测试,连数据库、消息队列一起验证)@SpringBootTest @AutoConfigureMockMvc class BookApiIntegrationTest { @Autowired private MockMvc mockMvc; @Test void contextLoads() { // 如果能跑到这里,说明上下文启动没有问题 } }第二种:只切Web层,Service用Mockito打桩(适合做纯Controller单元测试,速度极快)@WebMvcTest(BookController.class) class BookControllerSliceTest { @Autowired private MockMvc mockMvc; @MockBean private BookService bookService; @Test void shouldReturnBookWhenServiceReturnsData() { // ... } }很多人刚开始会纠结选哪种。我的建议很简单:如果你要测的接口不依赖外部系统,或者你可以用Testcontainers把依赖都准备好,那就用@SpringBootTest,它能验证到真实Bean装配、事务、安全配置这些;如果你只想快速验证Controller层的逻辑分支,比如参数解析、状态码、响应结构,那用@WebMvcTest省下的启动时间非常可观。我实测的项目里,一个中等规模的后端服务(几十个Controller),@SpringBootTest启动上下文大概要8到12秒,@WebMvcTest只需要1到2秒。如果在CI上跑全量测试,这个时间差距就很可观了。另外一个容易被忽略的点是:如果你用了@WebMvcTest,默认不会扫描@Component、@Service、@Repository这些Bean,所以你必须在测试类里把需要的依赖全部通过@MockBean或@Import补齐。Controller里如果注入了RestTemplate、WebClient这类客户端,也是需要手动mock的。1.3 测试基类的抽取设计我在多个项目里都建议建一个测试基类,把公共的MockMvc实例、ObjectMapper实例、通用的工具方法放在里面,每个模块的测试类去继承。这样做的好处是避免每个测试类都重复注入。@SpringBootTest @AutoConfigureMockMvc public abstract class BaseApiTest { @Autowired protected MockMvc mockMvc; @Autowired protected ObjectMapper objectMapper; protected String toJson(Object obj) throws JsonProcessingException { return objectMapper.writeValueAsString(obj); } }子类测试就变得很干净:class BookApiTest extends BaseApiTest { @Test void createBook_shouldReturnCreated() throws Exception { String json = toJson(new CreateBookRequest("Spring实战", "Marchi")); mockMvc.perform(post("/api/books") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isCreated()); } }基类里我特别推荐放一个objectMapper,因为写请求体时会频繁用到,每次手动new都会造成Jackson配置不一致,比如LocalDateTime的序列化格式可能和项目里不同,导致测试请求体和生产环境的格式对不上。最后定位半天才发现是ObjectMapper配置不一致,这种坑我踩过很多次。2. 第一个MockMvc测试:GET请求的三种常见玩法2.1 状态码、响应头、JSON体三层验证MockMvc和真实HTTP请求的差异在于:它不经过真实的Socket端口,而是在内部模拟请求分发到DispatcherServlet执行。所以你不能用它来验证网络层的问题,但它足够验证从路由匹配到Controller返回值这一整条链路。最基础的GET请求测试长这样:mockMvc.perform(get("/api/books/1")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.title").value("深入理解REST架构")) .andExpect(jsonPath("$.author").value("Marchi"));我把验证拆成三层来讲:第一层,status().isOk()验证HTTP状态码。这里要注意,isOk()对应200,isCreated()对应201,isNoContent()对应204。如果你的接口返回202 Accepted,那就得用status().isAccepted(),别用isOk()糊弄过去,否则测试对状态码的约束就失效了。第二层,content().contentType()验证响应头里的Content-Type。这个经常被忽略,但很有用。比如一个接口应该返回application/json,结果因为配置问题返回了text/plain,客户端会解析失败。加上这一层断言能提前发现响应头配置错误。第三层,jsonPath()验证JSON主体。这是MockMvc最强大的地方,它通过JsonPath表达式从响应体里提取数据,然后和你期望的值比对。2.2 jsonPath的常用表达式与动态字段处理JsonPath表达式刚开始接触会有点懵,其实就是类似XPath之于XML。我常用的有这些:// 取普通字段 jsonPath("$.title").value("某本书") // 取嵌套字段 jsonPath("$.author.name").value("Marchi") // 取数组长度 jsonPath("$.tags.length()").value(3) // 取数组中的元素 jsonPath("$.tags[0]").value("java") // 判断字段是否存在 jsonPath("$.id").exists() // 判断字段类型 jsonPath("$.price").isNumber() jsonPath("$.title").isString()关于动态字段的验证,比如创建接口返回的ID是数据库自增的,你再怎么精确预测,每次值都可能变。这时候就不要用value()去断言具体ID,用exists()只验证它存在,然后再单独查询一次确认数据真的落库了,这样测试既有约束力又不会因为ID变化而频繁失败。还有一类场景是分页接口,返回结构是{ "content": [], "totalPages": 2 }。你可以这样断言:jsonPath("$.content").isArray() jsonPath("$.content.length()").value(10) jsonPath("$.totalPages").value(2) jsonPath("$.totalElements").value(20)2.3 请求参数与路径变量的传参方式GET接口通常有两种传参方式,MockMvc对应也有两种写法。路径变量风格:mockMvc.perform(get("/api/books/{id}", 1L)) .andExpect(status().isOk());这种方式要注意,{id}是URI模板占位符,后面的参数会自动进行字符串转换。如果传入的值带特殊字符,比如/api/books/abc/def,那路径就乱了。所以占位符的值最好预先做URL编码。查询参数风格:mockMvc.perform(get("/api/books") .param("page", "0") .param("size", "10") .param("sort", "title,asc")) .andExpect(status().isOk());.param()方法很贴心,它会自动处理URL编码,你不用关心?和&的拼接。多个同名的参数也可以重复调用.param("tag", "java").param("tag", "spring"),模拟出来的效果和使用方在浏览器地址栏输入?tag=java&tag=spring是一样的。这一点对测试多值筛选接口特别有用。还有一个细节:参数里如果传的是数字,MockMvc默认会转成String再绑定到Controller的@RequestParam Integer page,一般情况下没问题。但如果你加了@Validated并且对参数做了@Min、@Max校验,要刻意测一下越界值,比如page=-1应该返回400,这样是对参数约束的一种回归保护。3. POST/PUT/DELETE:带请求体和鉴权的接口怎么测3.1 ObjectMapper序列化与@RequestBody验证POST接口和GET最大的区别在于请求体。我习惯把所有请求参数封装成独立的DTO,测试时用ObjectMapper把DTO序列化成JSON字符串,再放到请求体里。@Test void createBook_shouldReturn201() throws Exception { CreateBookRequest req = new CreateBookRequest("Spring实战", "Marchi", 59.0); mockMvc.perform(post("/api/books") .contentType(MediaType.APPLICATION_JSON) .content(toJson(req))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").exists()) .andExpect(jsonPath("$.title").value("Spring实战")); }这里有一个很关键的陷阱:contentType必须设置为APPLICATION_JSON,否则Spring MVC的@RequestBody参数解析可能不会触发,接口直接返回415 Unsupported Media Type。这个错误我见过太多人犯了,一看到415就懵,其实原因就是contentType没设置或者写成了MediaType.TEXT_PLAIN。另一个陷阱是ObjectMapper对日期和时间的序列化。如果项目里的LocalDateTime字段使用了@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")这种注解,你在测试里new一个ObjectMapper是感知不到这些注解的,序列化出来的格式可能完全不对。所以我才在前面强调,测试基类里一定要注入Spring容器里的ObjectMapper,它已经配置了项目里所有自定义的序列化器和注解。3.2 请求头与鉴权Token的注入很多接口有登录鉴权,测试时需要带上token。这时候用.header()方法直接设置请求头,简单粗暴:mockMvc.perform(get("/api/books/mine") .header(HttpHeaders.AUTHORIZATION, "Bearer test-token-12345")) .andExpect(status().isOk());在实际项目中,我更推荐把这些公共请求头封装成工具方法,避免每个测试都写一大串重复代码。比如在基类里加一个方法:protected RequestBuilder authorizedGet(String url, String token) { return get(url).header(HttpHeaders.AUTHORIZATION, "Bearer " + token); }但这里需要提醒一下:如果你启用了Spring Security,并且有自定义的OncePerRequestFilter解析token,那MockMvc测试跑的是完整的安全链路,token必须是真实可被解析的格式。有些项目会把token解析逻辑包装成返回用户信息的过滤器,这种情况下光是mock一个token字符串是不够的,如果SecurityContext没有认证,接口会返回401。两种解决思路:第一种是测试时放行安全配置,通过@WithMockUser来指定当前用户;第二种是在测试里构造一个真正的token。@WithMockUser用起来最方便:@Test @WithMockUser(username = "marchi", roles = "ADMIN") void deleteBook_shouldReturn204() throws Exception { mockMvc.perform(delete("/api/books/1")) .andExpect(status().isNoContent()); }但要注意,@WithMockUser设置的是Spring Security上下文里的认证信息,它并不会自动把用户信息注入到你的Controller参数里。如果你的Controller通过@AuthenticationPrincipal UserDetails user这种方式获取当前用户,那@WithMockUser的默认值是可以匹配的。3.3 嵌套JSON与集合类型的响应断言REST API返回嵌套JSON很常见,比如一个Book对象里有作者信息,作者里又有出版社信息。用JsonPath逐层取就非常方便:jsonPath("$.author.publisher.name").value("某出版社")当然也可以取整个嵌套对象返回Map,再通过equalTo进行比较。不过我更推荐JsonPath分字段断言,因为一旦整个嵌套对象序列化顺序或多余字段有变化,equalTo比对特别容易碎,分字段断言更稳定。集合类型和响应包装也是非常常见的场景。很多公司的接口规范都会定义统一的响应包装类,比如Result<T>、ApiResponse<T>之类。测试时不要只验证$.code和$.message,更要深入到$.data里的具体内容:mockMvc.perform(get("/api/books?size=2")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.content[0].title").value("Spring实战")) .andExpect(jsonPath("$.data.content[1].title").value("深入理解REST架构"));这里我要强调一个容易误判的点:Controller返回的如果是ResponseEntity<Result<List<Book>>>,MockMvc的JsonPath一样可以逐层穿透。你不需要在测试里去反序列化整个响应体,JsonPath天然就是为了做这类断言设计的。但如果你需要做更复杂的断言,比如比较整个列表的内容,ObjectMapper.readValue(...)抓取响应体转对象也是个办法。4. 参数校验与全局异常处理:错误路径也要覆盖4.1 @Valid触发校验失败时的响应断言接口测试只测正常路径是不够的,异常和边界路径恰恰是容易出Bug的地方。Spring MVC里参数校验最常见的写法是Controller方法参数上加@Valid,DTO字段上加各种校验注解。@PostMapping("/api/books") public ResponseEntity<Book> createBook(@Valid @RequestBody CreateBookRequest req) { // ... }测试时故意传一个非法请求体:CreateBookRequest invalidReq = new CreateBookRequest("", "Marchi", -10.0); mockMvc.perform(post("/api/books") .contentType(MediaType.APPLICATION_JSON) .content(toJson(invalidReq))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").exists());这里要注意,@Valid校验失败后Spring默认返回400 Bad Request,错误响应体结构是的DefaultHandlerExceptionResolver或ResponseEntityExceptionHandler决定的。如果你的项目里有全局异常处理器@RestControllerAdvice,那错误响应的格式可能完全不一样。要确认你使用的框架版本下校验失败时具体返回哪些字段,不要想当然以为$.message一定存在——如果全局异常处理器把错误信息放在$.error或$.errors[0].message里,你的断言就会失败。4.2 自定义异常和ControllerAdvice的测试衔接很多项目的错误处理会统一交到@RestControllerAdvice,比如抛出BusinessException时返回特定的错误码和HTTP状态码。测试这类逻辑时,我不建议真的在测试里制造业务异常,而是通过Mockito让Service层抛出异常,再验证Controller的响应:@Test void updateBook_whenBookNotFound_shouldReturn404() throws Exception { when(bookService.updateBook(eq(100L), any(UpdateBookRequest.class))) .thenThrow(new BookNotFoundException("book not found")); mockMvc.perform(put("/api/books/100") .contentType(MediaType.APPLICATION_JSON) .content(toJson(updateReq))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("BOOK_NOT_FOUND")); }这个测试验证的是从Controller抛出的异常到@RestControllerAdvice兜底处理这整条链路是否正常工作。如果全局异常处理器里忘记了对BookNotFoundException的映射,这个测试会直接暴露出来,非常实用。4.3 404、405、500等状态码模拟MockMvc不仅可以走Controller逻辑,还能模拟路由匹配不到的情况。当你请求一个不存在路径时:mockMvc.perform(get("/api/nonexistent")) .andExpect(status().isNotFound());如果路径拼错了但对上了别名,或者请求方法不对,就可能返回405。比如接口只支持POST,你用GET去请求:mockMvc.perform(get("/api/books")) .andExpect(status().isMethodNotAllowed()); // 405这种测试能发现一个实际问题:是否有人在设计接口时一个URL对应了多个不同的HTTP方法,而框架配置没有跟上。还有一个有意思的场景是500状态码的模拟。如果你想让接口真的抛一个未捕获的RuntimeException,最好克隆一个专门用来测试异常的Service实现,或者用Mockito的doThrow。注意不要为了制造500而在生产代码里埋雷,测试完就有意保留。我自己写错误路径测试的时候有一个原则:对4xx的断言尽量具体,对5xx的断言保持结构层面即可,不要断言过于具体的错误文案,因为异常文案很容易改动,但状态码和错误码通常是稳定的接口契约。5. MockMvc + Mockito:把外部服务隔离掉的测试方案5.1 对Service层打桩,controller测业务编排当Controller已经相对成熟,重点关注的是接口层的逻辑时,用Mockito打桩Service层,配合@WebMvcTest做Controller切片测试,是我最常用的一套组合。假设BookController依赖BookService:@WebMvcTest(BookController.class) class BookControllerSliceTest { @Autowired private MockMvc mockMvc; @MockBean private BookService bookService; @Test void getBook_shouldReturnBook() throws Exception { Book book = new Book(1L, "Spring实战", "Marchi", 59.0); when(bookService.findById(1L)).thenReturn(book); mockMvc.perform(get("/api/books/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.title").value("Spring实战")); } }这里有个必须要提醒的点:@MockBean是Spring Boot测试提供的注解,它会向ApplicationContext里注册一个mock版本的Bean,替代原有的真实Bean。所以在切片测试里,你打桩的是Service接口,Controller里注入的就是这个mock实例。如果你没有对某个依赖方法打桩,Mockito默认返回空值(null、空集合等),Controller里如果对这些空值做了不必要的处理,测试会马上暴露出来。5.2 verify验证调用次数,防止无效刷新除了打桩返回值,Mockito的verify经常用来验证交互行为。比如一个刷新接口,如果某个参数为空,刷新的方法就不应该被调用:@Test void refreshCache_whenParamMissing_shouldNotCallService() throws Exception { mockMvc.perform(post("/api/cache/refresh") .param("force", "false")) .andExpect(status().isAccepted()); verify(cacheService, never()).forceRefresh(); }verify还能用来确认调用参数是否符合预期:verify(bookService, times(1)).findById(1L);有时候我在测试结束前加一个verifyNoMoreInteractions来强制校验没有其他多余调用,这可以避免Controller悄悄多调了一个Service方法而没人发现。不过这个用法要克制,否则接口一增加新逻辑测试就碎,维护成本会直线上升。5.3 避免过度mock的实践建议Mockito虽好用,但过度mock会导致测试失去意义。我见过有人把REST API的测试做成了“全链路打桩”,连自己内部Service方法里简单的数据处理都mock掉,最后测试只证明了“Mockito会按你设置的方式返回”,整个测试几乎没有验证价值。我的实践原则是三层:第一,如果测试目标是Controller层的参数绑定、序列化、路由、异常处理,就mock掉Service层,专注验证HTTP契约。第二,如果测试目标是核心业务逻辑,就别用MockMvc了,直接写纯JUnit测试调用Service方法,用内存里的Repository实现或者真实数据库(Testcontainers),确保业务代码真正被执行。第三,如果测试目标是跨服务的流程编排,那用@SpringBootTest,把模块内部依赖都装配成真的,只mock外部RPC或者消息队列客户端。这三种测试分层的边界有时候会被混淆,但只要你明确当前测试“要守护什么”,mock的选择自然就清晰了。6. 排查实录:我在MockMvc测试中踩过的坑6.1NoSuchMethodError与版本冲突的定位思路MockMvc + JUnit 5的版本冲突问题非常经典。现象是运行时抛java.lang.NoSuchMethodError,或者java.lang.NoClassDefFoundError。原因多半是Spring框架版本和Spring Boot版本不一致,或者spring-test的版本和spring-webmvc对不上。我的排查思路是这样的:先看spring-boot-starter-parent定义的版本,再执行mvn dependency:tree检查实际落地的依赖版本。常见的一个坑是项目里自己引了老版本的spring-web,导致MockHttpServletRequest或MockHttpServletResponse里缺失某些方法。6.2 jsonPath取值一直失败JsonPath报错最常见的原因是路径写错,比如响应体里字段名是first_name,你写成firstName;或者字段是data而不是data.list。我的经验是调试时先别加太细的jsonPath断言,先用简单的断言打印出响应体内容:mockMvc.perform(get("/api/books")) .andDo(print());andDo(print())会把整个请求和响应体打印出来,一眼就能看到实际返回的JSON结构,再对着结构写JsonPath表达式就清晰了。另外,有些接口返回的JSON里有$符号或者@符号的字段名,比如$ref,JsonPath会把$当作根路径处理,直接取会报错。必须用jsonPath("$['$ref']")这种写法转义。6.3 MockMvc请求返回404、406、415状态码这三个状态码在MockMvc测试里是最常出现的,我把它们的经典场景和排查方向列成一个速查表:状态码典型场景排查方向404路径不存在或Controller方法未匹配检查URL是否拼错、检查@RequestMapping的路径是否一致、检查测试类是否扫描到了Controller406Content-Type或Accept头不匹配检查.accept()设置的值,以及@ResponseBody序列化结果是否和Accept匹配415请求体格式和@RequestBody不匹配检查.contentType()是否设置为APPLICATION_JSON,以及JSON格式是否合法关于404还有一个坑,就是@WebMvcTest的场景。如果你用@WebMvcTest(BookController.class),但Controller内部通过@RequestMapping直接返回视图名而不是@ResponseBody,就可能出现404。Controller如果在类上加了@Controller而没有加@ResponseBody,方法的返回值会被当作视图名解析,测试接口路径时就会得到404或HttpMessageNotWritableException。解决办法是确保Controller返回的是ResponseEntity,或者类上加了@RestController。6.4 中文乱码和字符编码问题中文乱码在MockMvc测试里也经常遇到,但很多时候不是测试代码的问题,而是接口的Content-Type没有包含charset=UTF-8。如果接口返回的JSON是中文,但响应头的Content-Type是application/json而没有charset,MockMvc在解析响应体时可能按ISO-8859-1来处理,导致中文乱码。一个临时验证方法:在断言前用andDo(print())看打印出来的响应体会不会是乱码。解决方法是让接口侧的MappingJackson2HttpMessageConverter正确配置UTF-8,或者在测试断言时指定字符集:.andExpect(content().encoding("UTF-8"))在实际项目中,我会在基类里为所有测试统一配置characterEncoding,防止每个接口测试都重复处理中文编码问题。6.5 测试数据残留导致断言失败MockMvc测试如果连了真实数据库(比如H2或者Testcontainers),非常容易出现测试数据残留导致互相影响的问题。比如第一个测试插入了一个id=1的书,第二个测试查询时按id=1能查到,但后续测试断言数量时把之前的数据也算进去了,导致总数对不上。我长期实践下来的方案是:每个测试方法都开启事务并回滚,或者至少在每个测试之前清理相关数据。Spring的@Transactional用在测试方法上,默认情况下测试结束会回滚,这样测试之间数据不会互相污染。@SpringBootTest @AutoConfigureMockMvc @Transactional class BookApiIntegrationTest { // ... }但要注意,使用@Transactional回滚的前提是测试过程中Controller和Service层的数据库操作都在同一个事务里。如果项目中用了REQUIRES_NEW传播行为,或者异步线程里操作数据库,回滚就失效了。这种情况下就得用数据清理策略。关于这个话题,我在实际项目里还有一个常用的技巧:在测试基类里统一维护一套构造测试数据的工具方法,比如createBookInDb(...),然后在每个测试里用独立的ID范围或数据特征去隔离,断言时也只关心自己创建的数据。最后分享一点测试重构的经验MockMvc测试如果不好好控制粒度,项目一大会变得很脆弱。我见过最多的失败原因不是代码逻辑错了,而是测试断言写得过于具体,比如精确断言某个自增ID的具体值,或者断言一个可能因为配置变化而改变的日期格式。所以我的一个原则是:对核心契约(状态码、业务字段、关键业务值)做严格断言,对易变信息(自增ID、服务器时间、分页总数)做存在性和类型断言。另外,测试命名也值得投入一点精力。别用test1、test2这种名字,我用的是方法名_场景_预期结果的格式,比如createBook_titleTooLong_shouldReturn400。这样的命名在测试报告里一目了然,别人接手你的代码时也能快速知道这个测试在守护什么行为。REST API的MockMvc + JUnit 5测试说到底是一件需要不断打磨的事。环境配置、请求构造、响应断言,每一层都有细节。等你把前面这些点都跑通了,再回头看那些让新人抓狂的415、406、404,基本就是一眼定位,不会再被它们卡住。