How to Test Reactive Spring WebFlux Endpoints with StepVerifier

Reactive Spring WebFlux endpoints return asynchronous publishers rather than ordinary Java values. A controller may produce a Mono for one response, a Flux for a stream, or a pipeline that waits on several non-blocking services before sending anything to the client. Traditional assertions often miss timing, cancellation, empty results, and reactive errors.

StepVerifier gives you a precise way to subscribe to those publishers and describe the signals that should arrive. Combined with WebTestClient, it can test both the HTTP boundary and the Reactor pipeline underneath it. This is useful for Australian systems dealing with variable NBN connections, mobile clients in regional Queensland or Western Australia, and services deployed in an AWS Sydney region where real network timing must never be confused with deterministic unit-test behaviour.

Test target Recommended tool What to verify
Service or handler returns a Mono or Flux StepVerifier Values, completion, errors, cancellation, and request demand
WebFlux controller and HTTP mapping WebTestClient Status, headers, JSON body, and endpoint routing
Endpoint with delayed or scheduled work StepVerifier.withVirtualTime Time-based behaviour without waiting in real time
Streaming response WebTestClient with a reactive assertion Media type, emitted items, and stream completion
Failure from a downstream service StepVerifier plus WebTestClient Exception mapping and the resulting HTTP status

Understand The Reactive Test Boundary

A Mono<T> emits zero or one item, while a Flux<T> can emit any number of items. Neither type performs its work merely because it has been created. The pipeline usually starts when a subscriber requests data. StepVerifier becomes that subscriber and records the sequence of signals in a controlled test.

A basic service test looks like this:

@Test
void returnsCustomer() {
    Mono<Customer> result = customerService.findById("42");

    StepVerifier.create(result)
        .assertNext(customer -> {
            assertThat(customer.id()).isEqualTo("42");
            assertThat(customer.name()).isEqualTo("Mia");
        })
        .verifyComplete();
}

assertNext checks one emitted value and verifyComplete requires a normal completion signal. For several values, use expectNext, expectNextCount, or recordWith. An empty Mono should be checked with verifyComplete after expectComplete, while a failed publisher can use expectError, expectErrorMatches, or expectErrorSatisfies.

This distinction matters when an endpoint is tested at two levels. A service test should focus on business and reactive behaviour. An HTTP test should confirm that Spring converts that behaviour into the correct status code and response body. Testing both layers with the same broad assertion often makes failures harder to diagnose.

Set Up WebFlux Tests With WebTestClient

For a controller slice, @WebFluxTest loads WebFlux infrastructure and the selected controller without starting the complete application. Dependencies such as repositories or remote clients can be supplied with @MockBean, or with the appropriate replacement mechanism in newer Spring Boot versions.

@WebFluxTest(CustomerController.class)
class CustomerControllerTest {

    @Autowired
    WebTestClient client;

    @MockBean
    CustomerService customerService;

    @Test
    void getsCustomerAsJson() {
        given(customerService.findById("42"))
            .willReturn(Mono.just(new Customer("42", "Mia")));

        client.get()
            .uri("/customers/42")
            .exchange()
            .expectStatus().isOk()
            .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
            .expectBody()
            .jsonPath("$.id").isEqualTo("42")
            .jsonPath("$.name").isEqualTo("Mia");
    }
}

WebTestClient does not require a running HTTP server for this style of test. It binds directly to the WebFlux application context, which makes the test fast and avoids unstable network conditions. That is especially valuable when a build runs across Australian offices with different network paths or when developers switch between AEST and AEDT during daylight-saving months.

Use @SpringBootTest(webEnvironment = RANDOM_PORT) when you need to exercise a larger application configuration, filters, codecs, security, or server integration. The trade-off is slower execution and more moving parts. Keep most endpoint tests slice-focused, then retain a smaller number of full-context tests for configuration coverage.

Verify Values, Statuses, And Completion

A controller method often delegates directly to a service publisher:

@GetMapping("/{id}")
Mono<ResponseEntity<Customer>> get(@PathVariable String id) {
    return customerService.findById(id)
        .map(ResponseEntity::ok)
        .defaultIfEmpty(ResponseEntity.notFound().build());
}

The service-level test should prove the emitted domain object. The WebFlux test should prove that a present customer becomes HTTP 200 and an empty result becomes HTTP 404. This separation prevents a test from passing merely because the controller returned a publisher, without checking what that publisher actually emits.

For an endpoint returning a JSON array, WebTestClient can assert the decoded body:

client.get()
    .uri("/customers")
    .exchange()
    .expectStatus().isOk()
    .expectBodyList(Customer.class)
    .hasSize(2);

For a streaming endpoint, use an appropriate media type such as application/x-ndjson or text/event-stream, and assert each emitted item where practical. A stream that never completes is valid in some designs, so do not automatically require completion. Instead, request a bounded number of signals or cancel deliberately. This models clients that disconnect after receiving enough updates.

At the publisher level, cancellation is observable with doOnCancel, a test publisher, or a mock downstream component. For example, a Flux.interval source should stop when its consumer cancels. Testing that behaviour protects services from continuing expensive work after a browser, mobile app, or unreliable regional connection has gone away.

Control Delays With Virtual Time

Reactive tests become slow and flaky when they wait for real delays. StepVerifier.withVirtualTime replaces Reactor’s schedulers with a virtual clock, allowing a test to advance time immediately.

@Test
void emitsAfterFiveSeconds() {
    Supplier<Flux<Long>> scenario =
        () -> Flux.interval(Duration.ofSeconds(5)).take(2);

    StepVerifier.withVirtualTime(scenario)
        .expectSubscription()
        .expectNoEvent(Duration.ofSeconds(5))
        .thenAwait(Duration.ofSeconds(5))
        .expectNext(0L, 1L)
        .verifyComplete();
}

The publisher must be created inside the supplier. If it is constructed before withVirtualTime installs its scheduler, operators such as delayElements, timeout, and interval may still use real time. This is one of the most common mistakes in virtual-time tests.

Use expectNoEvent carefully because a subscription itself is a signal. Place expectSubscription() first when the scenario requires it. thenAwait advances the clock without asserting that no signal appeared during the interval, while expectNoEvent fails if an unexpected item, completion, or error arrives.

Virtual time is useful for retry and timeout policies:

StepVerifier.withVirtualTime(() ->
        unreliableCall.retryWhen(Retry.fixedDelay(2, Duration.ofSeconds(10))))
    .thenAwait(Duration.ofSeconds(20))
    .expectError(ServiceUnavailableException.class)
    .verify();

This test is deterministic whether it runs in Sydney, Perth, or a developer’s laptop. It also makes the intended resilience policy visible instead of hiding it behind a long Thread.sleep.

Test Errors, Empty Results, And Backpressure

Reactive errors are terminal signals. Once a publisher emits an error, it cannot emit another value or complete normally. Test the exact contract rather than accepting any exception:

StepVerifier.create(customerService.findById("missing"))
    .expectErrorMatches(error ->
        error instanceof CustomerNotFoundException &&
        error.getMessage().contains("missing"))
    .verify();

When the controller maps that exception with @ExceptionHandler or a global @RestControllerAdvice, add a WebTestClient test for the public API:

client.get()
    .uri("/customers/missing")
    .exchange()
    .expectStatus().isNotFound()
    .expectBody()
    .jsonPath("$.code").isEqualTo("CUSTOMER_NOT_FOUND");

Also cover malformed input, downstream timeouts, authentication failures, and unexpected exceptions. A service may correctly emit TimeoutException, but the endpoint should expose a stable status and safe response structure rather than an implementation detail.

Backpressure deserves attention for large exports, search results, and event feeds. StepVerifier.create(publisher, 0) starts with no demand. You can then call thenRequest(1) and verify that exactly one item becomes available:

StepVerifier.create(result, 0)
    .expectSubscription()
    .thenRequest(1)
    .expectNext(firstRecord)
    .thenRequest(1)
    .expectNext(secondRecord)
    .thenCancel()
    .verify();

This catches publishers that eagerly load all records into memory or ignore demand. Such behaviour may pass with a small local fixture but fail under production load, particularly when an Australian marketplace receives a burst of traffic during a public holiday sale.

Make Reactive Tests Reliable In CI

Mock asynchronous dependencies with Reactor-friendly tools. TestPublisher is useful when you need to control emissions manually, including invalid or delayed sequences. PublisherProbe verifies whether a fallback branch was subscribed to. Mockito can return Mono.just, Mono.empty, or Mono.error, but a plain synchronous mock does not test scheduling or cancellation by itself.

Avoid blocking inside tests unless the purpose is specifically to prove blocking detection. Calling .block() can conceal ordering and subscription problems, and it removes the demand and cancellation behaviour that StepVerifier is designed to inspect. Prefer assertions on the publisher, then use WebTestClient for HTTP semantics.

Use StepVerifierOptions to add scenario names and test descriptions. These labels make failures easier to interpret in a continuous delivery pipeline:

StepVerifier.create(result,
        StepVerifierOptions.create().scenarioName("customer lookup"))
    .expectNext(expected)
    .verifyComplete();

Keep scheduler configuration explicit. A test that depends on parallel execution can behave differently on a developer machine and a CI worker. Where ordering matters, use deterministic schedulers or inject a scheduler into the application component. Never use an external Sydney service, a real NBN connection, or wall-clock time to prove a unit-level reactive contract.

A practical workflow is to first write a StepVerifier test for the service publisher, then add a WebTestClient test for the controller mapping, and finally include one integration test for codecs, security, and filters. Start by implementing the smallest failing test for one endpoint—such as GET /customers/{id}—with a mocked Mono, an expected HTTP status, and verifyComplete() before expanding the scenario.