Probando Corrutinas y Flows con kotlinx-coroutines-test
Domina el tiempo: prueba codigo suspendido de forma determinista con runTest, tiempo virtual y Flow.toList().
Probar codigo suspendido usando el reloj real es una receta para tests lentos e inestables. Si una corrutina llama a delay(5_000), no quieres que tu test espere realmente cinco segundos, y desde luego no quieres que carreras de tiempo decidan si una asercion pasa. El artefacto kotlinx-coroutines-test resuelve esto con un planificador de tiempo virtual: los retrasos se omiten al instante mientras se preserva el orden relativo de los eventos. Agregalo a tu build con testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1"), igualando la version de tu dependencia coroutines-core.
El punto de entrada es el builder runTest { ... }. Ejecuta el cuerpo de tu test dentro de un TestScope respaldado por un TestDispatcher, avanza el planificador hasta que todas las corrutinas hijas terminan, y relanza cualquier excepcion no capturada para que los fallos aparezcan como fallos de test normales. De forma crucial, runTest avanza el tiempo virtual automaticamente: cuando todas las corrutinas estan suspendidas en un delay, el planificador salta hasta la siguiente reanudacion programada. Esto significa que una funcion que espera una hora termina en microsegundos, y aun asi el codigo que depende del orden se comporta correctamente. Usa siempre runTest en lugar de runBlocking en tests para heredar esta maquinaria de tiempo virtual.
Dos implementaciones de TestDispatcher controlan como se planifican las corrutinas. StandardTestDispatcher (el predeterminado dentro de runTest) encola las nuevas corrutinas en vez de ejecutarlas con avidez, asi que solo corren cuando cedes el control o avanzas explicitamente el planificador. UnconfinedTestDispatcher ejecuta las nuevas corrutinas con avidez hasta su primer punto de suspension, lo cual es comodo cuando solo quieres que el trabajo lanzado empiece de inmediato. Elige StandardTestDispatcher cuando necesites control preciso del entrelazado, y UnconfinedTestDispatcher cuando la ejecucion avida haga el test mas natural de leer. Ambos comparten el mismo TestCoroutineScheduler, que puedes extraer y pasar a varios dispatchers para que avancen al unisono.
Cuando no quieres que el avance automatico lo ejecute todo de golpe, manejas el reloj manualmente. advanceTimeBy(duracion) empuja el tiempo virtual hacia adelante una cantidad fija, reanudando las corrutinas cuyos delays hayan transcurrido; runCurrent() entonces ejecuta las tareas programadas para el instante actual. advanceUntilIdle() ejecuta todo hasta que no queda trabajo pendiente. Tambien puedes leer currentTime para afirmar cuanto tiempo virtual consumio una pieza de trabajo. Esto te permite probar logica de debounce, timeout, reintento con backoff y sondeo con precision: programa el trabajo, avanza el tiempo paso a paso y afirma el estado intermedio en cada tick.
Una regla sutil pero importante: el codigo bajo prueba no debe codificar Dispatchers.Default o Dispatchers.IO de forma fija, porque esos dispatchers reales ignoran el planificador de test y rompen el tiempo virtual. Inyecta un CoroutineDispatcher (o CoroutineContext) en tus clases para que los tests puedan sustituir un TestDispatcher. Como recurso para codigo que no puedes cambiar, Dispatchers.setMain(testDispatcher) reemplaza el dispatcher Main, acompanado de Dispatchers.resetMain() en el teardown. Reutilizar un solo planificador entre el dispatcher inyectado y el test unifica los delays, de modo que un delay dentro de un repositorio y un delay dentro del test observan el mismo reloj virtual.
Probar Flows se apoya en la misma base. La asercion mas simple y confiable es recolectar un flow finito en una lista con toList() y compararla con la secuencia esperada. Como toList() suspende hasta que el flow completa, drena de forma natural cada emision, y dentro de runTest cualquier delay entre emisiones se omite. Esto funciona de maravilla para flows construidos con flowOf, map, filter o un bloque flow { } que emite un numero conocido de elementos, permitiendote afirmar valores y orden exactos en una sola linea.
Para flows con delays intermedios combinas toList() con control manual del tiempo, o afirmas el momento de cada emision. Un ejemplo clasico es debounce: alimenta valores al flow, avanza el tiempo virtual mas alla de la ventana de debounce, y verifica que solo sobrevive el valor final. Como el planificador es determinista, el test produce el mismo resultado en cada ejecucion y en cada maquina. Cuando necesitas inspeccionar las emisiones a medida que ocurren en vez de tras completar, la libreria Turbine (app.cash.turbine) ofrece un DSL test { awaitItem() }, pero para flows finitos el simple toList() mantiene las dependencias minimas y la intencion clara.
Dos trampas merecen una palabra final. Primero, un flow infinito o caliente (como un MutableStateFlow o un flow que nunca completa) hara que toList() se cuelgue para siempre; acotalo con take(n) antes de recolectar, o usa Turbine. Segundo, el trabajo lanzado en un scope separado o sobre un dispatcher real inyectado no sera esperado por runTest, dejando las aserciones compitiendo contra corrutinas inacabadas; encamina ese trabajo por el mismo TestScope o TestDispatcher para que el planificador pueda seguirlo. Internamente runTest refuerza esto fallando el test si quedan corrutinas corriendo tras un timeout, lo cual es una senal util de que un dispatcher se escapo del control del test.
En conjunto, estas herramientas convierten codigo asincrono notoriamente dependiente del tiempo en tests unitarios rapidos y deterministas. Inyecta dispatchers para que el codigo de produccion sea testeable, envuelve los cuerpos de test en runTest, avanza el reloj virtual para ejercitar logica basada en tiempo, y afirma la salida de los flows con toList(). El resultado es una suite que corre en milisegundos sin importar cuantos segundos, minutos u horas simulados finjan esperar tus corrutinas.
import kotlinx.coroutines.delayimport kotlinx.coroutines.test.runTestimport kotlin.test.Testimport kotlin.test.assertEqualsclass GreeterTest {suspend fun greetAfterDelay(name: String): String {delay(10_000) // 10 seconds of virtual time, skipped instantlyreturn "Hello, $name"}@Testfun returnsGreeting() = runTest {val result = greetAfterDelay("Kotlin")assertEquals("Hello, Kotlin", result)// currentTime is a Long reporting the virtual time consumedassertEquals(10_000L, currentTime)}}
runTest skips delays via virtual time. This delay(10_000) finishes instantly, and the test still asserts ordering correctly.
Arena IDEimport kotlinx.coroutines.delayimport kotlinx.coroutines.launchimport kotlinx.coroutines.test.runTestimport kotlin.test.Testimport kotlin.test.assertEqualsclass TimedTest {@Testfun advancesStepByStep() = runTest {val log = mutableListOf<String>()launch {delay(1_000); log += "one"delay(1_000); log += "two"}// Nothing has run yet with StandardTestDispatcherassertEquals(emptyList(), log)advanceTimeBy(1_001) // past the first delayrunCurrent()assertEquals(listOf("one"), log)advanceUntilIdle() // run the restassertEquals(listOf("one", "two"), log)}}
Driving the virtual clock manually with advanceTimeBy and advanceUntilIdle to test time-based work step by step.
Arena IDEimport kotlinx.coroutines.delayimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.flowimport kotlinx.coroutines.flow.mapimport kotlinx.coroutines.flow.toListimport kotlinx.coroutines.test.runTestimport kotlin.test.Testimport kotlin.test.assertEqualsclass FlowTest {fun numbers(): Flow<Int> = flow {for (i in 1..3) {delay(500) // skipped by virtual timeemit(i)}}@Testfun emitsDoubledValues() = runTest {val result = numbers().map { it * 2 }.toList()assertEquals(listOf(2, 4, 6), result)}}
Asserting Flow emissions: collect a finite flow with toList(). Inter-emission delays are skipped under runTest.
Arena IDEimport kotlinx.coroutines.CoroutineDispatcherimport kotlinx.coroutines.withContextimport org.springframework.stereotype.Service@Serviceclass PriceService(private val repo: PriceRepository,private val dispatcher: CoroutineDispatcher // injected, not Dispatchers.IO) {suspend fun latestPrice(id: Long): Int = withContext(dispatcher) {repo.findPrice(id)}}// Test: substitute a TestDispatcher backed by the test scheduler// @Test fun price() = runTest {// val service = PriceService(fakeRepo, StandardTestDispatcher(testScheduler))// assertEquals(42, service.latestPrice(1))// }
Inject a dispatcher so production code is testable. In a Spring @Service, never hard-code Dispatchers.IO; pass one in and substitute a TestDispatcher.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. Inside runTest, why does a suspending function that calls delay(60_000) complete almost instantly instead of taking 60 seconds?