Reactor StepVerifier: Probar Flujos Reactivos
Conduce un Flux a traves de un guion de expectativas y verifica exactamente que emite, que falla y cuando termina, incluso adelantando los retrasos.
Los publishers reactivos son perezosos y asincronos, lo que los hace incomodos de probar con aserciones normales: un `Flux` no hace nada hasta que algo se suscribe, y sus valores pueden llegar en otro hilo en un momento impredecible. Project Reactor incluye `StepVerifier` (en el artefacto `reactor-test`) precisamente para esto. En lugar de recolectar resultados en una lista y verificar despues, escribes un *guion de expectativas* que se suscribe por ti, toma cada senal a medida que llega, y falla ruidosamente en el instante en que la realidad se desvia de tu guion. Nada se ejecuta realmente hasta que llamas al metodo terminal `verify()`, que bloquea el hilo del test hasta que la secuencia completa, falla, o agota el tiempo.
Anade primero la dependencia. Con Spring Boot el starter de WebFlux trae Reactor, y `org.springframework.boot:spring-boot-starter-test` ya incluye `io.projectreactor:reactor-test`, asi que `StepVerifier` esta en tu classpath de test desde el inicio. Construyes un verificador con `StepVerifier.create(publisher)`, encadenas expectativas en el orden de emision, y terminas con una verificacion terminal. La forma clasica del camino feliz es `expectNext(...)` por cada valor onNext, luego `expectComplete()` para afirmar que el flujo termino limpiamente, y luego `verify()` para ejecutarlo de verdad. Si olvidas `verify()` (o `verifyComplete()`), todo el guion es codigo muerto y el test pasa de forma vacia: este es el error mas comun con StepVerifier.
La familia onNext te da varias formas de afirmar valores. `expectNext(a, b, c)` empareja varios elementos por igualdad en orden; `expectNextCount(n)` empareja `n` elementos sin importar su contenido; y `expectNextMatches { it > 0 }` te permite afirmar con un predicado cuando la igualdad es demasiado rigida. `assertNext { assertThat(it.name).isEqualTo("Ada") }` es la opcion mas expresiva, entregando cada elemento a una lambda donde puedes usar AssertJ o JUnit sobre sus campos. Elige la expectativa mas laxa que aun pruebe lo que el test busca: sobreespecificar hace los tests fragiles ante cambios irrelevantes.
Las expectativas terminales deciden como debe terminar la secuencia. `expectComplete()` afirma un onComplete sin error, y el metodo de conveniencia `verifyComplete()` es exactamente `expectComplete().verify()` en uno. Para caminos de fallo, `expectError()` afirma que el flujo termino con *cualquier* error, `expectError(IllegalStateException::class.java)` afirma el tipo de error, y `expectErrorMatches { it is IllegalArgumentException && it.message == "bad id" }` comprueba tipo y mensaje. Tambien existe `verifyError(...)` como atajo fusionado. De forma crucial, un error y una completacion son senales terminales mutuamente excluyentes: un Flux que falla nunca emite onComplete, asi que escribe en el guion la que realmente esperas.
El terminal `verify()` devuelve un `Duration` (el tiempo real de reloj que tomo la verificacion) y acepta una sobrecarga con timeout, `verify(Duration.ofSeconds(5))`, de modo que un publisher colgado falla el test en vez de bloquear tu CI para siempre. Esto importa porque `verify()` es bloqueante por diseno: es el puente desde el mundo reactivo asincrono de vuelta al metodo de test sincrono. Termina siempre con una llamada terminal para que la suscripcion se cree y las aserciones se evaluen en el hilo llamante.
Los operadores basados en tiempo son donde StepVerifier de verdad brilla. Supon que tu Flux emite con `delayElements(Duration.ofHours(1))`: no quieres un test que duerma durante horas. `StepVerifier.withVirtualTime { ... }` instala un `VirtualTimeScheduler` para que el trabajo programado corra sobre un reloj falso que tu controlas. Luego avanzas ese reloj explicitamente con `thenAwait(Duration.ofHours(1))`, y los elementos programados para ese instante se liberan de inmediato. Pasa el publisher como una lambda `Supplier` (no una instancia ya construida) para que el scheduler virtual se instale *antes* de que los operadores capturen su scheduler: construir el Flux fuera de la lambda es un bug sutil que silenciosamente vuelve al reloj real.
Un guion tipico de tiempo virtual intercala espera y aserciones: `expectSubscription()`, luego `expectNoEvent(Duration.ofMillis(900))` para probar que nada se filtra antes de tiempo, luego `thenAwait(Duration.ofMillis(100))` para cruzar el limite del retraso, y luego `expectNext(value)`. La combinacion de `expectNoEvent` y `thenAwait` te deja verificar el tiempo con precision: que un elemento aparece *despues* de un retraso y no antes. Usa `expectSubscription()` como primer paso en tiempo virtual porque la senal de suscripcion es en si un evento que de otro modo tendrias que contabilizar.
Dos notas practicas cierran esto. Primero, al probar `WebClient` de Spring o repositorios R2DBC normalmente verificas el `Mono`/`Flux` devuelto directamente con StepVerifier en lugar de bloquear: manten la cadena reactiva intacta de extremo a extremo. Segundo, StepVerifier es para aserciones sobre *senales y tiempo*; para preparar secuencias controlables aguas arriba en los tests, combinalo con `Flux.just`, `Flux.error`, `Flux.range`, o un `TestPublisher` cuando necesites emitir senales manualmente. Juntos te permiten describir casi cualquier escenario reactivo como un test preciso, rapido y determinista.
import org.junit.jupiter.api.Testimport reactor.core.publisher.Fluximport reactor.test.StepVerifierclass GreetingFluxTest {@Testfun `emits names in order then completes`() {val names: Flux<String> = Flux.just("Ada", "Linus", "Grace")StepVerifier.create(names).expectNext("Ada").expectNext("Linus", "Grace") // several at once.expectComplete().verify() // without this the test is a no-op}}
Happy path: assert each emitted value in order, then assert clean completion. verify() actually runs the subscription and blocks until done.
import org.junit.jupiter.api.Testimport reactor.core.publisher.Fluximport reactor.test.StepVerifierclass UserFluxTest {data class User(val id: Long, val name: String)@Testfun `emits one user then fails`() {val flux: Flux<User> = Flux.concat(Flux.just(User(1, "Ada")),Flux.error(IllegalArgumentException("bad id")))StepVerifier.create(flux).assertNext { user -> check(user.name == "Ada") }.expectErrorMatches { it is IllegalArgumentException && it.message == "bad id" }.verify()}}
Error path plus flexible value matching. expectErrorMatches checks both the exception type and its message; assertNext inspects fields with a lambda.
import org.junit.jupiter.api.Testimport reactor.core.publisher.Fluximport reactor.test.StepVerifierimport java.time.Durationclass DelayedFluxTest {@Testfun `releases element only after the delay`() {StepVerifier.withVirtualTime {Flux.just("late").delayElements(Duration.ofHours(1))}.expectSubscription().expectNoEvent(Duration.ofMinutes(59)) // still silent.thenAwait(Duration.ofMinutes(1)) // cross the boundary.expectNext("late").verifyComplete() // = expectComplete().verify()}}
Virtual time: a one-hour delay is fast-forwarded instantly. Build the Flux INSIDE the supplier lambda so the virtual scheduler is in effect; expectNoEvent proves nothing leaks before the boundary.
import kotlinx.coroutines.delayimport kotlinx.coroutines.test.runTestimport kotlin.test.Testimport kotlin.test.assertEqualsclass VirtualClockTest {private suspend fun fetchLate(): String {delay(60_000) // skipped instantly by runTest's virtual timereturn "late"}@Testfun `delay is fast-forwarded`() = runTest {val result = fetchLate()assertEquals("late", result)}}
Plain kotlinx.coroutines analogue of 'fast-forward through a delay': runTest skips delay() on a virtual clock, the same idea StepVerifier.withVirtualTime applies to Reactor. This one runs on stdlib + coroutines.
Arena IDE🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. Why must the publisher passed to StepVerifier.withVirtualTime be built inside the supplier lambda rather than created beforehand and passed in?