Spring WebFlux Testing: WebTestClient, StepVerifier, and Reactive Repositories

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.

Read more