Unit testing is a fundamental aspect of software development, ensuring the reliability, correctness, and maintainability of our applications. In the realm of Spring Reactive, where asynchronous and event-driven programming paradigms prevail, testing takes on a unique significance. In this chapter, we’ll explore comprehensive testing practices for Spring Reactive applications, covering mainly unit tests but similar testing strategies can be utilised for other types of tests like integration and end-to-end tests.

Challenges of Unit Testing in Spring Reactive

Unit testing in Spring Reactive presents several challenges, primarily due to the asynchronous and event-driven nature of reactive programming. Traditional testing approaches may not suffice when dealing with reactive components such as Flux and Mono. Asynchronous execution introduces complexities such as timing issues, thread safety concerns, and the need for handling reactive streams effectively.

Best Practices for Unit Testing Spring Reactive

  • Embrace Reactive Testing Libraries: Leverage libraries such as Project Reactor’s StepVerifier or AssertJ’s reactive/asynchronous assertions to test reactive components effectively. These libraries provide utilities for composing and verifying reactive sequences, simplifying the testing process.
  • Mocking and Stubbing: Utilize mocking frameworks like Mockito to mock external dependencies and stub reactive data sources. Mocking enables isolation of the unit under test, ensuring focused and deterministic tests.
  • Test with Virtual Time: Spring provides support for virtual time in tests, allowing control over the progression of time within a test context. This is particularly useful for testing time-sensitive operations in reactive code, ensuring predictable and reproducible outcomes.
  • Use Virtual Schedulers: Project Reactor offers VirtualTimeScheduler for controlling the execution of reactive code in tests. By using VirtualTimeScheduler, you can simulate different execution contexts, such as parallelism and concurrency, enabling comprehensive testing of reactive scenarios.
  • Test Error Handling and Backpressure: Reactive applications must handle errors and backpressure gracefully to maintain stability and resilience. Write tests to validate error-handling mechanisms and backpressure strategies, ensuring robustness under varying conditions.

Here we will focus on Project Reactor’s StepVerifier for unit testing.

Getting Started

To start with Project Reactor’s StepVerifier, the following dependency should be incorporated into the dependency management file, such as build.gradle or pom.xml:

<!-- https://mvnrepository.com/artifact/io.projectreactor/reactor-test -->
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<version>${version}</version>
<scope>test</scope>
</dependency>

Understanding StepVerifier

StepVerifier is a testing utility provided by Project Reactor, designed specifically for testing reactive code. It allows developers to assert and verify the behavior of reactive streams in a concise and expressive manner. StepVerifier operates by subscribing to a Flux or Mono sequence and then asserting the emitted elements, terminal events, and errors.

StepVerifier Operators

create

StepVerifier.create() is used to transform a reactive publisher to StepVerifier which can be utilize for assertions.

Syntax:

static <T> FirstStep<T> create(Publisher<? extends T> publisher)

static <T> FirstStep<T> create(Publisher<? extends T> publisher, long n)

static <T> FirstStep<T> create(Publisher<? extends T> publisher,
StepVerifierOptions options)

Example:

// Transform the Mono publisher to StepVerifier
StepVerifier.create(Mono.just(1));

// Transform the Flux publisher to StepVerifier
Flux.just(1, 2, 3).as(StepVerifier::create);

// Transform the Flux publisher to StepVerifier and initially request for
// 2 elements
StepVerifier.create(Flux.just(1, 2, 3), 2);

// Transform the Flux publisher to StepVerifier with "Demo Scenario" as
// scenario name and initially request for 2 elements
StepVerifier.create(
Flux.just(1, 2, 3),
StepVerifierOptions.create()
.scenarioName("Demo Scenario")
.initialRequest(2));

expectNext

StepVerifier.expectNext() is the basic from of assertion where emitted elements can be asserted in ordered way.

Syntax:

Step<T> expectNext(T... ts)

Example:

// Transform the Mono publisher to StepVerifier and expect next element as 1
StepVerifier.create(Mono.just(1))
.expectNext(1);

// Transform the Flux publisher to StepVerifier and expect next elements are 1, 2, 3 in same sequence
Flux.just(1, 2, 3).as(StepVerifier::create)
.expectNext(1, 2, 3);

expectComplete

StepVerifier.expectComplete() is used to verify complete as terminal signal.

Syntax:

StepVerifier expectComplete()

Example:

// Transform the Mono publisher to StepVerifier and expect next element as 1
StepVerifier.create(Mono.just(1))
.expectNext(1)
.expectComplete();

// Transform the Flux publisher to StepVerifier and expect next elements are 1, 2, 3 in same sequence
Flux.just(1, 2, 3).as(StepVerifier::create)
.expectNext(1, 2, 3)
.expectComplete();

verify

StepVerifier.verify() is used to verify signals received by the subscriber. This method will block for maximum specified time or until the stream has been terminated (either through Subscriber. onComplete(), Subscriber. onError(Throwable) or Subscription. cancel()).

Syntax:

Duration verify() throws AssertionError

Duration verify(Duration duration) throws AssertionError

Example:

// Transform the Mono publisher to StepVerifier and expect next element as 1
StepVerifier.create(Mono.just(1))
.expectNext(1)
.expectComplete()
.verify();

// Transform the Flux publisher to StepVerifier and expect next elements are
// 1, 2, 3 in same sequence and wait for maximum 2 seconds
Flux.just(1, 2, 3).delayElements(Duration.ofSeconds(1)).as(StepVerifier::create)
.expectNext(1, 2, 3)
.expectComplete()
.verify(Duration.ofSeconds(2)); // we'll receive timed out exception

verifyComplete

StepVerifier.verifyComplete() is a syntactic sugar which internally expect complete terminal signal using expectComplete() and verifies the same using verify() with default timeout config.

Syntax:

Duration verifyComplete();

Example:

// Transform the Mono publisher to StepVerifier and expect next element as 1
StepVerifier.create(Mono.just(1))
.expectNext(1)
.verifyComplete();

// Transform the Flux publisher to StepVerifier and expect next elements are
// 1, 2, 3 in same sequence
Flux.just(1, 2, 3).delayElements(Duration.ofSeconds(1)).as(StepVerifier::create)
.expectNext(1, 2, 3)
.verifyComplete();

assertNext

StepVerifier.expectNext() is really great operator to perform verifications for primitives, simple objects but when it’s comes to verification for complex objects expectNext() isn’t capable to provide human readable simple error message. Here comes StepVerifier.assertNext() to rescue, where we can write complex readable assertions.

Syntax:

Step<T> assertNext(Consumer<? super T> assertionConsumer)

Example:

Flux.just(new Person(1, "John"), new Person(2, "Finnch"), new Person(3, "Chris"))
.as(StepVerifier::create)
.assertNext(element -> assertThat(element).isEqualTo(new Person(1, "John")))
.assertNext(element -> assertThat(element).isEqualTo(new Person(2, "Finnch")))
.assertNext(element -> assertThat(element).isEqualTo(new Person(3, "Chris")))
.verifyComplete();

expectNextCount

In some special scenarios we may want to just verify the number of emitted elements instead of actual value for each and every elements, in those case StepVerifier.expectNextCount() comes handy.

Syntax:

Step<T> expectNextCount(long count)

Example:

Flux.just(1, 2, 3)
.as(StepVerifier::create)
.expectNextCount(2)
.expectNext(3)
.verifyComplete();

expectError

StepVerifier.expectError() is the opposite of expectComplete(), used to verify error as terminal signal.

Syntax:

StepVerifier expectError()

StepVerifier expectError(Class<? extends Throwable> clazz)

Example:

// Transform the error Mono publisher to StepVerifier and expect it to
// terminate with error
Mono.error(new IllegalArgumentException("test error"))
.as(StepVerifier::create)
.expectError()
.verify();

// Transform the error Mono publisher to StepVerifier and expect it to
// terminate with IllegalArgumentException error
Mono.error(new IllegalArgumentException("test error"))
.as(StepVerifier::create)
.expectError(IllegalArgumentException.class)
.verify();

verifyError

StepVerifier.verifyError() is also a syntactic sugar similar to verifyComplete() which internally expect error terminal signal using expectError() and verifies the same using verify() with default timeout config.

Syntax:

StepVerifier verifyError()

StepVerifier verifyError(Class<? extends Throwable> clazz)

Example:

// Transform the error Mono publisher to StepVerifier and expect it to
// terminate with error
Mono.error(new IllegalArgumentException("test error"))
.as(StepVerifier::create)
.verifyError();

// Transform the error Mono publisher to StepVerifier and expect it to
// terminate with IllegalArgumentException error
Mono.error(new IllegalArgumentException("test error"))
.as(StepVerifier::create)
.verifyError(IllegalArgumentException.class);

verifyErrorMessage

StepVerifier.verifyErrorMessage() is used to verify error terminal signal with given message.

Syntax:

Duration verifyErrorMessage(String errorMessage)

Example:

// Transform the error Mono publisher to StepVerifier and expect it to
// terminate with "test error" error message
Mono.error(new IllegalArgumentException("test error"))
.as(StepVerifier::create)
.verifyErrorMessage("test error");

verifyErrorMatches

StepVerifier.verifyErrorMatches() is used to verify error terminal signal matches given predicate.

Syntax:

Duration verifyErrorMatches(Predicate<Throwable> predicate)

Example:

// Transform the error Mono publisher to StepVerifier and expect it to
// terminate with "test error" error message
Mono.error(new IllegalArgumentException("test error"))
.as(StepVerifier::create)
.verifyErrorMatches(throwable ->
throwable instanceof IllegalArgumentException
&& throwable.getMessage().equals("test error"));

verifyErrorSatisfies

With StepVerifier.verifyErrorMatches() the main problem is readability and assertion error message, which can be rectified by StepVerifier.verifyErrorSatisfies(). Using verifyErrorSatisfies we can easily write complex and custom assertions logic.

Syntax:

Duration verifyErrorSatisfies(Consumer<Throwable> assertionConsumer)

Example:

// Transform the error Mono publisher to StepVerifier and expect it to
// terminate with error message ending with "error"
Mono.error(new IllegalArgumentException("test error"))
.as(StepVerifier::create)
.verifyErrorSatisfies(throwable -> assertThat(throwable)
.isInstanceOf(IllegalArgumentException.class)
.hasMessageEndingWith("error"));

expectSubscription

StepVerifier.expectSubscription() is used to verify subscribe signal.

Syntax:

Step<T> expectSubscription()

Example:

Flux.just(1, 2, 3)
.delayElements(Duration.ofSeconds(1))
.as(StepVerifier::create)
.expectSubscription()
.expectNext(1, 2, 3)
.verifyComplete();

expectNoEvent

StepVerifier.expectNoEvent() is used to verify there should not be any signal/event emitted by the publisher for given duration.

Syntax:

Step<T> expectNoEvent(Duration duration)

Example:

Flux.just(1, 2, 3)
.delayElements(Duration.ofSeconds(1))
.as(StepVerifier::create)
.expectSubscription()
.expectNoEvent(Duration.ofSeconds(1))
.expectNext(1, 2, 3)
.verifyComplete();

thenAwait

StepVerifier.thenAwait() pauses the expectation evaluation for given duration. This method is mainly used with virtual time to advance time in controlled manner.

Syntax:

Step<T> thenAwait(Duration timeshift)

Example:

Flux.just(1, 2, 3)
.delayElements(Duration.ofSeconds(1))
.as(StepVerifier::create)
.expectSubscription()
.thenAwait(Duration.ofSeconds(3))
.expectNext(1, 2, 3)
.verifyComplete();

withVirtualTime

In reactive applications, operations often depend on time, such as delays, timeouts, or scheduling. Traditional testing approaches struggle with these aspects, as they typically require real-time delays, making tests non-deterministic and slow. Virtual time in StepVerifier addresses this challenge by simulating the passage of time in a controlled manner, without actually waiting for real time to elapse. This enables fast and deterministic testing of time-dependent operations.

To initialise a StepVerifier with virtual time capability, we need to make use of withVirtualTime() operator.

Syntax:

static <T> FirstStep<T> withVirtualTime(
Supplier<? extends Publisher<? extends T>> scenarioSupplier)

Example:

// Transform the Flux publisher of 10, 20 and 30 which emits elements every
// minute to StepVerifier with virtual time capability. Verifies publisher
// should emit last element 30 after 3 minutes of delay and terminates with
// complete signal but the overall verification should not take more than
// 500 milliseconds.

StepVerifier.withVirtualTime(() -> Flux.just(10, 20, 30)
.delayElements(Duration.ofMinutes(1)))
.thenAwait(Duration.ofMinutes(2))
.expectNextCount(2)
.thenAwait(Duration.ofMinutes(1))
.expectNext(30)
.expectComplete()
.verify(Duration.ofMillis(500));

Conclusion

In this article, we explored essential StepVerifier operators which are essential and commonly used in across different scenarios. Proficiency in these concepts establishes a sturdy groundwork for proper unit testing reactive applications. While we’ve covered some of the key operators, there are many more to discover which will be found on the StepVerifier documentation.

This article is a segment of the Cooking up Reactivity: The Spring Way series. To explore the entire series, click here.

Originally posted on medium.com.