Spring WebFlux Testing: WebTestClient, StepVerifier, and Reactive Repositories
Reactive programming introduces a fundamentally different execution model — asynchronous, non-blocking, event-driven — and testing it requires a different mindset. You can't just call a method and check the return value; you need to subscribe to a stream, assert on emitted items, verify completion signals, and handle errors in a reactive way.
Spring's testing ecosystem for WebFlux is mature and powerful. WebTestClient provides a fluent API for testing reactive HTTP endpoints. Project Reactor's StepVerifier gives you precise control over asserting on Mono and Flux streams, including time-based operators. This guide covers everything you need to test reactive Spring Boot applications comprehensively.
@WebFluxTest: The Slice Annotation
@WebFluxTest loads only the WebFlux layer — controllers, filters, WebExceptionHandler, and WebFluxConfigurer — without starting a full application context or HTTP server. It auto-configures WebTestClient for you.
@WebFluxTest(ArticleController.class)
class ArticleControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockBean
private ArticleService articleService;
@Test
void getArticle_returnsArticleForKnownSlug() {
Article article = new Article("spring-webflux-testing", "Spring WebFlux Testing");
when(articleService.findBySlug("spring-webflux-testing"))
.thenReturn(Mono.just(article));
webTestClient.get()
.uri("/api/articles/spring-webflux-testing")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.slug").isEqualTo("spring-webflux-testing")
.jsonPath("$.title").isEqualTo("Spring WebFlux Testing");
}
@Test
void getArticle_returns404ForUnknownSlug() {
when(articleService.findBySlug("non-existent"))
.thenReturn(Mono.empty());
webTestClient.get()
.uri("/api/articles/non-existent")
.exchange()
.expectStatus().isNotFound();
}
@Test
void getArticles_returnsListOfPublishedArticles() {
List<Article> articles = List.of(
new Article("slug-1", "Article One"),
new Article("slug-2", "Article Two"),
new Article("slug-3", "Article Three")
);
when(articleService.findAllPublished()).thenReturn(Flux.fromIterable(articles));
webTestClient.get()
.uri("/api/articles")
.exchange()
.expectStatus().isOk()
.expectBodyList(Article.class)
.hasSize(3)
.contains(articles.get(0));
}
}WebTestClient: Bound and Unbound Modes
WebTestClient operates in two modes:
Bound to a controller (no real HTTP): Used by @WebFluxTest, or manually bound to a RouterFunction or WebHandler. Fast — no network overhead.
Bound to a server (real HTTP): Used with @SpringBootTest(webEnvironment = RANDOM_PORT). Tests the full application stack including filters, security, and real network.
// Manually binding to a RouterFunction (for functional endpoints)
@Test
void routerFunctionEndpointTest() {
RouterFunction<ServerResponse> routerFunction = RouterFunctions
.route(GET("/api/hello"), request ->
ServerResponse.ok().bodyValue("Hello, World!"));
WebTestClient client = WebTestClient
.bindToRouterFunction(routerFunction)
.build();
client.get().uri("/api/hello")
.exchange()
.expectStatus().isOk()
.expectBody(String.class).isEqualTo("Hello, World!");
}
// Bound to the full application with a real port
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ArticleIntegrationTest {
@Autowired
private WebTestClient webTestClient;
@Test
void fullStackTest_returnsArticleWithAllFields() {
webTestClient.get()
.uri("/api/articles/getting-started-spring")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.slug").exists()
.jsonPath("$.title").exists()
.jsonPath("$.content").exists()
.jsonPath("$.publishedAt").exists();
}
}expectBody() and expectBodyList()
WebTestClient provides rich body assertion APIs:
@WebFluxTest(ArticleController.class)
class ArticleBodyAssertionTest {
@Autowired
private WebTestClient webTestClient;
@MockBean
private ArticleService articleService;
@Test
void expectBody_withJsonPath() {
when(articleService.findById(1L)).thenReturn(Mono.just(
new Article(1L, "spring-webflux", "Spring WebFlux", 1500, ArticleStatus.PUBLISHED)
));
webTestClient.get().uri("/api/articles/1")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.id").isEqualTo(1)
.jsonPath("$.wordCount").isEqualTo(1500)
.jsonPath("$.status").isEqualTo("PUBLISHED")
.jsonPath("$.content").doesNotExist(); // should not expose content in list
}
@Test
void expectBody_deserializedToType() {
Article expected = new Article(1L, "spring-webflux", "Spring WebFlux");
when(articleService.findById(1L)).thenReturn(Mono.just(expected));
webTestClient.get().uri("/api/articles/1")
.exchange()
.expectStatus().isOk()
.expectBody(Article.class)
.consumeWith(result -> {
Article body = result.getResponseBody();
assertThat(body).isNotNull();
assertThat(body.getId()).isEqualTo(1L);
assertThat(body.getSlug()).isEqualTo("spring-webflux");
});
}
@Test
void expectBodyList_withSizeAndContent() {
when(articleService.findAll()).thenReturn(Flux.just(
new Article(1L, "article-1", "Article 1"),
new Article(2L, "article-2", "Article 2")
));
webTestClient.get().uri("/api/articles")
.exchange()
.expectStatus().isOk()
.expectBodyList(Article.class)
.hasSize(2)
.value(articles -> {
assertThat(articles).extracting(Article::getSlug)
.containsExactly("article-1", "article-2");
});
}
@Test
void serverSentEvents_streamEmitsAllItems() {
when(articleService.streamLatest()).thenReturn(
Flux.just("article-1", "article-2", "article-3")
.delayElements(Duration.ofMillis(10))
);
webTestClient.get()
.uri("/api/articles/stream")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.expectBodyList(String.class)
.hasSize(3)
.contains("article-1", "article-2", "article-3");
}
}StepVerifier: Testing Mono and Flux
StepVerifier is the core tool for testing reactive streams at the service layer. It subscribes to a publisher and lets you assert on items, errors, and completion signals in order.
class ArticleServiceReactiveTest {
private final ArticleRepository articleRepository = mock(ArticleRepository.class);
private final ArticleService articleService = new ArticleService(articleRepository);
@Test
void findBySlug_emitsArticleWhenFound() {
Article article = new Article("spring-webflux", "Spring WebFlux Guide");
when(articleRepository.findBySlug("spring-webflux")).thenReturn(Mono.just(article));
StepVerifier.create(articleService.findBySlug("spring-webflux"))
.expectNextMatches(a ->
a.getSlug().equals("spring-webflux") &&
a.getTitle().equals("Spring WebFlux Guide"))
.verifyComplete();
}
@Test
void findBySlug_emitsErrorWhenNotFound() {
when(articleRepository.findBySlug("missing")).thenReturn(Mono.empty());
StepVerifier.create(articleService.findBySlug("missing"))
.expectError(ArticleNotFoundException.class)
.verify();
}
@Test
void findAllPublished_emitsAllPublishedArticlesInOrder() {
when(articleRepository.findByStatus(ArticleStatus.PUBLISHED)).thenReturn(Flux.just(
new Article("article-1", "Article 1"),
new Article("article-2", "Article 2"),
new Article("article-3", "Article 3")
));
StepVerifier.create(articleService.findAllPublished())
.expectNextMatches(a -> a.getSlug().equals("article-1"))
.expectNextMatches(a -> a.getSlug().equals("article-2"))
.expectNextMatches(a -> a.getSlug().equals("article-3"))
.verifyComplete();
}
@Test
void publishArticle_transformsDraftToPublished() {
Article draft = new Article(1L, "spring-guide", "Spring Guide", ArticleStatus.DRAFT);
when(articleRepository.findById(1L)).thenReturn(Mono.just(draft));
when(articleRepository.save(any())).thenAnswer(inv -> Mono.just(inv.getArgument(0)));
StepVerifier.create(articleService.publish(1L))
.expectNextMatches(a -> a.getStatus() == ArticleStatus.PUBLISHED)
.verifyComplete();
}
@Test
void bulkDelete_returnsCountOfDeletedArticles() {
List<Long> ids = List.of(1L, 2L, 3L);
when(articleRepository.deleteAllById(ids)).thenReturn(Mono.just(3L));
StepVerifier.create(articleService.bulkDelete(ids))
.expectNext(3L)
.verifyComplete();
}
}StepVerifier.withVirtualTime() for Time-Based Operators
Testing reactive pipelines with delay, interval, timeout, or debounce operators in real time would make tests unbearably slow. withVirtualTime() advances virtual time without waiting:
@Test
void retryWithExponentialBackoff_retriesThreeTimesBeforeFailing() {
AtomicInteger attempts = new AtomicInteger(0);
StepVerifier.withVirtualTime(() ->
Mono.defer(() -> {
attempts.incrementAndGet();
return Mono.<Article>error(new RuntimeException("Service unavailable"));
})
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
.maxBackoff(Duration.ofSeconds(10)))
)
.expectSubscription()
.thenAwait(Duration.ofSeconds(14)) // enough for 3 retries with backoff
.expectError(RuntimeException.class)
.verify();
assertThat(attempts.get()).isEqualTo(4); // 1 original + 3 retries
}
@Test
void articleFeedRefreshes_everyFiveSeconds() {
StepVerifier.withVirtualTime(() ->
Flux.interval(Duration.ofSeconds(5))
.take(3)
.map(tick -> "refresh-" + tick)
)
.expectSubscription()
.thenAwait(Duration.ofSeconds(5))
.expectNext("refresh-0")
.thenAwait(Duration.ofSeconds(5))
.expectNext("refresh-1")
.thenAwait(Duration.ofSeconds(5))
.expectNext("refresh-2")
.verifyComplete();
}
@Test
void requestTimeout_triggersAfterThreeSeconds() {
StepVerifier.withVirtualTime(() ->
Mono.delay(Duration.ofSeconds(10))
.timeout(Duration.ofSeconds(3))
)
.expectSubscription()
.thenAwait(Duration.ofSeconds(3))
.expectError(TimeoutException.class)
.verify();
}Testing Reactive Repositories with @DataMongoTest
@DataMongoTest is the reactive equivalent of @DataJpaTest for MongoDB. It loads the MongoDB repository layer and auto-configures an embedded MongoDB (via de.flapdoodle.embed.mongo):
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo.spring30x</artifactId>
<scope>test</scope>
</dependency>@DataMongoTest
class ArticleReactiveRepositoryTest {
@Autowired
private ReactiveArticleRepository articleRepository;
@BeforeEach
void setUp() {
articleRepository.deleteAll().block();
}
@Test
void save_persistsAndReturnsArticle() {
Article article = new Article(null, "reactive-mongo", "Reactive MongoDB", ArticleStatus.PUBLISHED);
StepVerifier.create(articleRepository.save(article))
.expectNextMatches(saved ->
saved.getId() != null &&
saved.getSlug().equals("reactive-mongo"))
.verifyComplete();
}
@Test
void findByStatus_returnsOnlyPublishedArticles() {
List<Article> articles = List.of(
new Article(null, "pub-1", "Published 1", ArticleStatus.PUBLISHED),
new Article(null, "pub-2", "Published 2", ArticleStatus.PUBLISHED),
new Article(null, "draft-1", "Draft 1", ArticleStatus.DRAFT)
);
articleRepository.saveAll(articles).collectList().block();
StepVerifier.create(articleRepository.findByStatus(ArticleStatus.PUBLISHED))
.expectNextCount(2)
.verifyComplete();
}
@Test
void findBySlug_emitsEmptyForUnknownSlug() {
StepVerifier.create(articleRepository.findBySlug("does-not-exist"))
.verifyComplete(); // Mono.empty() — no items, no error, just completion
}
@Test
void countByStatus_returnsAccurateCount() {
articleRepository.saveAll(List.of(
new Article(null, "a1", "A1", ArticleStatus.PUBLISHED),
new Article(null, "a2", "A2", ArticleStatus.PUBLISHED),
new Article(null, "a3", "A3", ArticleStatus.DRAFT)
)).collectList().block();
StepVerifier.create(articleRepository.countByStatus(ArticleStatus.PUBLISHED))
.expectNext(2L)
.verifyComplete();
}
}Testing Error Handling in Reactive Streams
Error handling is critical in reactive pipelines. Test that your error operators (onErrorReturn, onErrorResume, doOnError) behave correctly:
class ArticleServiceErrorHandlingTest {
private final ArticleRepository articleRepository = mock(ArticleRepository.class);
private final ArticleService articleService = new ArticleService(articleRepository);
@Test
void findBySlug_fallsBackToCacheOnDatabaseError() {
when(articleRepository.findBySlug("cached-article"))
.thenReturn(Mono.error(new DataAccessException("DB unreachable") {}));
// Service should fall back to cache
StepVerifier.create(articleService.findBySlug("cached-article"))
.expectNextMatches(article -> article.getSlug().equals("cached-article"))
.verifyComplete();
}
@Test
void createArticle_propagatesValidationError() {
Article invalid = new Article(null, null, null, null); // missing required fields
StepVerifier.create(articleService.create(invalid))
.expectError(ValidationException.class)
.verify();
}
@Test
void batchProcess_continuesOnItemError() {
Flux<Article> articles = Flux.just(
new Article("valid-1", "Valid 1"),
new Article(null, null), // will fail processing
new Article("valid-2", "Valid 2")
);
StepVerifier.create(articleService.batchProcess(articles))
.expectNextMatches(r -> r.getSlug().equals("valid-1"))
.expectNextMatches(r -> r.getSlug().equals("valid-2"))
.verifyComplete();
}
}Testing Reactive Security
Reactive security uses @WithMockUser and SecurityMockServerConfigurers with WebTestClient:
@WebFluxTest(ArticleController.class)
@Import(SecurityConfig.class)
class ReactiveSecurityTest {
@Autowired
private WebTestClient webTestClient;
@MockBean
private ArticleService articleService;
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteArticle() {
when(articleService.delete(1L)).thenReturn(Mono.empty());
webTestClient.mutateWith(csrf())
.delete().uri("/api/articles/1")
.exchange()
.expectStatus().isNoContent();
}
@Test
void unauthenticatedCannotDeleteArticle() {
webTestClient.mutateWith(csrf())
.delete().uri("/api/articles/1")
.exchange()
.expectStatus().isUnauthorized();
}
@Test
void jwtAuthenticatedUserCanReadArticles() {
when(articleService.findAllPublished()).thenReturn(Flux.empty());
webTestClient
.mutateWith(mockJwt()
.jwt(jwt -> jwt.subject("user-123")
.claim("scope", "read:articles")))
.get().uri("/api/articles")
.exchange()
.expectStatus().isOk();
}
}Common Pitfalls
Never block in tests. Calling .block() inside a StepVerifier chain will deadlock. Use .block() only for setup/teardown, never inside the publisher being tested.
verifyComplete() vs verify(). verifyComplete() asserts that the sequence completed normally. verify() just starts the verification without an implicit completion assertion — use it when you expect an error.
Virtual time requires the publisher to be created inside the supplier. The lambda passed to withVirtualTime() must create the publisher lazily. Creating it outside the lambda means real time has already elapsed.
StepVerifier.create() is synchronous. It blocks the test thread until the subscription completes, times out, or errors. Set a Duration timeout via .verify(Duration.ofSeconds(5)) to prevent tests hanging indefinitely.
Continuous Monitoring for Reactive Applications
Reactive applications are inherently harder to reason about because of their asynchronous, non-blocking nature. A race condition, a missing error handler, or a backpressure misconfiguration can surface only under load, making continuous testing all the more critical.
HelpMeTest runs your WebFlux test suites continuously as part of your CI/CD pipeline, surfacing regressions in reactive behavior — including subtle timing issues and stream completion failures — before they reach production. Pair comprehensive WebTestClient and StepVerifier coverage with automated monitoring, and your reactive API becomes as reliably testable as any traditional synchronous application.