Testing Reactive Code with StepVerifier
Introduction
This tutorial ends the Reactive Spring module by testing reactive code. AMono or Flux is a description of signals that arrive later, so ordinary assertions on a return value do not work. Reactor's StepVerifier subscribes to a publisher and checks its signals one step at a time: the elements, their order, completion or the error. We will test operators, errors and empty results with it, see the report of a failing expectation, and use virtual time to test a pipeline that runs for three hours - in milliseconds. TestPublisher will feed hand-made signals into the code under test, and finally @WebFluxTest with WebTestClient will test a WebFlux controller, including a streaming endpoint. Every test run below comes from running the tests with JUnit 6 and Reactor 3.8.Why not just block()?
The quickest way to test aMono is assertThat(mono.block()).isEqualTo(...), and for a single value that is acceptable. But block() cannot tell a Flux that completed after three elements from one that would have emitted a fourth, does not check that a stream completes rather than hangs, loses the difference between an empty Mono and a null, and makes a test of a one-hour delay take an hour. StepVerifier, in the reactor-test library (part of Spring Boot's test starter), checks the signals themselves -
StepVerifier.create(publisher)starts a script;expectNext(...),expectNextCount(n),expectNextMatches(predicate)andassertNext(consumer)describe the elements, in order;expectComplete(),expectError(...)and their variants describe the end;verify()- or a shortcut such asverifyComplete()- subscribes, runs the script, and fails the test at the first signal that does not match. Without it, nothing is tested at all.
The code under test
A small price service with typical reactive logic: a running minimum over a stream of prices, a lookup that fails for an unknown book, a remote call with a timeout and a fallback, and an hourly report -src/main/java/.../testing/PriceFeed.java
//PriceFeed.java
package com.javabrahman.reactive.testing;
import reactor.core.publisher.Mono;
public interface PriceFeed {
Mono<Integer> currentPrice(String isbn); // a remote call
}
src/main/java/.../testing/PriceService.java
//PriceService.java
package com.javabrahman.reactive.testing;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.TimeoutException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public class PriceService {
private static final Map<String, String> TITLES = Map.of("111", "Effective Java", "222", "Clean Code");
private final PriceFeed feed;
public PriceService(PriceFeed feed) {
this.feed = feed;
}
// emits a price whenever it is lower than every price before it
public Flux<Integer> newLows(Flux<Integer> prices) {
return prices.scan(Math::min).distinctUntilChanged();
}
public Mono<String> title(String isbn) {
return Mono.justOrEmpty(TITLES.get(isbn))
.switchIfEmpty(Mono.error(new IllegalArgumentException("unknown isbn " + isbn)));
}
// the remote price, or the list price 450 if the feed does not answer within 2 seconds
public Mono<Integer> price(String isbn) {
return feed.currentPrice(isbn)
.timeout(Duration.ofSeconds(2))
.onErrorReturn(TimeoutException.class, 450);
}
public Flux<String> hourlyReport() {
return Flux.interval(Duration.ofHours(1)).map(i -> "report " + (i + 1)).take(3);
}
}
Elements, completion, errors and empty results
src/test/java/.../testing/PriceServiceTest.java
//PriceServiceTest.java
package com.javabrahman.reactive.testing;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
class PriceServiceTest {
private final PriceService service = new PriceService(isbn -> Mono.just(399));
@Test
void emitsOnlyNewLows() {
StepVerifier.create(service.newLows(Flux.just(450, 470, 420, 430, 399, 399)))
.expectNext(450, 420, 399)
.verifyComplete();
}
@Test
void checksElementsWithConditions() {
StepVerifier.create(service.newLows(Flux.range(1, 100).map(i -> 1000 - i)))
.expectNextMatches(price -> price == 999)
.expectNextCount(98)
.assertNext(last -> assertThat(last).isEqualTo(900))
.verifyComplete();
}
@Test
void failsForAnUnknownIsbn() {
StepVerifier.create(service.title("999"))
.expectErrorSatisfies(e -> assertThat(e)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("unknown isbn 999"))
.verify();
}
@Test
void anEmptyStreamOnlyCompletes() {
StepVerifier.create(service.newLows(Flux.empty()))
.verifyComplete(); // no onNext at all
}
@Test
void cancelsAnInfiniteStream() {
StepVerifier.create(service.newLows(Flux.generate(sink -> sink.next(500))))
.expectNext(500)
.thenCancel() // the source never completes; stop after the first element
.verify();
}
}
╷
└─ JUnit Jupiter ✔
└─ PriceServiceTest ✔
├─ emitsOnlyNewLows() ✔
├─ checksElementsWithConditions() ✔
├─ failsForAnUnknownIsbn() ✔
├─ cancelsAnInfiniteStream() ✔
└─ anEmptyStreamOnlyCompletes() ✔
[ 2 containers found ]
[ 0 containers skipped ]
[ 2 containers started ]
[ 0 containers aborted ]
[ 2 containers successful ]
[ 0 containers failed ]
[ 5 tests found ]
[ 0 tests skipped ]
[ 5 tests started ]
[ 0 tests aborted ]
[ 5 tests successful ]
[ 0 tests failed ]
expectNext(450, 420, 399)followed byverifyComplete()checks the exact sequence and that nothing more follows: a fourth element would fail the test.- For long streams,
expectNextCount()skips over elements, andassertNext()runs any AssertJ assertion on one. - An error is the expected end of
failsForAnUnknownIsbn();expectErrorSatisfies()inspects it, andexpectError(Type.class)orverifyErrorMessage()are shorter forms. - An infinite stream is ended by the script with
thenCancel().
When an expectation fails
A test that expects 400 where the stream emits 420 -src/test/java/.../testing/WrongExpectationTest.java
//WrongExpectationTest.java
package com.javabrahman.reactive.testing;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
class WrongExpectationTest {
private final PriceService service = new PriceService(isbn -> Mono.just(399));
@Test
void expectsTheWrongLow() {
StepVerifier.create(service.newLows(Flux.just(450, 470, 420)))
.expectNext(450)
.expectNext(400)
.verifyComplete();
}
}
└─ expectsTheWrongLow() ✘ expectation "expectNext(400)" failed (expected value: 400; actual value: 420) [ 0 tests successful ] [ 1 tests failed ]
expectNext(400) - with the expected and the actual value. Naming steps with .as("description") makes such reports easier to read in long scripts.
Virtual time
hourlyReport() emits three reports, one hour apart. A real-time test would take three hours, and the timeout of price() would cost two seconds per test. StepVerifier.withVirtualTime() replaces Reactor's time-based schedulers with a VirtualTimeScheduler whose clock only moves when the script says so: thenAwait(duration) advances it, and expectNoEvent(duration) advances it while checking that nothing happens -
src/test/java/.../testing/VirtualTimeTest.java
//VirtualTimeTest.java
package com.javabrahman.reactive.testing;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
class VirtualTimeTest {
@Test
void threeHourlyReportsInMilliseconds() {
PriceService service = new PriceService(isbn -> Mono.just(399));
Duration took = StepVerifier.withVirtualTime(service::hourlyReport) // a Supplier: built inside virtual time
.expectSubscription()
.expectNoEvent(Duration.ofHours(1).minusSeconds(1))
.thenAwait(Duration.ofSeconds(1))
.expectNext("report 1")
.thenAwait(Duration.ofHours(2))
.expectNext("report 2", "report 3")
.verifyComplete();
assertThat(took).isLessThan(Duration.ofSeconds(1)); // three virtual hours, well under a real second
}
@Test
void fallsBackWhenTheFeedNeverAnswers() {
PriceService service = new PriceService(isbn -> Mono.never()); // a remote call that hangs
StepVerifier.withVirtualTime(() -> service.price("111"))
.expectSubscription()
.expectNoEvent(Duration.ofSeconds(2))
.expectNext(450)
.verifyComplete();
}
@Test
void usesTheFeedWhenItAnswersInTime() {
PriceService service = new PriceService(isbn -> Mono.just(399).delayElement(Duration.ofSeconds(1)));
StepVerifier.withVirtualTime(() -> service.price("111"))
.thenAwait(Duration.ofSeconds(1))
.expectNext(399)
.verifyComplete();
}
}
╷
└─ JUnit Jupiter ✔
└─ VirtualTimeTest ✔
├─ fallsBackWhenTheFeedNeverAnswers() ✔
├─ usesTheFeedWhenItAnswersInTime() ✔
└─ threeHourlyReportsInMilliseconds() ✔
[ 2 containers found ]
[ 0 containers skipped ]
[ 2 containers started ]
[ 0 containers aborted ]
[ 2 containers successful ]
[ 0 containers failed ]
[ 3 tests found ]
[ 0 tests skipped ]
[ 3 tests started ]
[ 0 tests aborted ]
[ 3 tests successful ]
[ 0 tests failed ]
verify() returns the real duration, which the first test asserts. Two rules: the publisher must be created inside the supplier passed to withVirtualTime(), so that its operators pick up the virtual scheduler; and the script should start with expectSubscription() before expectNoEvent(), because the subscription itself counts as an event.
TestPublisher: signals made by hand
To test how code reacts to a source, it helps to control that source exactly.TestPublisher emits whatever the test tells it to, when it tells it to - and can also misbehave, to test robustness -
src/test/java/.../testing/TestPublisherTest.java
//TestPublisherTest.java
package com.javabrahman.reactive.testing;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import reactor.test.publisher.TestPublisher;
class TestPublisherTest {
private final PriceService service = new PriceService(isbn -> Mono.just(399));
@Test
void reactsToEachPriceAsItArrives() {
TestPublisher<Integer> prices = TestPublisher.create();
StepVerifier.create(service.newLows(prices.flux()))
.then(() -> prices.next(450))
.expectNext(450)
.then(() -> prices.next(470, 440))
.expectNext(440)
.then(() -> prices.error(new IllegalStateException("feed lost")))
.expectErrorMessage("feed lost")
.verify();
prices.assertWasSubscribed();
prices.assertNoRequestOverflow(); // the code never received more than it requested
}
}
╷
└─ JUnit Jupiter ✔
└─ TestPublisherTest ✔
└─ reactsToEachPriceAsItArrives() ✔
[ 2 containers found ]
[ 0 containers skipped ]
[ 2 containers started ]
[ 0 containers aborted ]
[ 2 containers successful ]
[ 0 containers failed ]
[ 1 tests found ]
[ 0 tests skipped ]
[ 1 tests started ]
[ 0 tests aborted ]
[ 1 tests successful ]
[ 0 tests failed ]
then() runs code between expectations - here, emitting the next prices - so the test controls the exact interleaving of input and output. The service passed the error on, and never received more elements than it requested.
Testing a WebFlux controller with WebTestClient
For the web layer,@WebFluxTest is the reactive counterpart of @WebMvcTest: a slice with only the WebFlux infrastructure and the named controller, and a WebTestClient that calls it without a real server. The controller under test, with a JSON list, a 404 and an NDJSON stream -
src/main/java/.../testing/web/BookController.java
//BookController.java
package com.javabrahman.reactive.testing.web;
import java.time.Duration;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
public class BookController {
private static final Map<Long, Book> BOOKS = Map.of(1L, new Book(1, "Effective Java", 450), 2L, new Book(2, "Clean Code", 380));
public record Book(long id, String title, int price) {
}
@GetMapping("/books/{id}")
public Mono<Book> one(@PathVariable long id) {
return Mono.justOrEmpty(BOOKS.get(id))
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND)));
}
@GetMapping(value = "/books/stream", produces = MediaType.APPLICATION_NDJSON_VALUE)
public Flux<Book> stream() {
return Flux.just(BOOKS.get(1L), BOOKS.get(2L)).delayElements(Duration.ofMillis(100));
}
}
src/main/java/.../testing/web/TestApp.java
//TestApp.java
package com.javabrahman.reactive.testing.web;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TestApp {
}
src/test/java/.../testing/web/BookControllerTest.java
//BookControllerTest.java
package com.javabrahman.reactive.testing.web;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webflux.test.autoconfigure.WebFluxTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
@WebFluxTest(BookController.class)
class BookControllerTest {
@Autowired
WebTestClient client;
@Test
void returnsABook() {
client.get().uri("/books/1").exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.title").isEqualTo("Effective Java")
.jsonPath("$.price").isEqualTo(450);
}
@Test
void answers404ForAnUnknownBook() {
client.get().uri("/books/42").exchange()
.expectStatus().isNotFound();
}
@Test
void streamsBooksOneByOne() {
Flux<BookController.Book> body = client.get().uri("/books/stream")
.accept(MediaType.APPLICATION_NDJSON)
.exchange()
.expectStatus().isOk()
.returnResult(BookController.Book.class)
.getResponseBody();
StepVerifier.create(body)
.expectNextMatches(book -> book.title().equals("Effective Java"))
.expectNextMatches(book -> book.title().equals("Clean Code"))
.verifyComplete();
}
}
╷
└─ JUnit Jupiter ✔
└─ BookControllerTest ✔
├─ answers404ForAnUnknownBook() ✔
├─ streamsBooksOneByOne() ✔
└─ returnsABook() ✔
[ 2 containers found ]
[ 0 containers skipped ]
[ 2 containers started ]
[ 0 containers aborted ]
[ 2 containers successful ]
[ 0 containers failed ]
[ 3 tests found ]
[ 0 tests skipped ]
[ 3 tests started ]
[ 0 tests aborted ]
[ 3 tests successful ]
[ 0 tests failed ]
expectBody().jsonPath() checks a JSON response, and returnResult().getResponseBody() hands a streaming body back as a Flux, which StepVerifier verifies element by element. Spring Boot 4 places @WebFluxTest in spring-boot-webflux-test (package org.springframework.boot.webflux.test.autoconfigure), included with spring-boot-starter-webflux-test. WebTestClient also works against a full @SpringBootTest on a random port, and even against a Spring MVC application.
Habits for reactive tests
- Always end with a
verifycall: a StepVerifier script without one never subscribes, and the test passes without checking anything. - Check the end of the stream:
verifyComplete()or an error expectation, so that extra elements and missing completions fail. - Use virtual time for anything with delays, intervals, timeouts or retries with backoff, and build the publisher inside the supplier.
- Set a safety net:
verify(Duration.ofSeconds(5)), orStepVerifier.setDefaultTimeout(), turns a stream that hangs into a failure instead of a frozen build. - Test blocking behaviour too: the BlockHound agent makes tests fail when code blocks on a non-blocking thread.
Summary
In this tutorial we tested reactive code with Reactor's test tools. StepVerifier checked a running minimum element by element and its completion, inspected an error, verified an empty stream and cancelled an infinite one, and reported exactly which step failed when an expectation was wrong. Virtual time verified three hourly reports, a two-second timeout and a delay in well under a second, andTestPublisher fed prices and an error into the service under the test's control. @WebFluxTest with WebTestClient tested a controller's JSON, its 404 and an NDJSON stream. This completes the Reactive Spring module: ReactorClick to read tutorial: Reactive Programming with Project Reactor: Mono and Flux, WebFluxClick to read tutorial: Building REST APIs with Spring WebFlux, WebClientClick to read tutorial: Calling HTTP APIs with Spring WebClient, R2DBCClick to read tutorial: Reactive Database Access with Spring Data R2DBC, SSE and WebSocketsClick to read tutorial: Server-Sent Events and WebSockets in Spring WebFlux, and when to choose virtual threads insteadClick to read tutorial: Virtual Threads vs WebFlux: Which to Choose in 2026.Reactive Spring Tutorials
Virtual Threads vs WebFlux: Which to Choose in 2026Click to read tutorial: Virtual Threads vs WebFlux: Which to Choose in 2026 Reactive Programming with Project Reactor: Mono and FluxClick to read tutorial: Reactive Programming with Project Reactor: Mono and Flux Building REST APIs with Spring WebFluxClick to read tutorial: Building REST APIs with Spring WebFlux Calling HTTP APIs with Spring WebClientClick to read tutorial: Calling HTTP APIs with Spring WebClient Reactive Database Access with Spring Data R2DBCClick to read tutorial: Reactive Database Access with Spring Data R2DBC Server-Sent Events and WebSockets in Spring WebFluxClick to read tutorial: Server-Sent Events and WebSockets in Spring WebFlux
Virtual Threads vs WebFlux: Which to Choose in 2026Click to read tutorial: Virtual Threads vs WebFlux: Which to Choose in 2026 Reactive Programming with Project Reactor: Mono and FluxClick to read tutorial: Reactive Programming with Project Reactor: Mono and Flux Building REST APIs with Spring WebFluxClick to read tutorial: Building REST APIs with Spring WebFlux Calling HTTP APIs with Spring WebClientClick to read tutorial: Calling HTTP APIs with Spring WebClient Reactive Database Access with Spring Data R2DBCClick to read tutorial: Reactive Database Access with Spring Data R2DBC Server-Sent Events and WebSockets in Spring WebFluxClick to read tutorial: Server-Sent Events and WebSockets in Spring WebFlux