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。

— 感谢阅读 —

一起交流

分享你的思考,让讨论更进一步。