Probando el stack reactivo: runTest, StepVerifier y WebTestClient
El codigo asincrono merece pruebas sincronas y deterministas, y el ecosistema de la JVM te da una herramienta precisa para cada capa.
Lo mas dificil de probar codigo reactivo y concurrente es que el trabajo no termina cuando la linea de codigo retorna. Una funcion suspend se suspende, un Flux emite mas tarde, una corrutina se lanza en otro dispatcher. Si tu prueba simplemente llama al codigo y verifica de inmediato, estas compitiendo contra el runtime y tus aserciones pasaran o fallaran al azar. El objetivo de las herramientas de esta leccion es hacer determinista la ejecucion asincrona: le indicas al framework que lleve el trabajo hasta su finalizacion y luego verificas el resultado. Elige la herramienta que coincide con el tipo que pruebas — runTest para suspend y Flow, StepVerifier para Mono y Flux, y WebTestClient para endpoints HTTP — y tus pruebas seran tan aburridas y confiables como las sincronas.
Para corrutinas, kotlinx-coroutines-test te da runTest. Ejecuta el cuerpo en un TestScope respaldado por un scheduler especial que usa tiempo virtual, asi que cualquier delay() dentro de la corrutina se omite al instante en lugar de dormir de verdad. Una prueba que ejercita codigo con un delay de cinco segundos termina en microsegundos. runTest tambien instala un StandardTestDispatcher, espera a que todas las corrutinas hijas lanzadas en su scope terminen antes de retornar, y relanza las excepciones no capturadas para que los fallos aparezcan como fallos de prueba. Nunca necesitas Thread.sleep ni timeouts arbitrarios con la esperanza de que el trabajo haya terminado a tiempo.
El tiempo virtual es la caracteristica que convierte pruebas de temporizacion fragiles en pruebas precisas. Dentro de runTest puedes tomar el scheduler del TestScope y avanzar el tiempo tu mismo con advanceTimeBy(duracion), advanceUntilIdle() para ejecutar todo lo pendiente, o runCurrent() para ejecutar las tareas programadas en el instante actual. Esto te permite verificar los estados intermedios exactos de logica dependiente del tiempo — debounce, reintentos con backoff, polling, ventanas de timeout — sin esperar nunca en tiempo real de reloj. Una trampa comun: si lanzas trabajo en un dispatcher real como Dispatchers.IO en lugar del de prueba, el tiempo virtual no aplica, asi que inyecta los dispatchers en tus clases en vez de fijarlos en codigo.
Probar un Flow sigue la misma filosofia. Como un Flow es frio, nada ocurre hasta que lo recolectas, asi que el patron canonico es llamar a toList() dentro de runTest y verificar los elementos recolectados. Para flows con operadores de temporizacion avanzas el tiempo virtual entre aserciones. Para aserciones de Flow mas ricas y legibles muchos equipos usan la libreria Turbine, cuyo bloque test { } te permite esperar elementos uno a uno con awaitItem(), verificar la finalizacion con awaitComplete() y los errores con awaitError() — ademas falla ruidosamente si el flow emite algo que no consumiste, atrapando bugs sutiles.
Cuando trabajas directamente con tipos de Reactor — Mono y Flux, que es lo que devuelven los handlers de Spring WebFlux y los repositorios reactivos — la herramienta idiomatica es StepVerifier de reactor-test. Envuelves el publisher en StepVerifier.create(...) y describes la secuencia esperada paso a paso: expectNext para valores emitidos, expectError o expectErrorMatches para fallos, expectComplete para la terminacion normal, y finalmente verify() para suscribirte realmente y bloquear hasta comprobar las aserciones. Olvidar la llamada terminal verify() es el error clasico — sin ella nada se suscribe y la prueba pasa en silencio. StepVerifier tiene su propio soporte de tiempo virtual via StepVerifier.withVirtualTime, que sustituye un reloj virtual para que puedas llamar a thenAwait(Duration) y adelantar delays y operadores temporales de forma determinista.
Para la capa HTTP, Spring ofrece WebTestClient, la contraparte reactiva de MockMvc. Puede enlazarse a todo el contexto de tu aplicacion sobre un puerto real, a un solo controlador, o directamente a una RouterFunction para endpoints funcionales, y habla una API de aserciones fluida: construyes una peticion, haces exchange() y encadenas aserciones expectStatus, expectHeader y expectBody. Lo crucial es que WebTestClient maneja por ti la naturaleza asincrona de WebFlux — exchange() se suscribe y espera la respuesta internamente — asi que tu codigo de prueba se lee de forma sincrona aunque el servidor sea totalmente no bloqueante. Puedes verificar el body deserializado, el JSON crudo con jsonPath, o recibir Server-Sent Events como un Flux y verificarlos con StepVerifier.
Algunas practicas mantienen confiable toda la suite. Inyecta siempre tus dispatchers (y el equivalente Scheduler de Reactor) para que las pruebas puedan sustituirlos por deterministas; un Dispatchers.IO o Schedulers.boundedElastic() fijado en codigo derrota el tiempo virtual. Prefiere probar el comportamiento a traves de la API publica suspend o reactiva en lugar de hurgar en el estado interno de las corrutinas. Agrega un UnconfinedTestDispatcher solo cuando quieras especificamente que las corrutinas hijas se ejecuten con avidez y en profundidad; el StandardTestDispatcher por defecto suele ser lo que quieres porque modela una planificacion realista. Y recuerda el puente entre mundos: una funcion suspend que consume internamente un Mono via .awaitSingle() se puede probar con runTest, mientras que un controlador que devuelve Flux se prueba mejor de extremo a extremo con WebTestClient.
Por ultimo, trata los casos de error y los limites como ciudadanos de primera clase. StepVerifier.expectError te deja verificar el tipo exacto de excepcion que emite una tuberia reactiva; runTest con assertFailsWith verifica que una funcion suspend lanza; y WebTestClient.expectStatus().is5xxServerError() o .isBadRequest() fija como tu endpoint mapea fallos a HTTP. Combinadas con el tiempo virtual para reintentos y timeouts, estas herramientas te permiten demostrar no solo el camino feliz sino tambien la cancelacion, los errores por backpressure y los fallbacks con limite de tiempo — exactamente los escenarios casi imposibles de reproducir con pruebas ingenuas basadas en sleep.
import kotlinx.coroutines.launchimport kotlinx.coroutines.delayimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.flowimport kotlinx.coroutines.flow.toListimport kotlinx.coroutines.test.advanceTimeByimport kotlinx.coroutines.test.runCurrentimport kotlinx.coroutines.test.runTestimport kotlin.test.Testimport kotlin.test.assertEqualsfun tickerFlow(): Flow<Int> = flow {repeat(3) { i ->delay(1000)emit(i)}}class TickerTest {@Testfun emitsEverySecond() = runTest {// toList drives the cold flow to completion; the 3s of delays// are virtual, so the test finishes almost instantly.val result = tickerFlow().toList()assertEquals(listOf(0, 1, 2), result)}@Testfun stepsThroughVirtualTime() = runTest {val collected = mutableListOf<Int>()val job = launch { tickerFlow().collect { collected.add(it) } }advanceTimeBy(1000); runCurrent()assertEquals(listOf(0), collected)advanceTimeBy(2000); runCurrent()assertEquals(listOf(0, 1, 2), collected)job.cancel()}}
runTest with virtual time: a delay of 1 second is skipped instantly, and advanceTimeBy lets you assert intermediate state. Runs on kotlinx-coroutines-test alone.
Arena IDEimport org.junit.jupiter.api.Testimport reactor.core.publisher.Fluximport reactor.core.publisher.Monoimport reactor.test.StepVerifierimport java.time.Durationclass ReactorTest {@Testfun verifiesFluxSequence() {val flux = Flux.just("a", "b", "c")StepVerifier.create(flux).expectNext("a", "b").expectNext("c").verifyComplete() // subscribes and blocks until assertions checked}@Testfun verifiesError() {val mono = Mono.error<String>(IllegalStateException("boom"))StepVerifier.create(mono).expectErrorMatches { it is IllegalStateException && it.message == "boom" }.verify()}@Testfun fastForwardsDelaysWithVirtualTime() {// The Flux would take 2 hours in real time; virtual time makes it instant.StepVerifier.withVirtualTime { Flux.interval(Duration.ofHours(1)).take(2) }.expectSubscription().thenAwait(Duration.ofHours(2)).expectNext(0L, 1L).verifyComplete()}}
StepVerifier for Reactor Mono/Flux, including virtual-time fast-forwarding with withVirtualTime. The terminal verify()/verifyComplete() is required or nothing subscribes.
import org.junit.jupiter.api.Testimport org.springframework.beans.factory.annotation.Autowiredimport org.springframework.boot.test.context.SpringBootTestimport org.springframework.boot.test.context.SpringBootTest.WebEnvironmentimport org.springframework.test.web.reactive.server.WebTestClient@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)class UserEndpointTest(@Autowired val client: WebTestClient) {@Testfun returnsUserJson() {client.get().uri("/users/42").exchange().expectStatus().isOk.expectHeader().contentType("application/json").expectBody().jsonPath("$.id").isEqualTo(42).jsonPath("$.name").isEqualTo("Ada")}@Testfun returns404ForMissingUser() {client.get().uri("/users/999").exchange().expectStatus().isNotFound}}
WebTestClient against a WebFlux coroutine controller. exchange() subscribes and awaits under the hood, so assertions read synchronously even though the server is non-blocking.
import kotlinx.coroutines.reactive.awaitSingleimport kotlinx.coroutines.test.runTestimport reactor.core.publisher.Monoimport kotlin.test.Testimport kotlin.test.assertEqualsimport kotlin.test.assertFailsWithclass PriceService(private val repo: PriceRepository) {suspend fun priceFor(sku: String): Int =repo.findPrice(sku).awaitSingle() // Mono -> suspend bridge}interface PriceRepository { fun findPrice(sku: String): Mono<Int> }class PriceServiceTest {@Testfun returnsPrice() = runTest {val service = PriceService(object : PriceRepository {override fun findPrice(sku: String) = Mono.just(1500)})assertEquals(1500, service.priceFor("ABC"))}@Testfun propagatesError() = runTest {val service = PriceService(object : PriceRepository {override fun findPrice(sku: String) = Mono.error<Int>(NoSuchElementException())})assertFailsWith<NoSuchElementException> { service.priceFor("missing") }}}
Testing a suspend service that bridges to Reactor via awaitSingle, plus asserting a thrown exception. The injected dispatcher keeps the test deterministic under runTest.
🧠Comprueba tu comprensión
0/1 · 0/1 answered1. You write a StepVerifier test for a Flux but it always passes even when the data is wrong. What is the most likely cause?