Spring Framework 7 在 spring-test 里加了 RestTestClient,它是包在 RestClient 外面的测试客户端,断言写法和 WebTestClient 几乎一样,但只依赖 Servlet 栈。它既能绑到 MockMvc 上不起服务器测,也能连随机端口上的真服务器,Spring MVC 项目想要一套链式的请求加断言写法,不用再为了 WebTestClient 把 WebFlux 拉进测试依赖。下面用 Spring Boot 4.1.1(Framework 7.0.9)建了个小项目,把 RestTestClient 的五种绑定方式、MockMvc 的两种写法和 WebTestClient 的两种用法都跑了一遍,15 个测试全部通过。
被测的东西
一个最小的 MVC 应用,BookController 提供 GET /books/{id} 和 POST /books,找不到书时抛 ResponseStatusException 返回 404。另外注册了一个函数式路由 /ping,专门给 bindToRouterFunction 用:
@RestController
@RequestMapping("/books")
public class BookController {
private final Map<String, Book> books = new ConcurrentHashMap<>(Map.of("42", new Book("42", "Spring in Practice")));
@GetMapping("/{id}")
Book get(@PathVariable String id) {
Book book = books.get(id);
if (book == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no book " + id);
}
return book;
}
@PostMapping
ResponseEntity<Book> create(@RequestBody Book book) {
books.put(book.id(), book);
return ResponseEntity.created(URI.create("/books/" + book.id())).body(book);
}
}
public final class PingRoute {
public static RouterFunction<ServerResponse> route() {
return RouterFunctions.route()
.GET("/ping", request -> ServerResponse.ok().body("pong"))
.build();
}
}
依赖
Boot 4 把测试支持也拆成了模块。spring-boot-starter-webmvc-test 带了 spring-boot-starter-test、spring-boot-webmvc-test 和 spring-boot-resttestclient,所以 MockMvc 和 RestTestClient 引这一个就够。WebTestClient 要另引 spring-boot-webtestclient:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-webtestclient</artifactId>
<scope>test</scope>
</dependency>
mvn dependency:tree 里能看清两者的差别,RestTestClient 那条线上没有响应式的东西,WebTestClient 那条线带进了 spring-webflux 和 reactor-core:
[INFO] +- org.springframework.boot:spring-boot-starter-webmvc-test:jar:4.1.1:test
[INFO] | +- org.springframework.boot:spring-boot-starter-test:jar:4.1.1:test
[INFO] | | \- org.springframework:spring-test:jar:7.0.9:test
[INFO] | \- org.springframework.boot:spring-boot-resttestclient:jar:4.1.1:test
[INFO] \- org.springframework.boot:spring-boot-webtestclient:jar:4.1.1:test
[INFO] \- org.springframework:spring-webflux:jar:7.0.9:test
[INFO] \- io.projectreactor:reactor-core:jar:3.8.7:test
MockMvc 的两种写法
MockMvc 还是老样子。perform 加 Hamcrest 的 andExpect 是一种,Boot 3.4 起有了 AssertJ 风格的 MockMvcTester,之前有篇介绍 MockMvcTester 的文章讲过。@WebMvcTest 两个都会自动配好。注意 Boot 4 里这个注解换了包,现在是 org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest。
@WebMvcTest(BookController.class)
class MockMvcStyleTest {
@Autowired
MockMvc mockMvc;
@Autowired
MockMvcTester mvc;
@Test
void hamcrestStyle() throws Exception {
mockMvc.perform(get("/books/42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Spring in Practice"));
}
@Test
void assertjStyle() {
assertThat(mvc.get().uri("/books/42"))
.hasStatusOk()
.bodyJson().extractingPath("$.title").isEqualTo("Spring in Practice");
}
}
MockMvc 的长处是能直接摸到 Servlet 层的东西。比如 404 这种情况,在 mock 环境里响应体是空的,原因写在 MockHttpServletResponse 的 errorMessage 上,用 MockMvc 能直接读到:
MockMvc 404 body=[] errorMessage=no book 7
request attribute 和 forward 也一样。项目里加了一个老式的 @Controller,往请求里放一个属性,然后 forward 到 /books/{id}:
@Controller
public class LegacyBookController {
@GetMapping("/old-books/{id}")
String forwardToBooks(@PathVariable String id, HttpServletRequest request) {
request.setAttribute("source", "legacy");
return "forward:/books/" + id;
}
}
MockMvc 不会真的执行 forward,只记录目标地址,所以测试能同时断言属性和 forward 目标。同一个接口用建在 MockMvc 上的 RestTestClient 去调,只拿得到一个 200 和空响应体,属性和 forward 地址都看不到:
@WebMvcTest(LegacyBookController.class)
@AutoConfigureRestTestClient
class MockMvcServletDetailsTest {
@Autowired
MockMvc mockMvc;
@Autowired
RestTestClient restTestClient;
@Test
void mockMvcSeesRequestAttributeAndForward() throws Exception {
var result = mockMvc.perform(get("/old-books/42"))
.andExpect(status().isOk())
.andExpect(request().attribute("source", "legacy"))
.andExpect(forwardedUrl("/books/42"))
.andReturn();
System.out.println("MockMvc forwardedUrl=" + result.getResponse().getForwardedUrl()
+ " attribute source=" + result.getRequest().getAttribute("source"));
}
@Test
void restTestClientOnlySeesTheHttpResponse() {
var result = restTestClient.get().uri("/old-books/42")
.exchange()
.expectStatus().isOk()
.expectBody(String.class).returnResult();
System.out.println("RestTestClient over MockMvc status=" + result.getStatus()
+ " body=[" + result.getResponseBody() + "]");
}
}
MockMvc forwardedUrl=/books/42 attribute source=legacy
RestTestClient over MockMvc status=200 OK body=[null]
RestTestClient 的五种绑定
RestTestClient 的入口是几个静态方法,源码里有 bindToController、bindToRouterFunction、bindToApplicationContext、bindTo(MockMvc)、bindToServer(),还有一个接收 ClientHttpRequestFactory 的 bindToServer 重载。前四种都走 MockMvc,不起服务器。bindTo(MockMvc) 在源码里就是用 MockMvcClientHttpRequestFactory 包一层再交给 bindToServer,所以发出去的请求最后落到 MockMvc 上。
在 @WebMvcTest 上加 @AutoConfigureRestTestClient,Boot 会注入一个建在 MockMvc 上的 RestTestClient。四种 mock 绑定写在一个测试类里:
@WebMvcTest(BookController.class)
@AutoConfigureRestTestClient
class RestTestClientBindingsTest {
@Autowired
RestTestClient autoConfigured;
@Autowired
MockMvc mockMvc;
@Autowired
WebApplicationContext context;
@Test
void autoConfiguredOnTopOfMockMvc() {
autoConfigured.get().uri("/books/42")
.exchange()
.expectStatus().isOk()
.expectBody(Book.class).isEqualTo(new Book("42", "Spring in Practice"));
}
@Test
void bindToMockMvc() {
RestTestClient client = RestTestClient.bindTo(mockMvc).build();
client.post().uri("/books")
.contentType(MediaType.APPLICATION_JSON)
.body(new Book("7", "Testing Spring"))
.exchange()
.expectStatus().isCreated()
.expectHeader().location("/books/7");
}
@Test
void bindToApplicationContext() {
RestTestClient client = RestTestClient.bindToApplicationContext(context).build();
client.get().uri("/books/42")
.exchange()
.expectStatus().isOk()
.expectBody().jsonPath("$.title").isEqualTo("Spring in Practice");
}
@Test
void bindToControllerWithoutSpringContext() {
RestTestClient client = RestTestClient.bindToController(new BookController()).build();
client.get().uri("/books/7")
.exchange()
.expectStatus().isNotFound();
}
@Test
void bindToRouterFunction() {
RestTestClient client = RestTestClient.bindToRouterFunction(PingRoute.route()).build();
client.get().uri("/ping")
.exchange()
.expectStatus().isOk()
.expectBody(String.class).isEqualTo("pong");
}
}
bindToController 和 bindToRouterFunction 完全不需要 Spring 容器,new 一个对象就能测,跑得最快。body(...) 直接收对象,JSON 序列化由底层的 RestClient 完成,这点比 MockMvc 手写 JSON 字符串省事。
习惯 AssertJ 的话,RestTestClientResponse.from(spec) 能把结果转成 AssertJ 断言,写法和 MockMvcTester 接近:
@Test
void assertjResponse() {
var spec = autoConfigured.get().uri("/books/42").exchange();
assertThat(RestTestClientResponse.from(spec))
.hasStatusOk()
.bodyJson().extractingPath("$.id").isEqualTo("42");
}
第五种是连真服务器。@SpringBootTest(webEnvironment = RANDOM_PORT) 加 @AutoConfigureRestTestClient,注入的客户端会把相对路径解析到随机端口上。不用自动配置的话,bindToServer().baseUrl(...) 手动指地址也行。WebTestClient 在同一个类里也注入了一份做对照:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@AutoConfigureWebTestClient
class RunningServerTest {
@LocalServerPort
int port;
@Autowired
RestTestClient restTestClient;
@Autowired
WebTestClient webTestClient;
@Test
void autoConfiguredRestTestClientHitsRealServer() {
var result = restTestClient.get().uri("/books/7")
.exchange()
.expectStatus().isNotFound()
.expectBody(String.class).returnResult();
System.out.println("RestTestClient real-server 404 uri=" + result.getUrl() + " body=" + result.getResponseBody());
}
@Test
void bindToServerWithExplicitBaseUrl() {
RestTestClient client = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
client.get().uri("/books/42")
.exchange()
.expectStatus().isOk()
.expectBody().jsonPath("$.title").isEqualTo("Spring in Practice");
}
@Test
void webTestClientHitsRealServer() {
webTestClient.get().uri("/books/42")
.exchange()
.expectStatus().isOk()
.expectBody(Book.class).isEqualTo(new Book("42", "Spring in Practice"));
}
}
同一个 404,走真服务器时响应体是 Boot 的错误 JSON,里面没有 "no book 7" 这句原因:
Tomcat started on port 42201 (http) with context path '/'
RestTestClient real-server 404 uri=http://localhost:42201/books/7 body={"timestamp":"2026-10-11T00:06:04.584Z","status":404,"error":"Not Found","path":"/books/7"}
消息没出来,是因为 Boot 默认不把异常消息写进错误响应。按 Boot 3 的习惯用 -Dserver.error.include-message=always 重跑这个测试,响应体没有任何变化:
$ mvn test -Dtest='RunningServerTest#autoConfiguredRestTestClientHitsRealServer' -Dserver.error.include-message=always
RestTestClient real-server 404 uri=http://localhost:44873/books/7 body={"timestamp":"2026-10-11T00:05:44.106Z","status":404,"error":"Not Found","path":"/books/7"}
Boot 4 把这组属性挪到了 spring.web.error 下,spring-boot-web-server 的配置元数据里 server.error.include-message 已标为 error 级废弃,替代键是 spring.web.error.include-message。换成新键再跑,message 出来了:
$ mvn test -Dtest='RunningServerTest#autoConfiguredRestTestClientHitsRealServer' -Dspring.web.error.include-message=always
RestTestClient real-server 404 uri=http://localhost:45963/books/7 body={"timestamp":"2026-10-11T00:05:59.955Z","status":404,"error":"Not Found","message":"no book 7","path":"/books/7"}
用旧键时测试日志里没有任何报错或警告,只是不生效。
WebTestClient 放在哪
WebTestClient 在 Servlet 项目里也能用。不起服务器时,通过 MockMvcWebTestClient 绑到 MockMvc 上:
@Test
void webTestClientOverMockMvcStandalone() {
WebTestClient client = MockMvcWebTestClient.bindToController(new BookController()).build();
client.get().uri("/books/42")
.exchange()
.expectStatus().isOk()
.expectBody().jsonPath("$.title").isEqualTo("Spring in Practice");
}
它和 RestTestClient 的链式写法几乎一一对应,exchange()、expectStatus()、expectBody()、jsonPath(...) 都是同名的。区别在底层,WebTestClient 基于 WebFlux 的 WebClient。WebTestClient 文档里有流式响应一节,text/event-stream、application/x-ndjson 这类可能无限长的流,先校验状态和响应头,再拿 FluxExchangeResult 逐条消费。它还能不起服务器直接绑 WebFlux 的控制器、路由函数和应用上下文。RestTestClient 文档写的 mock 方式只有 MockMvc,也就是只覆盖 Spring MVC,所以不起服务器测 WebFlux 应用只能用 WebTestClient。纯 MVC 项目里它能做的,RestTestClient 现在都能做,还省掉一条 WebFlux 依赖。
所以三者的边界很清楚。Servlet 请求和响应的细节,比如上面的 errorMessage、request attribute 和 forward 目标,只有 MockMvc 和 MockMvcTester 看得到。RestTestClient 给 MVC 项目一套客户端风格的写法,同一套代码在 mock 和真端口之间切换只是换个绑定方式。WebFlux 应用和 SSE 这类流式响应仍然归 WebTestClient。
实际跑一遍
环境是 Debian 13、OpenJDK 21.0.12.1、Maven 3.9.9,Spring Boot 4.1.1,Framework 7.0.9,reactor-core 3.8.7。mvn test 的结果:
MockMvc 404 body=[] errorMessage=no book 7
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.489 s -- in com.example.rtc.MockMvcStyleTest
MockMvc forwardedUrl=/books/42 attribute source=legacy
RestTestClient over MockMvc status=200 OK body=[null]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.165 s -- in com.example.rtc.MockMvcServletDetailsTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.170 s -- in com.example.rtc.RestTestClientBindingsTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.064 s -- in com.example.rtc.WebTestClientMockTest
RestTestClient real-server 404 uri=http://localhost:42201/books/7 body={"timestamp":"2026-10-11T00:06:04.584Z","status":404,"error":"Not Found","path":"/books/7"}
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.509 s -- in com.example.rtc.RunningServerTest
[INFO] Tests run: 15, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
过程中碰到的几处。Boot 4 的测试注解换了包,WebMvcTest 在 org.springframework.boot.webmvc.test.autoconfigure,AutoConfigureRestTestClient 在 org.springframework.boot.resttestclient.autoconfigure,AutoConfigureWebTestClient 在 org.springframework.boot.webtestclient.autoconfigure。把 MockMvcStyleTest 的 import 换回 Boot 3 的 org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 编译一次,直接报错:
[ERROR] src/test/java/com/example/rtc/MockMvcStyleTest.java:[10,63] package org.springframework.boot.test.autoconfigure.web.servlet does not exist
[ERROR] src/test/java/com/example/rtc/MockMvcStyleTest.java:[14,2] cannot find symbol
@AutoConfigureWebTestClient 不在 spring-boot-starter-webmvc-test 里,要单独引 spring-boot-webtestclient。第一次编译还卡在 MockHttpServletResponse.getContentAsString() 上,它声明了 UnsupportedEncodingException,测试方法要加 throws。上面 mock 环境和真服务器的 404 响应体不同,所以从 MockMvc 换到真端口后,断言错误响应体的那条测试也得改。错误响应相关的配置在 Boot 4 里归到 spring.web.error.*,沿用 server.error.* 时日志里没有提示。
API 和配置以 Spring Framework 7.0.9、Spring Boot 4.1.1 源码及下面两份官方文档为准,并在上述项目里逐一跑过:Spring Framework 参考文档 RestTestClient、Spring Boot 参考文档 Testing Spring Boot Applications。
一起交流
分享你的思考,让讨论更进一步。