Testing reactive streams with Reactor Test in Spring WebFlux
Reactive endpoints in Spring WebFlux can still be changing when a test receives a result. A response may arrive later, an error may replace the expected body, or a client may cancel its subscription before the publisher finishes. Reactor Test gives Java and Groovy teams a precise vocabulary for these events, so a test checks whether the asynchronous contract was honoured, not merely whether a value appeared.
For Australian teams, that precision is useful. Backends in Sydney and Melbourne often support products used across Australia, New Zealand and wider Asia-Pacific time zones, while distributed teams in Brisbane and Perth work without sharing an office clock. Reactive Stream verification turns those conditions into repeatable checks instead of vague waits and flaky retries.
Why reactive testing changes the mindset
WebFlux runs application work on an event-loop thread and models a response as a Publisher of signals. Unlike a conventional synchronous controller test, a reactive test must account for subscription, demand, emission, completion, error and cancellation. Reactor's StepVerifier API makes those lifecycle points explicit and gives failures a useful diagnostic trail.
| Verification style | Best fit | What it establishes | Main trade-off |
|---|---|---|---|
Direct StepVerifier |
A service returning Mono or Flux |
Values, errors, completion and request bounds | Does not exercise HTTP routing |
WebTestClient with @WebFluxTest |
A controller slice | Binding, routing, codecs and status | Omits parts of the application context |
WebTestClient against a running server |
The full WebFlux stack | Filters, security and server configuration | More setup and slower execution |
The key distinction is between a component contract and an end-to-end HTTP contract. Use the narrowest level that can prove the behaviour you care about, then add a smaller number of full-stack tests for interactions that only appear across filters, codecs or error handlers. This keeps feedback fast without hiding integration defects.
Setting up a focused Spring WebFlux test
Spring Boot's dependency management keeps the Reactor version used by spring-boot-starter-webflux aligned with reactor-test. Put the latter in the test scope, alongside the starter for WebFlux, and let the Boot parent or imported BOM manage versions. The same arrangement works in Maven and Gradle projects, common in Australian engineering teams that maintain both Java and Groovy services.
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
A @WebFluxTest gives WebTestClient access to a focused controller slice. It is a good home for REST routing, request-body binding and JSON assertions, while avoiding the cost and noise of booting every background component.
@WebFluxTest(GreetingController.class)
class GreetingControllerTest {
@Autowired WebTestClient client;
@Test
void returnsGreeting() {
client.get().uri("/greeting")
.exchange()
.expectStatus().isOk()
.expectBody(String.class).isEqualTo("hello");
}
}
When configuration, filters or security matter, start the application on a random port and inject a server-bound WebTestClient. For a publisher below the HTTP layer, skip the client and verify the returned Mono or Flux directly. Reserve block() for exceptional cases; blocking an event-loop thread in a test can hide the concurrency problem you are trying to reproduce.
Verifying emissions, demand and cancellation
StepVerifier describes what should happen after subscription. An expectation such as expectNext checks one value, expectComplete checks normal termination, and expectError verifies a typed failure. The final verify call waits for the sequence, while verifyComplete combines the terminal expectation with the wait.
StepVerifier.create(service.findCities("Sydney"))
.expectNext("Sydney")
.expectNext("Newcastle")
.verifyComplete();
A robust script also makes demand visible. Use expectRequest and thenRequest when the publisher has a bounded or unusual backpressure policy, and use a duration-based expectNoEvent to prove that silence is intentional rather than a missing event. For code that implements its own Subscriber, Reactor Test's TestPublisher and PublisherProbe can expose request counts, cancellation and other subscriber behaviour.
Avoid Thread.sleep: it makes the suite slower and less reliable because the assertion is tied to wall-clock timing instead of the observable sequence. Cancellation is a first-class outcome in Reactive Streams. A client disconnect, timeout or explicit downstream decision can stop a Flux before completion, so tests should use thenCancel when that is the expected behaviour.
Handling validation, errors and deterministic time
Reactive errors can arrive as values, so assertions must distinguish a successful zero-item Mono, a completed empty Flux, and an error signal. Match the expected exception with expectError or expectErrorMatches, and assert business details rather than accepting any RuntimeException. For validation rules that sit before reactive orchestration, this focused guide to custom Spring validators shows how to keep constraint checks clear and testable.
When a failure crosses several asynchronous boundaries, preserve enough context to identify its source. Assert the exception type, a stable error code and selected fields, but avoid matching a complete stack trace. In WebFlux, also check the HTTP status and serialized error body at the client level; a direct publisher test cannot reveal a mismatch introduced by an exception handler.
Time-sensitive tests need deterministic scheduling. Reactor Test's virtual time can advance a TestScheduler without waiting in real time, which suits retry intervals, timeouts and delayed emissions. For controller or server tests, configure the application to use that scheduler and verify the resulting sequence. Never use virtual time with code that reads the system clock directly, because the clock will remain outside the scheduler's controlled timeline.
Practical checks for a dependable reactive suite
Start with the behaviour contract, then add only the assertions that make a failure actionable. Many Australian organisations use a mixed delivery model, with local teams in Sydney or Melbourne and offshore partners in Asia. Reproducible commands and machine-readable reports matter across time zones, while commercial tooling quoted in AUD, with GST applied, can make extra CI capacity a real budget decision.
Useful assertions
- The first signal and its value or type
- The exact number of requested elements
- Completion, error or cancellation as appropriate
- Important side effects and emitted context
A passing publisher test is not enough when the service also writes to a repository or sends an event. Verify the consequential side effect, preferably through a deterministic fake or mock. In an Aussie team, clear assertion names also travel well between Sydney, Melbourne and offshore delivery partners, where “no worries” may end a discussion but a failed test still needs an unambiguous cause.
Fragility warning signs
- Arbitrary sleeps or generous timeout-only assertions
- Assertions on log text or unordered concurrent emissions
- Tests coupled to a particular event-loop thread
- A full application boot for every simple value check
Treat these patterns as design feedback. A flaky asynchronous test may indicate uncontrolled time, shared mutable state or an overly broad test boundary. Fix the cause at the smallest useful layer, then retain a full-stack check only where it protects a genuine integration risk.
The habit worth keeping
Reactor Test is most effective when a verification script mirrors the public contract: subscribe, request, observe signals, and settle only when the expected terminal event arrives. StepVerifier handles the mechanics, but the test still needs a precise statement about ordering, demand, errors, cancellation and side effects.
The habit to remember is to prove observable behaviour at the narrowest sensible boundary. A trustworthy reactive suite shows that Spring WebFlux emits the right results, respects backpressure, stops when required and survives the real conditions of an Australian delivery pipeline without relying on luck.