Definition
- Lazy and cold – a
MonoorFluxonly builds a recipe of operators; nothing runs until a subscriber subscribes. - StepVerifier – the Reactor test tool that subscribes to a publisher and lets you assert the emitted sequence step by step.
- Terminal verify – the
verify(),verifyComplete(), orverifyError()call that both triggers the subscription and blocks the test thread until the sequence finishes. - Vacuous pass – if you forget the terminal verify, the chain never executes, no assertions are checked, and the test passes while having tested nothing.
- Virtual time –
StepVerifier.withVirtualTimelets you advance a virtual clock so chains withdelayortimeoutcan be tested instantly.
Think of a reactive chain at the National Bank of India like a money-transfer instruction form that Rajesh fills out. Writing the form does not move any money. Money only moves when someone submits the form to the teller, who processes each step. A Mono or Flux is that form: declaring it builds the instructions but moves nothing. Subscribing is submitting it to the teller. In a test, StepVerifier is the teller: it submits the form, watches each step, and checks that the right amounts appeared. If you never hand the form to the teller, nothing happens and your test is checking a form that was never processed.
Understanding the Concepts
What is a reactive chain and why is it lazy?
In Project Reactor, a Mono or Flux is lazy and cold by default. Declaring a chain of operators only builds a recipe. Nothing actually runs until a subscriber subscribes. This laziness is deliberate because it lets Reactor compose, schedule, and optimize the entire pipeline before execution.
package com.bank.service;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
public class AccountService {
// This method returns a Mono but does NOT execute anything yet.
// The chain is a blueprint, not an action.
public Mono<BigDecimal> getBalance(String accountId) {
return accountRepo.findById(accountId)
.map(Account::getBalance)
.filter(b -> b.signum() > 0);
}
}
Calling getBalance("SAV2001") returns a Mono but makes no database call. The database call only happens when something subscribes to the returned Mono.
Why must you subscribe in a test?
In production, the framework subscribes for you. When you return a Mono from a Spring WebFlux controller, Spring subscribes automatically. But in a unit test there is no framework subscribing on your behalf. If you call the method and do nothing with the returned Mono, the chain never runs.
package com.bank.service;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
class AccountServiceTest {
// WRONG: this test passes but tests nothing.
// The Mono is created but never subscribed.
@Test
void getBalance_wrong() {
AccountService service = new AccountService(mockRepo);
service.getBalance("SAV2001"); // Mono returned, never subscribed
// Test passes vacuously. No assertion ever runs.
}
}
This is the number-one reactive testing bug: a test that looks like it exercises logic but executes none of it.
How does StepVerifier work?
StepVerifier is the Reactor test tool designed to subscribe and assert in a controlled way. You create it with StepVerifier.create(publisher), declare expectations step by step, and then call a terminal method to trigger the subscription.
package com.bank.service;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import java.math.BigDecimal;
class AccountServiceTest {
// CORRECT: StepVerifier subscribes and asserts.
@Test
void getBalance_correct() {
AccountService service = new AccountService(mockRepo);
Mono<BigDecimal> balance = service.getBalance("SAV2001");
StepVerifier.create(balance)
.expectNext(new BigDecimal("1000000.00"))
.verifyComplete(); // This triggers the subscription
}
}
The key assertion methods are:
expectNext(value)asserts the next emitted item equals the value.expectNextMatches(predicate)asserts the next item satisfies a condition.expectError(SomeException.class)asserts the sequence fails with that error.verifyComplete()asserts the sequence finished without error.verifyError()asserts the sequence terminated with an error.
What is the role of the terminal verify call?
The terminal verify(), verifyComplete(), or verifyError() is what actually subscribes to the publisher and blocks the test thread until the sequence reaches a terminal signal or a timeout. Without it, nothing runs.
// WRONG: expectations declared but terminal verify missing.
// Nothing subscribes. Nothing runs. Test passes vacuously.
@Test
void transfer_missingVerify() {
Mono<TransferReceipt> receipt = transferService.execute(
"SAV2001", "SAV2002", new BigDecimal("50000.00"));
StepVerifier.create(receipt)
.expectNextMatches(r -> r.status().equals("COMPLETED"));
// Missing .verifyComplete() -- chain never runs!
}
// CORRECT: terminal verifyComplete triggers subscription.
@Test
void transfer_withVerify() {
Mono<TransferReceipt> receipt = transferService.execute(
"SAV2001", "SAV2002", new BigDecimal("50000.00"));
StepVerifier.create(receipt)
.expectNextMatches(r -> r.status().equals("COMPLETED"))
.verifyComplete(); // Now the chain actually runs
}
What happens if you never subscribe?
The consequences are concrete:
- The publisher's work never starts. Any side effect inside the chain, such as a database write or a balance debit, never happens.
- Any exception the chain would throw is never thrown. An
assertThrowswrapped around merely building the chain sees nothing. - Time-based operators like
delayortimeoutnever advance. - The test passes with zero assertions evaluated, which is a dangerous false green.
// WRONG: assertThrows around building the chain, not subscribing.
@Test
void insufficientFunds_wrong() {
// This does NOT throw because the chain is never subscribed.
assertThrows(InsufficientFundsException.class, () -> {
transferService.execute(
"SAV2001", "SAV2002", new BigDecimal("99999999.00"));
});
// Test FAILS -- no exception thrown because chain never ran.
}
// CORRECT: use StepVerifier to subscribe and assert the error.
@Test
void insufficientFunds_correct() {
Mono<TransferReceipt> result = transferService.execute(
"SAV2001", "SAV2002", new BigDecimal("99999999.00"));
StepVerifier.create(result)
.expectError(InsufficientFundsException.class)
.verify(); // Subscribes and asserts the error
}
How do you test time-based reactive chains?
StepVerifier.withVirtualTime replaces the real clock with a virtual one. You can advance the clock with thenAwait or expectNoEvent to test chains containing delay, timeout, or interval without waiting in real time.
@Test
void loanApprovalTimeout() {
// The service returns a Mono that times out after 30 seconds.
StepVerifier.withVirtualTime(() ->
loanService.approve("LOAN5001")
.timeout(java.time.Duration.ofSeconds(30)))
.expectSubscription()
.thenAwait(java.time.Duration.ofSeconds(30))
.expectError(java.util.concurrent.TimeoutException.class)
.verify();
}
The virtual clock makes this test complete instantly instead of waiting 30 real seconds.
How is StepVerifier different from block()?
block() is an alternative way to subscribe in a test. It subscribes, blocks, and returns the final value or throws the error. StepVerifier gives fine-grained, step-by-step assertions over the entire emitted sequence.
| Feature | block() | StepVerifier |
|---|---|---|
| Subscribes | Yes | Yes (on terminal verify) |
| Returns value | Yes, the final item | No, asserts inline |
| Multi-item assertions | No, only the last | Yes, each item in order |
| Error assertions | Via try-catch | Built-in expectError |
| Virtual time | No | Yes, withVirtualTime |
| Sequence shape | Cannot assert | Can assert order, count, signals |
// block() approach: simple but limited.
@Test
void getBalance_block() {
BigDecimal balance = accountService.getBalance("SAV2001").block();
assertEquals(new BigDecimal("1000000.00"), balance);
}
// StepVerifier approach: full sequence control.
@Test
void getBalance_stepVerifier() {
StepVerifier.create(accountService.getBalance("SAV2001"))
.expectNext(new BigDecimal("1000000.00"))
.verifyComplete();
}
What are common mistakes with reactive testing?
The most frequent mistakes and their fixes:
// MISTAKE 1: No terminal verify.
StepVerifier.create(mono)
.expectNext(value);
// FIX: add .verifyComplete();
// MISTAKE 2: Asserting on the Mono itself instead of subscribing.
assertNotNull(transferService.execute(from, to, amount));
// FIX: the Mono object is always non-null. Subscribe and assert the emitted value.
// MISTAKE 3: Not asserting the terminal signal.
StepVerifier.create(flux)
.expectNext(item1)
.expectNext(item2)
.verify(); // Does not check if the Flux completed or errored after item2.
// FIX: use .verifyComplete() to also assert clean completion.
What should a senior engineer know?
- Always assert the terminal signal, not just intermediate items. A chain that emits the right values but then errors is still broken.
- Use
verify(Duration)to bound tests. A hung chain should fail fast rather than blocking the entire test suite. - Be careful with hot publishers and shared mutable state, where multiple subscriptions can change behavior.
- The
as(StepVerifierOptions.create().scenarioName(...))variant gives named scenarios in failure output, which is useful for large banking test suites. - Prefer
StepVerifieroverblock()for anything beyond the simplest single-value assertion.
// Senior pattern: bounded verify with a timeout.
@Test
void transferWithTimeout() {
StepVerifier.create(transferService.execute(
"SAV2001", "SAV2002", new BigDecimal("50000.00")))
.expectNextMatches(r -> r.status().equals("COMPLETED"))
.verifyComplete();
// If the chain hangs, this fails after the default timeout
// instead of blocking the test suite indefinitely.
}
Visual Overview
Developer writes chain
│
▼
Mono/Flux (lazy recipe)
│
│ Nothing runs yet
▼
┌─────────────────────────────────────────┐
│ StepVerifier.create() │
│ Wraps publisher, prepares subscriber │
└──────────────────┬──────────────────────┘
│
Declare expectations:
expectNext(value)
expectError(type)
expectComplete
│
▼
┌─────────────────────────────────────────┐
│ Terminal: verifyComplete() │
│ 1. Subscribes to the publisher │
│ 2. Triggers execution of the chain │
│ 3. Blocks until terminal signal │
│ 4. Checks each expectation in order │
└──────────────────┬──────────────────────┘
│
┌────────┴────────┐
│ │
All passed Mismatch
Test GREEN Test RED
Without terminal verify:
Mono/Flux (lazy recipe) ──→ Never subscribed
│ │
│ No execution
│ No side effects
│ No assertions
▼ │
Test exits immediately ▼
Result: GREEN (false positive) Dangerous!
Key Takeaway
In Project Reactor a Mono or Flux is lazy and cold by default, which means declaring the chain of operators only builds a recipe and nothing runs until something subscribes. In production the framework subscribes for you, but in a unit test there is no framework, so you must trigger the subscription yourself. StepVerifier is the tool built for exactly that. It wraps the publisher, lets you declare expectations step by step, and the terminal verify call is what actually subscribes and blocks the test thread until the sequence finishes.
The most dangerous bug in reactive testing is forgetting the terminal verify. Without it, the chain never executes, no side effects happen, no exceptions are thrown, and no assertions are checked. The test passes with a green bar while having tested nothing. This is a silent false positive that can hide real bugs in critical banking logic like transfers and balance calculations.
On the banking project this bit us directly. A teammate wrote a test that called the transfer method, which returns a Mono, and asserted the balances expecting the debit to have happened. The test passed even when the transfer logic was completely broken, because the Mono was never subscribed. We fixed it by routing the call through StepVerifier with expectNextMatches for the receipt and verifyComplete as the terminal call.
Remember one sentence: a reactive test without a terminal verify is not a test, because the chain never ran.
---