WebFlux 测试实战——WebTestClient、StepVerifier 完整测试指南
WebFlux 测试实战——WebTestClient、StepVerifier 完整测试指南
适读人群:写 WebFlux 代码但还没建立完善测试体系的工程师 | 阅读时长:约14分钟 | 核心价值:用真实测试代码演示 WebFlux 测试的正确姿势
我之前有一段时间不太写 WebFlux 的测试,原因说出来有点惭愧:不知道怎么测。响应式代码的测试方式和 MVC 不太一样,第一次尝试写测试,要么 .block() 到处都是,要么用了 StepVerifier 但写法不对,测试通过了但其实什么都没验证。
直到有一次代码 review,被一个同事指出来:"你这个测试,assertThat 里永远不会走到,你知道吗?"
我把代码仔细看了一遍,发现他说的是对的。我写的响应式测试,表面上通过了,但断言根本没执行——Mono 没有被订阅,异步操作根本没触发。
那次之后,我认真把 WebFlux 的测试工具学了一遍。这篇文章是系统总结。
一、测试工具概览
WebFlux 测试主要用两个工具:
StepVerifier:验证Mono/Flux的发射内容和行为,用于 Service 层、Repository 层的单元测试WebTestClient:模拟 HTTP 请求,用于 Controller 层的集成测试
两者可以结合用,也可以独立用。
二、StepVerifier 基础用法
StepVerifier 是 Project Reactor 提供的专门测试工具,它会订阅你的 Mono/Flux,然后你可以"断言"流中的每一个事件:
@Test
void testSimpleMono() {
Mono<String> mono = Mono.just("hello");
StepVerifier.create(mono)
.expectNext("hello") // 期望收到 "hello"
.verifyComplete(); // 期望流正常完成(不能只到这里,必须调用 verify 方法)
// 注意:如果不调 verifyComplete()(或其他 verify 方法),
// StepVerifier 不会真正执行,测试会通过但断言没有运行!
// 这是最常见的错误写法:
// StepVerifier.create(mono).expectNext("hello"); // 错误!缺少 verify
}
@Test
void testFlux() {
Flux<Integer> flux = Flux.range(1, 5);
StepVerifier.create(flux)
.expectNext(1, 2, 3, 4, 5)
.verifyComplete();
}
@Test
void testEmptyMono() {
Mono<String> empty = Mono.empty();
StepVerifier.create(empty)
.verifyComplete(); // 期望没有元素,直接完成
}三、测试错误场景
@Test
void testMonoError() {
Mono<String> errorMono = Mono.error(new RuntimeException("something went wrong"));
StepVerifier.create(errorMono)
.expectErrorMessage("something went wrong") // 验证错误消息
.verify();
}
@Test
void testErrorType() {
Mono<User> mono = userService.findUser(-1L);
StepVerifier.create(mono)
.expectError(UserNotFoundException.class) // 验证错误类型
.verify();
}
@Test
void testErrorWithPredicate() {
Mono<User> mono = userService.findUser(-1L);
StepVerifier.create(mono)
.expectErrorMatches(e ->
e instanceof UserNotFoundException
&& e.getMessage().contains("-1"))
.verify();
}四、测试异步时序
有时候需要测试带时间延迟的操作(比如 delayElements),但不想真的等那么长时间:
@Test
void testWithVirtualTime() {
// 使用虚拟时间,不需要真的等待
StepVerifier.withVirtualTime(() ->
Flux.interval(Duration.ofSeconds(1)).take(3)
)
.expectSubscription()
.thenAwait(Duration.ofSeconds(3)) // 虚拟地"推进"时间
.expectNext(0L, 1L, 2L)
.verifyComplete();
// 这个测试几乎立即完成,不会真的等3秒
}
@Test
void testCacheWithTtl() {
StepVerifier.withVirtualTime(() ->
cacheService.getWithTtl("key") // 5分钟过期
)
.expectNext("value")
.thenAwait(Duration.ofMinutes(6)) // 虚拟推进6分钟
.verifyComplete(); // 期望过期后流完成(或者后续的操作)
}五、WebTestClient:Controller 层测试
WebTestClient 有两种模式:
模式1:绑定 Controller(轻量,不启动完整 Spring 上下文)
@ExtendWith(SpringExtension.class)
class UserControllerTest {
private WebTestClient webTestClient;
@MockBean
private UserService userService;
@BeforeEach
void setUp() {
webTestClient = WebTestClient
.bindToController(new UserController(userService))
.build();
}
@Test
void testGetUser() {
UserVO mockUser = UserVO.builder()
.id(1L)
.username("testuser")
.email("test@example.com")
.build();
when(userService.findUser(1L)).thenReturn(Mono.just(mockUser));
webTestClient.get()
.uri("/api/users/1")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody(UserVO.class)
.value(user -> {
assertThat(user.getId()).isEqualTo(1L);
assertThat(user.getUsername()).isEqualTo("testuser");
});
}
@Test
void testGetUserNotFound() {
when(userService.findUser(999L))
.thenReturn(Mono.error(new UserNotFoundException(999L)));
webTestClient.get()
.uri("/api/users/999")
.exchange()
.expectStatus().isNotFound()
.expectBody()
.jsonPath("$.code").isEqualTo("USER_NOT_FOUND");
}
}模式2:@SpringBootTest 全集成测试
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTest {
@Autowired
private WebTestClient webTestClient;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
// 初始化测试数据
userRepository.deleteAll()
.then(userRepository.save(testUser()))
.block(); // 这里用 block() 是合理的,只是测试初始化
}
@Test
void testCreateUser() {
CreateUserRequest request = new CreateUserRequest("newuser", "new@example.com");
webTestClient.post()
.uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.exchange()
.expectStatus().isCreated()
.expectBody(UserVO.class)
.value(user -> {
assertThat(user.getId()).isNotNull();
assertThat(user.getUsername()).isEqualTo("newuser");
});
}
}六、测试需要认证的接口
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SecuredControllerTest {
@Autowired
private WebTestClient webTestClient;
@Autowired
private JwtUtils jwtUtils;
private String validToken;
@BeforeEach
void setUp() {
validToken = jwtUtils.generateToken("testuser", List.of("ROLE_USER"));
}
@Test
void testAccessWithValidToken() {
webTestClient.get()
.uri("/api/profile")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)
.exchange()
.expectStatus().isOk();
}
@Test
void testAccessWithoutToken() {
webTestClient.get()
.uri("/api/profile")
.exchange()
.expectStatus().isUnauthorized();
}
// 用 @WithMockUser 注解模拟用户(需要额外配置 WebFlux Security 测试支持)
@Test
@WithMockUser(username = "admin", roles = {"ADMIN"})
void testAdminAccess() {
webTestClient
.mutateWith(SecurityMockServerConfigurers.mockUser("admin").roles("ADMIN"))
.get()
.uri("/admin/users")
.exchange()
.expectStatus().isOk();
}
}七、测试 Flux 流式数据
@Test
void testFluxResponse() {
when(dataService.getDataStream())
.thenReturn(Flux.just(
new DataItem(1, "A"),
new DataItem(2, "B"),
new DataItem(3, "C")
));
webTestClient.get()
.uri("/api/stream")
.accept(MediaType.APPLICATION_NDJSON) // JSON Lines 格式
.exchange()
.expectStatus().isOk()
.expectBodyList(DataItem.class) // 收集所有响应
.hasSize(3)
.value(items -> {
assertThat(items.get(0).getId()).isEqualTo(1);
assertThat(items.get(2).getValue()).isEqualTo("C");
});
}
// 测试 SSE(Server-Sent Events)
@Test
void testSseStream() {
FluxExchangeResult<String> result = webTestClient.get()
.uri("/api/sse")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(String.class);
StepVerifier.create(result.getResponseBody().take(3))
.expectNextCount(3)
.thenCancel()
.verify(Duration.ofSeconds(5));
}八、常见测试陷阱
陷阱1:StepVerifier 没有调用 verify 方法
// 错误的写法!expectNext 之后没有调 verify,断言不会执行
@Test
void wrongTest() {
StepVerifier.create(Mono.just("hello"))
.expectNext("hello"); // 看起来没问题,但 verify 没调,测试通过≠正确
}
// 正确写法
@Test
void correctTest() {
StepVerifier.create(Mono.just("hello"))
.expectNext("hello")
.verifyComplete(); // 必须!
}陷阱2:测试里随意用 block()
// 不推荐,但理解其局限性
@Test
void blockTest() {
String result = Mono.just("hello").block();
assertThat(result).isEqualTo("hello");
// 这样写能测到"值",但测不到响应式链的行为(比如操作符顺序、错误处理等)
}陷阱3:测试时间依赖的代码没用虚拟时间
// 慢!真的会等5秒
@Test
void slowTest() {
Mono<String> delayed = Mono.just("hello").delayElement(Duration.ofSeconds(5));
StepVerifier.create(delayed)
.expectNext("hello")
.verifyComplete(); // 这会真的等5秒
}
// 快!用虚拟时间
@Test
void fastTest() {
StepVerifier.withVirtualTime(() ->
Mono.just("hello").delayElement(Duration.ofSeconds(5)))
.thenAwait(Duration.ofSeconds(5))
.expectNext("hello")
.verifyComplete();
}把 StepVerifier 和 WebTestClient 这两个工具用熟了,WebFlux 的测试体验其实并不比 Spring MVC 差。主要是思维上的转变:从"调方法验返回值"变成"订阅流,验证每个事件"。
下一篇是微服务场景里 WebFlux 服务间调用——WebClient 的完整实践,这个是真实项目里用得最多的场景之一。
