Tests de corrutinas: tiempo virtual
Al terminar: Probar corrutinas con runTest, tiempo virtual e inyectando el dispatcher.
Al terminar esta lección Marcador probará un partido de 90 minutos en unas decenas de milisegundos, sin esperar de verdad y sin que el resultado dependa de lo rápida que sea tu máquina. Y sabrás por qué los tests de las lecciones anteriores, que sí esperan, son frágiles.
Es la penúltima pieza del módulo: sin tests que corran deprisa, nadie se atreve a tocar código con corrutinas. Además, es la primera vez que instalas una librería solo para los tests: kotlinx-coroutines-test. Temas: runTest, tiempo virtual, TestDispatcher, inyectar el dispatcher, y la diferencia entre implementation y testImplementation.
El problema: los tests que esperan de verdad
Hasta ahora las pruebas de corrutinas usan runBlocking y un partido acelerado: unos pocos ms por minuto de juego en lugar de un minuto real. Funciona, pero tiene tres costes:
- Esperan de verdad. Un partido a tiempo real son 5.400.000 ms, es decir, 90 minutos. Acelerarlo lo hace tolerable, pero cada test sigue durmiendo.
- Dependen de la máquina. Hay aserciones como
ms < 1500oms < 250(un tiempo real medido, con margen amplio). En un ordenador lento o en un servidor saturado, un test correcto puede fallar. - No son deterministas. Si dos corrutinas se pisan por milisegundos, el test pasa unas veces y otras no. Esos tests son los que acaban ignorándose.
El tiempo virtual quita los tres problemas de golpe.
Tiempo virtual: runTest
runTest crea un alcance de test (se llama TestScope) con un planificador (testScheduler) que lleva un reloj propio. Cuando lo único que queda por hacer es esperar un delay, el planificador salta directamente al momento en que acabaría. currentTime lee ese reloj.
Pruébalo en el Kotlin Playground, que trae la librería de tests de corrutinas y deja usar runTest dentro de un main:
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
@OptIn(ExperimentalCoroutinesApi::class) // currentTime es experimental
fun main() = runTest { // runTest: un reloj virtual
delay(90 * 60_000L) // 90 minutos… que no se esperan
println("Reloj virtual: $currentTime ms") // el reloj virtual sí avanzó
}Reloj virtual: 5400000 msSe ejecuta en un instante y el reloj virtual marca 5.400.000 ms: nada durmió.
Cuando saltarse todo no es lo que quieres, hay tres funciones para gobernar el reloj. Las usarás en el ejercicio de abajo y en la lección siguiente:
| Función | Qué hace |
|---|---|
advanceTimeBy(ms) | Adelanta el reloj ms y ejecuta lo que toca antes de ese instante |
runCurrent() | Ejecuta lo que está pendiente en este instante, sin mover el reloj |
advanceUntilIdle() | Adelanta hasta que no quede trabajo pendiente |
Por eso, después de adelantar, se llama a runCurrent(): ejecuta lo que cae justo en el límite.
Las tres, con una corrutina lanzada dentro del test. launch { … } dentro de runTest crea una hija del test: corre dentro de él y runTest no termina hasta que ella acaba. Un panadero que avisa cuando el pan está listo:
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
@OptIn(ExperimentalCoroutinesApi::class)
fun main() = runTest {
launch { // hija del test: espera 1 s y avisa
delay(1_000)
println("Pan listo a los $currentTime ms")
}
advanceTimeBy(1_000) // llega al segundo 1, pero NO ejecuta lo que cae justo ahí
println("Tras advanceTimeBy: aún no ha avisado")
runCurrent() // ejecuta lo de este instante: sale el aviso
launch { delay(5_000); println("Segunda hornada a los $currentTime ms") }
advanceUntilIdle() // adelanta hasta que no quede nada pendiente
println("Reloj virtual: $currentTime ms")
}Tras advanceTimeBy: aún no ha avisado
Pan listo a los 1000 ms
Segunda hornada a los 6000 ms
Reloj virtual: 6000 msDispatchers de prueba, e inyectarlos
runTest sabe saltarse los delay de su planificador. Si tu código elige su propio dispatcher, como flowOn(Dispatchers.Default), esa corrutina corre en un hilo real y el reloj virtual no la alcanza.
Un aviso sobre lo que esto significa: en un test, todo corre en el único hilo del test, aunque en la app haya un grupo de hilos (Default). Un test con dispatchers de prueba no detecta problemas de paralelismo real; sirve para probar la lógica y el orden, no las carreras entre hilos.
La solución es inyectar el dispatcher: recibirlo de fuera, con un valor por defecto para la app. En producción es IO (o Default); en un test, uno de prueba que comparte el planificador de runTest:
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
// Código «de la app»: el dispatcher llega de fuera, con IO por defecto
class Biblioteca(private val dispatcher: CoroutineDispatcher = Dispatchers.IO) {
suspend fun buscarLibro(): String = withContext(dispatcher) {
delay(1_000) // una búsqueda que tarda 1 segundo
"El Quijote"
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun main() = runTest {
val biblioteca = Biblioteca(StandardTestDispatcher(testScheduler)) // el dispatcher de prueba, con el mismo reloj
println("Encontrado: ${biblioteca.buscarLibro()}")
println("Reloj virtual: $currentTime ms")
}Encontrado: El Quijote
Reloj virtual: 1000 msStandardTestDispatcher(testScheduler) es un dispatcher de prueba: sus corrutinas esperan en cola hasta que el test se queda esperando (llega a un delay, un join()…) o llama a runCurrent(), y usan el reloj virtual del test. Hay un segundo, UnconfinedTestDispatcher, que arranca las corrutinas al instante; lo verás en la lección siguiente.
Antes del primer paso, a practicar el reloj virtual.
Reto · Ejercicio: cuenta atrás en tiempo virtual
En el Kotlin Playground, sin tocar tu proyecto: el Playground trae la librería de tests de corrutinas, y runTest también sirve dentro de un main.
- Escribe
suspend fun cuentaAtras(desde: Int, alSegundo: (Int) -> Unit): unfor (s in desde downTo 1)que esperadelay(1_000)y llama aalSegundo(s). - En
fun main() = runTest { … }(con@OptIn(ExperimentalCoroutinesApi::class)), crea una lista mutablevistosy lanza conlaunch { … }la cuenta atrás desde 60, que apunte cada número envistos. - Usa
advanceTimeBy(30_000)e imprime cuántos números van (vistos.size);runCurrent()e imprime otra vez;advanceUntilIdle()e imprime cuántos van, el último número (vistos.last()) y el reloj virtual (currentTime).
La consola debe mostrar:
Tras adelantar 30 s: 29
Tras runCurrent: 30
Al final: 60, último: 1
Reloj virtual: 60000 msAntes de ejecutar, predice los números que imprimirá.
Ver solución
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
// Espera 1 s y avisa con cada número, de `desde` a 1.
suspend fun cuentaAtras(desde: Int, alSegundo: (Int) -> Unit) {
for (s in desde downTo 1) {
delay(1_000)
alSegundo(s)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun main() = runTest {
val vistos = mutableListOf<Int>()
launch { cuentaAtras(60) { vistos += it } } // hija normal del test
advanceTimeBy(30_000) // adelanta 30 s virtuales
println("Tras adelantar 30 s: ${vistos.size}")
runCurrent() // ejecuta lo que toca justo ahora
println("Tras runCurrent: ${vistos.size}")
advanceUntilIdle() // adelanta hasta que no quede nada
println("Al final: ${vistos.size}, último: ${vistos.last()}")
println("Reloj virtual: $currentTime ms")
}Tras advanceTimeBy(30_000) faltan las tareas programadas exactamente en el segundo 30 (la trigésima marca de la cuenta atrás): advanceTimeBy no las ejecuta. runCurrent() sí. Es el mismo límite que verás en la prueba del gol del minuto 12 de la lección siguiente. Y advanceUntilIdle() adelanta hasta el final porque aquí la cuenta atrás es hija normal del test, que sí espera por ella.
Paso · Instala la librería de test
Objetivo: que los tests de :torneo vean kotlinx-coroutines-test, y el código normal no.
1 · Qué instalas. kotlinx-coroutines-test (org.jetbrains.kotlinx, de la familia de Corrutinas y suspend) trae runTest, StandardTestDispatcher, backgroundScope, advanceTimeBy, currentTime: el reloj virtual.
2 · Programa y tests. :torneo tiene dos conjuntos de código que Gradle compila por separado: el del programa (src/main) y el de los tests (src/test, el paquete con (test)). La palabra de delante en dependencies decide qué conjunto ve la librería:
dependencies {
implementation(libs.kotlinx.coroutines.core) // la ven el programa y los tests, y va en el programa final
testImplementation(libs.kotlin.test) // solo los tests; no engorda lo que se entrega
}3 · [versions]. No tocas esta sección: kotlinx-coroutines-test reutiliza kotlinxCoroutinesCore (mismo número, suben a la vez).
4 · [libraries]. Al final de la sección, una línea:
# Reutiliza la versión de las corrutinas
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesCore" }5 · Sin plugin. No tocas [plugins] ni la raíz.
6 · La dependencia. En build.gradle.kts (Module :torneo), debajo de la de kotlin-test:
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlin.test)
}dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test) // testImplementation: solo de tests
}7 · Sync Now. Sin errores rojos; la primera vez necesita internet.
8 · Comprueba. En la Terminal de Android Studio (View → Tool Windows → Terminal). Lo que ve el programa:
./gradlew :torneo:dependencies --configuration runtimeClasspathNo debe nombrar kotlinx-coroutines-test. Lo que ven los tests:
./gradlew :torneo:dependencies --configuration testRuntimeClasspathDebe aparecer org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0 (para no leer cientos de líneas, añade | grep coroutines-test al final del comando: | pasa la salida a grep, que deja solo las líneas que contienen ese texto). Es la diferencia entre implementation y testImplementation, medida.
Paso · Un partido de 90 minutos en milisegundos
Objetivo. Escribir tu primer test con runTest y verlo fallar tras un minuto; hacer que los dispatchers se inyecten en el código que ya existe; y ver pasar el mismo test en milisegundos.
Primero, un test que falla
Crea TiempoVirtualTest.kt. En torneo → kotlin+java, clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (test) (el que lleva (test)) → New → Kotlin Class/File → File → escribe TiempoVirtualTest y pulsa Intro. Se abre con solo la línea package. Escribe esto, con las dos librerías ya instaladas:
package com.sebastiannarvaez.marcador.torneo
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
class TiempoVirtualTest {
private val guion = Ejemplo.rayoContraToros
private val minuto = 60_000L // un minuto de juego = un minuto REAL
@Test
fun unPartidoDe90MinutosSeJuegaEnMilisegundos() = runTest {
val final = jugarPartido(guion, msPorMinuto = minuto)
assertEquals(2, final.golesLocal)
assertEquals(90 * minuto, currentTime)
}
}Qué es cada cosa:
@OptIn(ExperimentalCoroutinesApi::class): aceptas usarcurrentTime, que está marcado como experimental (también lo estánadvanceTimeByyrunCurrent, que usarás en la lección siguiente), con el mismo@OptIn(ExperimentalCoroutinesApi::class)que pusiste sobreseguirPartidoenMarcador.kt.= runTest { … }: el cuerpo del test corre en el reloj virtual. SinrunTest(orunBlocking, que ya conoces), una funciónsuspendcomojugarPartidono se puede llamar desde un test, que es una función normal.msPorMinuto = minuto: hace que cada minuto de juego dure un minuto de verdad (60.000 ms): 90 minutos son 5.400.000 ms.currentTime: es el reloj virtual; si el partido «dura» 90 minutos de reloj virtual, vale5_400_000.
Ejecútalo desde la Terminal de Android Studio, solo esta clase de tests:
./gradlew :torneo:test --tests "*TiempoVirtualTest*"El --tests limita la ejecución a lo que coincida con el texto, y cada * significa «cualquier texto, o ninguno»: así «*TiempoVirtualTest*» vale aunque delante lleve el paquete.
Tardará un minuto en fallar, es normal. Acaba con algo así:
TiempoVirtualTest > unPartidoDe90MinutosSeJuegaEnMilisegundos() FAILED
kotlinx.coroutines.test.UncompletedCoroutinesError at TestBuilders.kt:353Después, inyectar el dispatcher
Cada función o clase que elige un dispatcher lo recibirá de fuera, con un valor por defecto igual al que tenía, para que cualquier llamada anterior siga compilando. Lo importante es que el dispatcher del test llegue hasta flowOn, withContext y CoroutineScope(...). En SegundoPlano.kt el cambio no lo usa ningún test nuevo: se hace por coherencia, para poder probar esas funciones igual en el futuro.
Piezas nuevas de este paso (las necesitas para escribirlo):
CoroutineDispatcheres el tipo de cualquier dispatcher (Default,IO, el de prueba…): lo que recibe el parámetro.PartidoEnVivo(guion, msPorMinuto, dispatcher),jugarPartido(partido, msPorMinuto, dispatcher),jugarConCronometro(partido, msPorMinuto, dispatcher, alMinuto)yTransmisor(alFallar, dispatcher, jugar). Eldispatcherva antes de las lambdas (alMinuto,jugar) por dos razones distintas. La primera: una lambda que va la última se puede escribir fuera de los paréntesis, como enjugarConCronometro(guion, minuto, dispatcher) { minutos += it }. La segunda, solo enTransmisor: el valor por defecto dejugarusadispatcher, y un valor por defecto solo puede usar parámetros declarados antes que él.StandardTestDispatcher(testScheduler)crea un dispatcher de prueba que comparte el reloj virtual derunTest.
Intenta escribirlo tú, fichero a fichero: PartidoEnVivo, jugarPartido, jugarConCronometro, el Transmisor y las dos funciones de SegundoPlano.kt que ya elegían dispatcher, guardarEnSegundoPlano y tablaEnSegundoPlano (la de cargar se queda como está), reciben el dispatcher que hoy eligen por su cuenta, con ese mismo como valor por defecto, y lo usan en el sitio donde antes lo escribían. Deja que Android Studio te ofrezca los import con Option+Enter sobre cada nombre en rojo. No hay salida nueva: ./gradlew :torneo:run imprime exactamente lo mismo que antes, y todo debe compilar. Cuando termines, abre el bloque y compara.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
En PartidoEnVivo.kt se añade el import kotlinx.coroutines.CoroutineDispatcher, se cambia el encabezado de la clase y flowOn usa el parámetro. El resto del fichero no cambia.
import kotlinx.coroutines.CoroutineDispatcher
…
// `dispatcher` se INYECTA (llega de fuera con un valor por defecto): en la app
// es Default, pero una prueba con `runTest` le pasa su propio dispatcher de
// pruebas. Con `flowOn(Dispatchers.Default)` clavado en el código, el `delay`
// correría en tiempo real y el reloj virtual de `runTest` no podría adelantarlo.
class PartidoEnVivo(
val guion: Partido,
val msPorMinuto: Long = 10,
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
…
}.flowOn(dispatcher)En Simulacion.kt, con los imports CoroutineDispatcher y Dispatchers, jugarPartido pasa el dispatcher:
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
…
suspend fun jugarPartido(
partido: Partido,
msPorMinuto: Long = 10,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
): Partido =
PartidoEnVivo(partido, msPorMinuto, dispatcher).eventos()
.fold(Partido(partido.local, partido.visitante)) { jugado, evento -> jugado.registrar(evento) }En Cronometro.kt, con el import de CoroutineDispatcher, cambian jugarConCronometro y la cabecera de Transmisor. El parámetro dispatcher va antes de jugar (el valor por defecto de jugar lo usa, y solo puede usar parámetros anteriores) y, a diferencia de los otros dos, no lleva val: solo se usa al construir Transmisor, no se guarda como propiedad:
import kotlinx.coroutines.CoroutineDispatcher
…
suspend fun jugarConCronometro(
partido: Partido,
msPorMinuto: Long = 10,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
alMinuto: (Int) -> Unit = {},
): Partido = coroutineScope {
val reloj = cronometro(msPorMinuto, alMinuto)
val final = jugarPartido(partido, msPorMinuto, dispatcher)
reloj.cancel()
final
}
…
class Transmisor(
private val alFallar: (Throwable) -> Unit = {},
dispatcher: CoroutineDispatcher = Dispatchers.Default,
private val jugar: suspend (Partido, Long) -> Partido = { partido, ms -> jugarConCronometro(partido, ms, dispatcher) },
) {
private val trabajo = SupervisorJob()
private val manejador = CoroutineExceptionHandler { _, error -> alFallar(error) }
private val alcance = CoroutineScope(dispatcher + trabajo + manejador)
…En SegundoPlano.kt, con el mismo import, guardarEnSegundoPlano y tablaEnSegundoPlano, las dos que cambian de dispatcher (cargarTorneoEnSegundoPlano queda igual). Solo cambian la firma y el withContext.
import kotlinx.coroutines.CoroutineDispatcher
suspend fun Torneo.guardarEnSegundoPlano(fichero: File, dispatcher: CoroutineDispatcher = Dispatchers.IO): Result<Unit> =
withContext(dispatcher) { guardar(fichero) }
suspend fun Torneo.tablaEnSegundoPlano(dispatcher: CoroutineDispatcher = Dispatchers.Default): List<FilaDePosicion> {
val copiaDePartidos = partidos
val equiposInscritos = equipos
val reglas = reglamento
return withContext(dispatcher) { copiaDePartidos.tablaDePosiciones(equiposInscritos, reglas) }
}Ahora arregla el test: que el partido se juegue con un dispatcher de prueba que comparta el reloj virtual del test, que compruebe también el gol del visitante (2-1), que el reloj virtual marque exactamente 90 minutos y que el partido entero tarde menos de 2 segundos de reloj real. Intenta escribirlo y, después, abre el bloque y compara. Al ejecutar ./gradlew :torneo:test --tests "*TiempoVirtualTest*" debe terminar así, en pocos segundos (el número de segundos varía):
BUILD SUCCESSFUL in 4sIntenta primero. Cuando termines, compara con la solución.Mostrar el código
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlin.system.measureTimeMillis
import kotlin.test.assertTrue
…
@Test
fun unPartidoDe90MinutosSeJuegaEnMilisegundos() = runTest {
val real = measureTimeMillis {
val final = jugarPartido(guion, msPorMinuto = minuto, dispatcher = StandardTestDispatcher(testScheduler))
assertEquals(2, final.golesLocal)
assertEquals(1, final.golesVisitante)
}
assertEquals(90 * minuto, currentTime, "El reloj virtual debería marcar 90 minutos")
assertTrue(real < 2_000, "Tardó $real ms de reloj real")
}measureTimeMillis { … }devuelve cuántos milisegundos de reloj real tardó el bloque.assertEquals(esperado, real, "mensaje")yassertTrue(condición, "mensaje")son las dekotlin.testque usas desde Bucles y tu primer test; el último texto es el aviso que se muestra si fallan.$realmete el número en el texto.real < 2_000es un margen amplio de tiempo real: el número que no depende de tu máquina es el virtual,currentTime, que vale exactamente5_400_000.
Ejecuta otra vez ./gradlew :torneo:test --tests "*TiempoVirtualTest*". Ahora termina en BUILD SUCCESSFUL en pocos segundos (casi todo es lo que tarda Gradle en arrancar), y el partido de 90 minutos en sí cuesta unas decenas de milisegundos. Para ver cuánto tardó cada prueba, abre el informe de Gradle desde la Terminal: open torneo/build/reports/tests/test/index.html, pulsa la clase TiempoVirtualTest y mira la columna de duración.
Con esto el partido de 90 minutos se prueba en milisegundos. En la lección siguiente, Tests con backgroundScope y Turbine, gobiernas el reloj a mano y pruebas flujos que no terminan.
Punto de control
- En
Gradle Scripts, el catálogo tienekotlinx-coroutines-testen[libraries]ybuild.gradle.kts (Module :torneo)tiene la líneatestImplementation(libs.kotlinx.coroutines.test). Y./gradlew :torneo:dependencies --configuration runtimeClasspathno la nombra. - En la Terminal,
./gradlew :torneo:testtermina enBUILD SUCCESSFUL: 128 tests en total, 0 fallos, de ellos 1 deTiempoVirtualTest. Repítelo con--rerun-tasks(obliga a Gradle a ejecutar los tests otra vez en vez de reutilizar el resultado anterior): sale igual. - El test de 90 minutos tarda decenas de milisegundos de reloj real (mira el informe HTML de arriba), y
currentTimevale5_400_000. ./gradlew :torneo:runsigue mostrando las demos de las lecciones anteriores: los dispatchers por defecto no cambian nada en producción.- Los tests de tiempo real de las lecciones anteriores siguen en verde: no se han tocado.
- Sabes decir por qué
kotlinx-coroutines-testva contestImplementationy no conimplementation, y por qué el dispatcher se inyecta.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Cómo puede
runTestprobar un partido de 90 minutos en milisegundos?2.Tu test con
runTesttermina conUncompletedCoroutinesError: After waiting for 1m. El partido usaflowOn(Dispatchers.Default)clavado en el código. ¿Cuál es la causa y la solución?3.Un test lanza una corrutina con
StandardTestDispatchery comprueba el resultado justo después, sin suspender. ¿Qué pasa?4.Tras
advanceTimeBy(30_000), una cuenta atrás con undelay(1_000)por número ha registrado 29 y no 30. ¿Por qué?5.Has añadido
testImplementation(libs.kotlinx.coroutines.test)en el módulo:torneo. Escribesimport kotlinx.coroutines.test.runTestenSimulacion.kt, un fichero del programa, no de un test. ¿Qué ocurre al compilar?6.En el catálogo,
kotlinx-coroutines-testusaversion.ref = "kotlinxCoroutinesCore"en lugar de una versión propia. ¿Por qué?