Back to Kubernetes: Core Concepts
Q4Question 4 of 16

Why you must subscribe to a reactive chain in tests with StepVerifier, and what happens if you never subscribe

Key Concepts
  • A Mono or Flux is lazy and cold: declaring operators builds a recipe, nothing runs until a subscriber subscribes.
  • StepVerifier.create(publisher) subscribes to the publisher and lets you assert each emitted item, error, or completion signal.
  • The terminal verify(), verifyComplete(), or verifyError() call is what triggers the subscription and blocks the test thread.
  • Forgetting the terminal verify means the chain never executes, assertions are never checked, and the test passes vacuously.
  • Use withVirtualTime to test chains containing delay or timeout without waiting in real time.
  • Always assert the terminal signal, not just intermediate items, because a chain that emits correct values then errors is still broken.

Definition

A reactive chain is a lazy recipe that runs only when subscribed; StepVerifier subscribes and its terminal verify triggers execution and blocks until done, so forgetting to subscribe means the test passes having tested nothing.
  • Lazy and cold – a Mono or Flux only 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(), or verifyError() 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.withVirtualTime lets you advance a virtual clock so chains with delay or timeout can 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.

JAVA
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.

JAVA
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.

JAVA
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.

JAVA
// 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 assertThrows wrapped around merely building the chain sees nothing.
  • Time-based operators like delay or timeout never advance.
  • The test passes with zero assertions evaluated, which is a dangerous false green.
JAVA
// 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.

JAVA
@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 |

JAVA
// 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:

JAVA
// 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 StepVerifier over block() for anything beyond the simplest single-value assertion.
JAVA
// 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

DIAGRAM
  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
DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate