WebFlux 测试
概述
Spring WebFlux 测试体系基于 Reactor 和 WebTestClient 构建,支持从切片测试到端到端测试的全方位验证。
核心组件:
| 组件 | 作用 |
|---|---|
WebTestClient | 响应式 Web 客户端,发送请求并验证响应 |
@WebFluxTest | 切片测试,仅加载 WebFlux 相关 Bean |
StepVerifier | Reactor 响应式流断言工具 |
MockWebServer / WireMock | 模拟外部 HTTP 服务 |
@SpringBootTest | 全量集成测试 |
1. @WebFluxTest 切片测试
@WebFluxTest 仅扫描 @Controller、@RestController、@ControllerAdvice 等 WebFlux 层组件。
java
@WebFluxTest(controllers = UserController.class)
class UserControllerSliceTest {
@Autowired private WebTestClient webTestClient;
@Test
void shouldGetUserById() {
webTestClient.get().uri("/users/{id}", 1L).exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.id").isEqualTo(1)
.jsonPath("$.name").isEqualTo("Alice");
}
}Mock 协作 Bean
java
@WebFluxTest(controllers = UserController.class)
class UserControllerMockTest {
@Autowired private WebTestClient webTestClient;
@MockBean private UserService userService;
@Test
void shouldReturnUserWhenServiceReturns() {
when(userService.findById(1L)).thenReturn(Mono.just(new User(1L, "Alice", "a@b.com")));
webTestClient.get().uri("/users/{id}", 1L).exchange()
.expectStatus().isOk()
.expectBody(User.class)
.value(user -> assertThat(user.getName()).isEqualTo("Alice"));
}
}注意事项: 默认不加载 @Service、@Repository、@Component;Security 时用 @Import(SecurityConfig.class);Gateway 配合 @AutoConfigureWebFlux。
2. WebTestClient API
2.1 创建方式
java
WebTestClient client = WebTestClient.bindToController(new UserController()).build();
WebTestClient client = WebTestClient.bindToRouterFunction(routerFunction).build();
@Autowired private WebTestClient webTestClient;
WebTestClient client = WebTestClient.bindToServer().baseUrl("http://localhost:8080").build();2.2 GET 请求
java
webTestClient.get().uri("/users/{id}", 1L).exchange();
webTestClient.get()
.uri(uriBuilder -> uriBuilder.path("/users")
.queryParam("page", 0).queryParam("size", 20).build())
.exchange();
webTestClient.get().uri("/users/me")
.header("Authorization", "Bearer token123")
.accept(MediaType.APPLICATION_JSON)
.exchange();2.3 POST 请求
java
webTestClient.post().uri("/users")
.contentType(MediaType.APPLICATION_JSON).bodyValue(newUser)
.exchange().expectStatus().isCreated().expectHeader().location("/users/3");
webTestClient.post().uri("/login")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.bodyValue("username=admin&password=secret")
.exchange().expectStatus().isFound();2.4 PUT / DELETE / PATCH
java
webTestClient.put().uri("/users/{id}", 1L)
.contentType(MediaType.APPLICATION_JSON).bodyValue(updatedUser)
.exchange().expectStatus().isOk();
webTestClient.delete().uri("/users/{id}", 1L).exchange().expectStatus().isNoContent();
webTestClient.patch().uri("/users/{id}", 1L)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue("{\"name\":\"NewName\"}")
.exchange().expectStatus().isOk();2.5 exchange() 核心方法
exchange() 发送请求并获取响应,自动处理订阅,返回 ResponseSpec 用于链式断言。
java
webTestClient.get().uri("/users").exchange()
.expectStatus().isOk().expectBody().jsonPath("$").isArray();3. 响应式断言
3.1 expectStatus
java
expectStatus().isOk() // 200
expectStatus().isCreated() // 201
expectStatus().isNoContent() // 204
expectStatus().isFound() // 302
expectStatus().isBadRequest() // 400
expectStatus().isUnauthorized() // 401
expectStatus().isForbidden() // 403
expectStatus().isNotFound() // 404
expectStatus().is5xxServerError() // 5xx
expectStatus().isEqualTo(418) // 自定义
expectStatus().is2xxSuccessful()
expectStatus().is4xxClientError()3.2 expectHeader
java
expectHeader().contentType(MediaType.APPLICATION_JSON)
expectHeader().location("/users/42")
expectHeader().exists("X-Request-Id")
expectHeader().doesNotExist("X-Deprecated")
expectHeader().string("X-Rate-Limit", "100")
expectHeader().stringMatches("ETag", "\\\"v[0-9]+\\\"")
expectHeader().longValue("Content-Length", 256L)3.3 expectBody
java
expectBody().isEmpty()
expectBody().json("{\"name\":\"Alice\"}")
expectBody(User.class).value(user -> assertThat(user.getName()).isEqualTo("Alice"))
expectBodyList(User.class).hasSize(3).contains(mockUser1, mockUser2)
expectBody(Map.class).value(map -> assertThat(map).containsKey("total"))3.4 JSON Path 断言
java
expectBody()
.jsonPath("$.id").isEqualTo(1)
.jsonPath("$.name").isEqualTo("Alice")
.jsonPath("$.tags").isArray()
.jsonPath("$.tags.length()").isEqualTo(3)
.jsonPath("$.tags[0]").isEqualTo("admin")
.jsonPath("$.address.city").isEqualTo("Beijing");
.jsonPath("$.optionalField").doesNotExist()
.jsonPath("$.nullableField").isEmpty()3.5 消费完整响应
java
expectBody().consumeWith(result -> {
byte[] raw = result.getResponseBodyContent();
HttpStatus status = result.getStatus();
assertThat(status.value()).isEqualTo(200);
});3.6 Flux 流式断言
java
// 集合方式
webTestClient.get().uri("/users/stream").exchange()
.expectStatus().isOk().expectBodyList(User.class).hasSize(5);
// StepVerifier 逐元素验证
FluxExchangeResult<User> result = webTestClient.get()
.uri("/users/stream").exchange().expectStatus().isOk()
.returnResult(User.class);
StepVerifier.create(result.getResponseBody())
.expectNextMatches(u -> u.getName().equals("A"))
.expectNextMatches(u -> u.getName().equals("B"))
.expectNextCount(3).expectComplete().verify();3.7 returnResult
java
FluxExchangeResult<String> result = webTestClient.get()
.uri("/events").accept(MediaType.TEXT_EVENT_STREAM)
.exchange().expectStatus().isOk().returnResult(String.class);
StepVerifier.create(result.getResponseBody())
.expectNext("data:event1").expectNext("data:event2")
.thenCancel().verify();4. Mock 外部 HTTP 服务
外部 HTTP 接口使用 MockWebServer 或 WireMock 模拟。
4.1 OkHttp MockWebServer
xml
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<scope>test</scope>
</dependency>java
class ExternalServiceMockTest {
private MockWebServer mockWebServer;
private ExternalServiceClient externalServiceClient;
@BeforeEach
void setUp() {
mockWebServer = new MockWebServer();
WebClient webClient = WebClient.builder()
.baseUrl(mockWebServer.url("/").toString()).build();
externalServiceClient = new ExternalServiceClient(webClient);
}
@AfterEach
void tearDown() throws IOException { mockWebServer.shutdown(); }
@Test
void shouldMockSuccessResponse() {
mockWebServer.enqueue(new MockResponse()
.setResponseCode(200).setHeader("Content-Type", "application/json")
.setBody("{\"result\":\"success\"}"));
StepVerifier.create(externalServiceClient.fetchData())
.expectNextMatches(json -> json.contains("success")).verifyComplete();
assertThat(mockWebServer.takeRequest().getMethod()).isEqualTo("GET");
}
@Test
void shouldHandleError() {
mockWebServer.enqueue(new MockResponse().setResponseCode(503).setBody("{\"error\":\"unavailable\"}"));
StepVerifier.create(externalServiceClient.fetchData())
.expectError(WebClientResponseException.class).verify();
}
}4.2 WireMock
xml
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<scope>test</scope>
</dependency>java
@WireMockTest(httpPort = 9090)
class ExternalServiceWireMockTest {
private ExternalServiceClient client;
@BeforeEach
void setUp() {
client = new ExternalServiceClient(
WebClient.builder().baseUrl("http://localhost:9090").build());
}
@Test
void shouldStubGetRequest() {
stubFor(get(urlEqualTo("/api/v1/users/1")).willReturn(aResponse()
.withStatus(200).withHeader("Content-Type", "application/json")
.withBody("{\"id\":1,\"name\":\"Alice\"}")));
StepVerifier.create(client.getUser(1L))
.assertNext(user -> assertThat(user.getName()).isEqualTo("Alice"))
.verifyComplete();
}
@Test
void shouldVerifyRequestHistory() {
stubFor(get(urlPathEqualTo("/api/v1/users")).willReturn(aResponse().withStatus(200)));
client.getUser(1L).block(); client.getUser(2L).block();
verify(2, getRequestedFor(urlPathEqualTo("/api/v1/users")));
}
@Test
void shouldSimulateTimeout() {
stubFor(get(urlEqualTo("/api/v1/slow")).willReturn(aResponse().withFixedDelay(5000)));
StepVerifier.create(client.fetchSlowData().timeout(Duration.ofSeconds(2)))
.expectError(TimeoutException.class).verify();
}
}4.3 @MockBean + WebTestClient 集成
java
@WebFluxTest(controllers = GatewayController.class)
class GatewayControllerMockTest {
@Autowired private WebTestClient webTestClient;
@MockBean private ExternalServiceClient externalServiceClient;
@Test
void shouldReturnProxiedResponse() {
when(externalServiceClient.fetchData()).thenReturn(Mono.just("{\"status\":\"ok\"}"));
webTestClient.get().uri("/gateway/data").exchange()
.expectStatus().isOk()
.expectBody().jsonPath("$.status").isEqualTo("ok");
}
}5. 端到端测试
5.1 @SpringBootTest + WebTestClient
java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserE2ETest {
@Autowired private WebTestClient webTestClient;
@Test
void fullUserLifecycle() {
String location = webTestClient.post().uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new CreateUserRequest("Eve", "eve@example.com"))
.exchange().expectStatus().isCreated().expectHeader().exists("Location")
.returnResult().getResponseHeaders().getLocation().toString();
webTestClient.get().uri(location).exchange().expectStatus().isOk()
.expectBody().jsonPath("$.name").isEqualTo("Eve");
webTestClient.put().uri(location).contentType(MediaType.APPLICATION_JSON)
.bodyValue(new UpdateUserRequest("Eve Updated"))
.exchange().expectStatus().isOk()
.expectBody().jsonPath("$.name").isEqualTo("Eve Updated");
webTestClient.delete().uri(location).exchange().expectStatus().isNoContent();
webTestClient.get().uri(location).exchange().expectStatus().isNotFound();
}
@Test
void shouldReturnValidationErrors() {
webTestClient.post().uri("/api/users").contentType(MediaType.APPLICATION_JSON)
.bodyValue("{\"email\":\"invalid\"}")
.exchange().expectStatus().isBadRequest()
.expectBody().jsonPath("$.errors[0].field").isEqualTo("name");
}
}5.2 TestContainers 集成
java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Testcontainers
class UserIntegrationTest {
@Container
static MongoDBContainer mongo = new MongoDBContainer("mongo:7.0");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl);
}
@Autowired private WebTestClient webTestClient;
@Test void shouldPersistAndRetrieveUser() { /* 业务测试 */ }
}6. 自定义测试配置
6.1 覆盖 WebTestClient
java
@TestConfiguration(proxyBeanMethods = false)
class TestWebClientConfig {
@Bean
public WebTestClient webTestClient(WebTestClient.Builder builder) {
return builder.baseUrl("http://localhost:8080")
.defaultHeader("X-Tenant-Id", "default")
.filter(ExchangeFilterFunctions.basicAuthentication("test", "test123"))
.build();
}
}6.2 排除自动配置
java
@WebFluxTest(controllers = UserController.class,
excludeAutoConfiguration = { ReactiveSecurityAutoConfiguration.class })
class UserControllerWithoutSecurityTest { }6.3 @TestPropertySource
java
@WebFluxTest(controllers = ConfigController.class)
@TestPropertySource(properties = { "app.feature.flag=true", "app.timeout=500ms" })
class ConfigControllerTest {
@Autowired private WebTestClient webTestClient;
@Test
void shouldResolveProperties() {
webTestClient.get().uri("/api/config").exchange()
.expectBody().jsonPath("$.flag").isEqualTo(true);
}
}6.4 自定义 MockBean 工厂
java
@WebFluxTest(controllers = OrderController.class)
@Import(OrderServiceMockConfig.class)
class OrderControllerWithMockTest { }
@TestConfiguration
class OrderServiceMockConfig {
@Bean @Primary
OrderService orderService() {
OrderService mock = mock(OrderService.class);
when(mock.findById(anyLong())).thenReturn(Mono.just(new Order(1L, "DELIVERED")));
return mock;
}
}7. 实战:全链路测试模板
7.1 Controller 切片测试
java
@WebFluxTest(controllers = UserController.class)
class UserControllerTest {
@Autowired private WebTestClient webTestClient;
@MockBean private UserService userService;
@Test
void getUserById_ShouldReturn200_WhenUserExists() {
when(userService.findById(1L)).thenReturn(Mono.just(new User(1L, "Alice", "a@t.com")));
webTestClient.get().uri("/api/users/{id}", 1L).exchange()
.expectStatus().isOk()
.expectBody().jsonPath("$.id").isEqualTo(1).jsonPath("$.name").isEqualTo("Alice");
}
@Test
void getUserById_ShouldReturn404_WhenUserNotFound() {
when(userService.findById(99L)).thenReturn(Mono.empty());
webTestClient.get().uri("/api/users/{id}", 99L).exchange()
.expectStatus().isNotFound().expectBody().isEmpty();
}
@Test
void createUser_ShouldReturn201_WhenValid() {
when(userService.create(any())).thenReturn(Mono.just(new User(42L, "Bob", "bob@t.com")));
webTestClient.post().uri("/api/users").contentType(MediaType.APPLICATION_JSON)
.bodyValue(new CreateUserRequest("Bob", "bob@test.com")).exchange()
.expectStatus().isCreated().expectHeader().exists("Location")
.expectHeader().stringMatches("Location", ".*/api/users/42")
.expectBody().jsonPath("$.id").isEqualTo(42).jsonPath("$.name").isEqualTo("Bob");
}
@Test
void createUser_ShouldReturn400_WhenValidationFails() {
webTestClient.post().uri("/api/users").contentType(MediaType.APPLICATION_JSON)
.bodyValue(new CreateUserRequest("", "bob@test.com"))
.exchange().expectStatus().isBadRequest()
.expectBody().jsonPath("$.errors[0].field").isEqualTo("name");
}
@Test
void deleteUser_ShouldReturn204() {
when(userService.deleteById(1L)).thenReturn(Mono.empty());
webTestClient.delete().uri("/api/users/{id}", 1L).exchange()
.expectStatus().isNoContent().expectBody().isEmpty();
}
@Test
void searchUsers_ShouldReturnPagedResult() {
when(userService.search(anyString(), any()))
.thenReturn(Mono.just(new PageResult<>(
List.of(new User(1L, "Alice", "a@t.com")), 1, 0, 10)));
webTestClient.get()
.uri(uriBuilder -> uriBuilder.path("/api/users/search")
.queryParam("q", "Ali").queryParam("page", 0).queryParam("size", 10).build())
.exchange().expectStatus().isOk()
.expectBody()
.jsonPath("$.totalElements").isEqualTo(1)
.jsonPath("$.content[0].name").isEqualTo("Alice");
}
}7.2 端到端测试(含外部 Mock)
java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest(httpPort = 9090)
class UserFullChainTest {
@Autowired private WebTestClient webTestClient;
@Autowired private ReactiveUserRepository userRepository;
private static MockWebServer mockNotificationServer;
@BeforeAll
static void startMockServers() throws IOException {
mockNotificationServer = new MockWebServer();
mockNotificationServer.start(9091);
}
@AfterAll
static void stopMockServers() throws IOException { mockNotificationServer.shutdown(); }
@BeforeEach
void setUp() {
userRepository.deleteAll().block();
userRepository.save(new User(null, "Alice", "alice@test.com")).block();
WireMock.reset();
mockNotificationServer.enqueue(new MockResponse().setResponseCode(200).setBody("{}"));
}
@Test
void fullCreateUserFlow() {
webTestClient.get().uri("/api/users").exchange()
.expectStatus().isOk().expectBodyList(User.class).hasSize(1);
stubFor(post(urlEqualTo("/notify"))
.willReturn(aResponse().withStatus(200).withBody("{\"status\":\"ok\"}")));
webTestClient.post().uri("/api/users").contentType(MediaType.APPLICATION_JSON)
.bodyValue(new CreateUserRequest("Bob", "bob@test.com"))
.exchange().expectStatus().isCreated()
.expectBody().jsonPath("$.name").isEqualTo("Bob");
verify(postRequestedFor(urlEqualTo("/notify"))
.withRequestBody(matchingJsonPath("$.type")));
}
@Test
void shouldHandleExternalServiceFailure() {
stubFor(post(urlEqualTo("/notify"))
.willReturn(aResponse().withStatus(500).withBody("{\"error\":\"internal\"}")));
webTestClient.post().uri("/api/users").contentType(MediaType.APPLICATION_JSON)
.bodyValue(new CreateUserRequest("Carol", "carol@test.com"))
.exchange().expectStatus().isEqualTo(207).expectBody()
.jsonPath("$.userCreated").isEqualTo(true)
.jsonPath("$.notificationSent").isEqualTo(false);
}
}7.3 外部服务 Mock 工具类
java
public class ReactiveMockServer implements AutoCloseable {
private final MockWebServer server;
public ReactiveMockServer() { this.server = new MockWebServer(); }
public void start() {
try { server.start(); } catch (IOException e) { throw new RuntimeException(e); }
}
public void shutdown() { try { server.shutdown(); } catch (IOException e) { } }
public String baseUrl() { return server.url("/").toString(); }
public WebClient createWebClient() {
return WebClient.builder().baseUrl(baseUrl()).build();
}
public ReactiveMockServer enqueue(int status, String body) {
server.enqueue(new MockResponse().setResponseCode(status)
.setHeader("Content-Type", "application/json").setBody(body));
return this;
}
@Override public void close() { shutdown(); }
}8. 最佳实践
8.1 统一测试基类
java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient(timeout = "PT30S") @Testcontainers
public abstract class BaseIntegrationTest {
@Container
static MongoDBContainer mongo = new MongoDBContainer("mongo:7.0");
@Autowired protected WebTestClient webTestClient;
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl);
}
}
class UserIntegrationTest extends BaseIntegrationTest {
@Test
void shouldCreateUser() {
webTestClient.post().uri("/api/users")
.bodyValue(new CreateUserRequest("X", "x@test.com"))
.exchange().expectStatus().isCreated();
}
}8.2 断言工具方法
java
public class TestAssertions {
public static WebTestClient.ResponseSpec expectPaginated(
WebTestClient.ResponseSpec spec, int total) {
return spec.expectStatus().isOk().expectBody()
.jsonPath("$.totalElements").isEqualTo(total)
.jsonPath("$.content").isArray().and();
}
public static WebTestClient.ResponseSpec expectValidationError(
WebTestClient.ResponseSpec spec, String field) {
return spec.expectStatus().isBadRequest().expectBody()
.jsonPath("$.errors[?(@.field=='%s')]", field).exists().and();
}
}8.3 性能与超时
java
@SpringBootTest(properties = { "server.response-timeout=5s" })
@AutoConfigureWebTestClient(timeout = "PT60S")
class PerformanceAwareTest {
@Autowired private WebTestClient webTestClient;
@Test
void shouldRespondWithinLimit() {
long start = System.currentTimeMillis();
webTestClient.get().uri("/users").exchange().expectStatus().isOk();
assertThat(System.currentTimeMillis() - start).isLessThan(2000);
}
}9. 常见问题
WebTestClient 未自动注入: 添加
@SpringBootTest(webEnvironment = RANDOM_PORT)或@WebFluxTest。响应式断言失败(No body returned): 空流时用
StepVerifier替代expectBodyList:javaFluxExchangeResult<User> result = webTestClient.get() .uri("/users/stream").exchange().returnResult(User.class); StepVerifier.create(result.getResponseBody()) .expectNextCount(0).expectComplete().verify();MockWebServer 端口冲突:
server.start(0)使用随机端口。测试间数据污染: 每次
@BeforeEach中repository.deleteAll().block(),或用@Transactional。
10. 总结
| 测试类型 | 适用场景 | 注解/工具 |
|---|---|---|
| 切片测试 | 仅测试 Controller | @WebFluxTest + @MockBean |
| 外部服务 Mock | 依赖第三方 HTTP | MockWebServer / WireMock |
| 端到端测试 | 完整系统链路 | @SpringBootTest + WebTestClient |
| 流式断言 | Flux / SSE | StepVerifier + FluxExchangeResult |
核心原则:
- 切片测试为主:速度快,聚焦 Controller 逻辑
- 外部服务必 Mock:避免依赖不稳定网络
- 响应式断言不可少:
StepVerifier验证响应式流 - 端到端测试保底:确保模块间协作正常