Tests con backgroundScope y Turbine
Al terminar: Probar flujos con backgroundScope, el dispatcher Unconfined e instalar Turbine.
Al terminar esta lección Marcador tendrá diez tests de tiempo virtual y sabrás recoger un flujo que no termina nunca, gobernar el reloj a mano y probar un flujo evento a evento con Turbine, la primera librería de terceros (de una empresa que no es JetBrains) que instalas, pensada solo para tests.
Es la última pieza del módulo: sin tests que corran deprisa, nadie se atreve a tocar código con corrutinas. Temas: backgroundScope, StandardTestDispatcher frente a UnconfinedTestDispatcher, y Turbine.
Los dos dispatchers de prueba
Hay dos, y los dos comparten el reloj virtual del test:
StandardTestDispatcher | UnconfinedTestDispatcher | |
|---|---|---|
| Corrutina nueva | Espera en cola hasta que el test se queda esperando (llega a un delay, un awaitItem() (de Turbine, más abajo), un join()…) o llamas a runCurrent() | Arranca ya, sin esperar |
| Orden | Explícito: tú decides cuándo avanza | Las corrutinas corren en cuanto se lanzan |
| Úsalo para | Casi todo | Recoger un flujo desde el principio sin runCurrent() |
backgroundScope y Turbine
Un partido no termina solo si nadie cancela sus espectadores: son corrutinas infinitas. backgroundScope es un alcance del test cuyas corrutinas se cancelan al terminar el test.
Turbine es la librería para probar flujos evento a evento.
flowOf(a, b) crea un flow que emite esos valores y termina; sirve para probar sin montar un flow { }. Turbine recoge el flujo dentro del bloque test { }, que a su vez va dentro de un runTest. Este ejemplo es solo para leer (Turbine aún no está instalado en tu proyecto: lo haces en el primer paso):
import app.cash.turbine.test
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class UnoDosTest {
@Test
fun recogeUnoYDos() = runTest {
flowOf("uno", "dos").test {
assertEquals("uno", awaitItem()) // espera el siguiente valor
assertEquals("dos", awaitItem())
awaitComplete() // y que el flujo termine
}
}
}Gobernar el reloj de un test: un horno
Las piezas nuevas (backgroundScope, toList(lista), advanceTimeBy, runCurrent y UnconfinedTestDispatcher), en dos tests de ejemplo con un horno en vez de un partido, explicados línea a línea. Son solo para leer: no los pegues en tu proyecto (el recuento de 137 tests es el de Marcador); ya los comprobamos y pasan:
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
// Un horno que avisa cada 10 minutos (600.000 ms), sin parar
fun avisosDelHorno(): Flow<Int> = flow {
var minuto = 0
while (true) { delay(600_000); minuto += 10; emit(minuto) }
}
@OptIn(ExperimentalCoroutinesApi::class)
class HornoTest {
@Test
fun nadaPasaHastaQueAdelantasElReloj() = runTest {
val vistos = mutableListOf<Int>()
// backgroundScope: se cancela solo al acabar el test (el flujo no termina nunca)
backgroundScope.launch { avisosDelHorno().toList(vistos) } // toList(lista): llena una lista que ya existe
assertEquals(emptyList(), vistos) // la corrutina aún espera en cola: no ha corrido
advanceTimeBy(600_000 - 1) // el reloj virtual llega a 1 ms antes del aviso
runCurrent() // ejecuta lo que toca AHORA: todavía nada
assertEquals(emptyList(), vistos)
advanceTimeBy(1) // justo el minuto 10…
runCurrent() // …y se ejecuta el aviso de ese instante
assertEquals(listOf(10), vistos)
}
@Test
fun unconfinedArrancaYa() = runTest {
val temperatura = MutableStateFlow(20)
val vistas = mutableListOf<Int>()
// UnconfinedTestDispatcher: la corrutina corre YA, sin esperar a runCurrent()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { temperatura.toList(vistas) }
assertEquals(listOf(20), vistas) // el StateFlow da su valor actual al suscribirse
temperatura.value = 180 // asignar a .value cambia el estado y avisa a quien lo recoge
assertEquals(listOf(20, 180), vistas)
}
}Y ahora, a practicar el reloj virtual y el dispatcher que arranca al instante.
Reto · Ejercicio: el termostato en tiempo virtual
En el Kotlin Playground, sin tocar tu proyecto: el Playground trae la librería de tests de corrutinas, pero no Turbine, así que este ejercicio usa solo el reloj virtual y el dispatcher que arranca al instante.
- Escribe
class Termostatocon su temperatura como estado:MutableStateFlow(18)privado y, para el resto, unStateFlow<Int>llamadogrados(conasStateFlow()). Añadesuspend fun calentar(hasta: Int): mientras la temperatura sea menor quehasta, esperadelay(60_000)(un minuto) y la sube un grado con_grados.update { it + 1 }. - En
fun main() = runTest { … }(con@OptIn(ExperimentalCoroutinesApi::class)), crea una lista mutablevistasy, dentro debackgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { … }, recogetermostato.grados.toList(vistas). - Imprime «Al suscribirse: » seguido de
vistas; llama atermostato.calentar(21); imprime «Tras calentar: » seguido devistas, y «Minutos virtuales: » seguido de los minutos que han pasado (currentTime / 60_000).
La consola debe mostrar:
Al suscribirse: [18]
Tras calentar: [18, 19, 20, 21]
Minutos virtuales: 3Antes de ejecutar, predice qué imprimirá la primera línea.
Ver solución
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.*
class Termostato {
private val _grados = MutableStateFlow(18) // el estado: empieza en 18
val grados: StateFlow<Int> = _grados.asStateFlow() // lo que ve el resto, solo para leer
suspend fun calentar(hasta: Int) {
while (_grados.value < hasta) { delay(60_000); _grados.update { it + 1 } } // un grado por minuto
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun main() = runTest {
val termostato = Termostato()
val vistas = mutableListOf<Int>()
// Unconfined: la corrutina corre YA; backgroundScope: se cancela sola al acabar
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { termostato.grados.toList(vistas) }
println("Al suscribirse: $vistas")
termostato.calentar(21)
println("Tras calentar: $vistas")
println("Minutos virtuales: ${currentTime / 60_000}")
}La primera línea ya trae el 18 porque el dispatcher Unconfined arranca la corrutina al lanzarla y un StateFlow da su valor actual a quien se suscribe. Con StandardTestDispatcher la lista estaría vacía hasta el primer delay.
Paso · Instala Turbine
Objetivo: que los tests de :torneo vean Turbine, y el código normal no.
1 · Qué instalas. Turbine (app.cash.turbine:turbine, de Cash App) recoge un Flow en un test y te deja pedirle los valores con awaitItem() y awaitComplete(). Como kotlinx-coroutines-test, es una librería solo para tests: va con testImplementation.
2 · [versions]. En libs.versions.toml (Version Catalog "libs"), al final de [versions]. Turbine es de otra empresa, así que lleva su propia versión (no la de las corrutinas):
turbine = "1.2.1"3 · [libraries]. Al final de la sección:
# Alias y versión se llaman igual y no se pisan: son secciones distintas
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }4 · Sin plugin. No tocas [plugins] ni la raíz.
5 · La dependencia. En build.gradle.kts (Module :torneo), debajo de la de kotlinx-coroutines-test:
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
}dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine) // también testImplementation: solo de tests
}6 · Sync Now. Sin errores rojos; la primera vez necesita internet.
7 · Comprueba. En la Terminal de Android Studio (View → Tool Windows → Terminal). Lo que ve el programa (no debe nombrar turbine):
./gradlew :torneo:dependencies --configuration runtimeClasspathLo que ven los tests (debe aparecer app.cash.turbine:turbine:1.2.1; filtra con | grep turbine):
./gradlew :torneo:dependencies --configuration testRuntimeClasspathPaso · Cuatro tests con el reloj en la mano, y cinco con Turbine
Objetivo. Ampliar TiempoVirtualTest.kt hasta diez tests: dos que gobiernan el reloj con backgroundScope, dos con Turbine sobre el partido y cinco más que se pegan.
Gobernar el reloj tú: StandardTestDispatcher y backgroundScope
Abre TiempoVirtualTest.kt: ya tiene el primer test. Un test no tiene por qué saltar todo el partido de golpe. El segundo comprueba que nada pasa hasta que se lo dices: pone a un espectador a apuntar los eventos del partido en una lista, en el alcance de fondo del test; comprueba que la lista sigue vacía hasta que adelantas el reloj, que un milisegundo antes del minuto 12 aún no hay nada, que justo en el minuto 12 aparece el gol y que, al final de los 90 minutos, ha visto los cinco eventos. Escríbelo dentro de la clase, debajo del anterior (los import que falten, con Option+Enter; usa como guía el ejemplo del horno de arriba), y compara con el bloque. Al ejecutar la clase de tests, los dos deben pasar (BUILD SUCCESSFUL).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
@Test
fun elDispatcherEstandarSoloAvanzaCuandoSeLeDice() = runTest {
val enVivo = PartidoEnVivo(guion, minuto, StandardTestDispatcher(testScheduler))
val vistos = mutableListOf<EventoDePartido>()
backgroundScope.launch { enVivo.eventos().toList(vistos) }
// La corrutina se lanzó, pero un StandardTestDispatcher no la ejecuta hasta
// que el test suspende o llama a runCurrent().
assertEquals(0, vistos.size)
advanceTimeBy(12 * minuto - 1) // un milisegundo antes del gol del 12
runCurrent()
assertEquals(0, vistos.size)
advanceTimeBy(1) // en el 12: gol
runCurrent()
assertEquals(listOf(12), vistos.map { it.minuto })
// Hasta el final. Ojo: aquí NO vale advanceUntilIdle(): lo que lanza el
// backgroundScope es trabajo «de fondo» y advanceUntilIdle() no espera por él.
advanceTimeBy(90 * minuto)
runCurrent()
assertEquals(5, vistos.size)
}Cómo se lee:
enVivo.eventos().toList(vistos)es la versión detoListque llena una lista que ya existe (vistos) en vez de devolver una nueva. Así el test puede mirar la lista mientras el flujo sigue vivo.backgroundScope.launch { … }lanza el espectador en el alcance de fondo del test, que se cancela solo al terminar. Es un flujo que se recoge «para siempre»; sinbackgroundScope, el test no terminaría.- Con un
StandardTestDispatcher, la corrutina nueva espera en cola: no corre hasta que el test llega a undelay, unawait…(comoawaitItem()) o unjoin(), o llama arunCurrent(). Por eso, justo después de lanzarla,vistossigue vacía. advanceTimeBy(12 * minuto - 1)llega hasta un milisegundo antes del gol;advanceTimeBy(1)llega justo al minuto 12 yrunCurrent()ejecuta lo que cae en ese instante: el gol.- El último comentario avisa de una trampa, que está justo debajo.
El tercero usa el otro dispatcher de prueba, el que arranca la corrutina ya: apunta el marcador de un partido desde el principio y comprueba que, nada más suscribirse y sin ejecutar nada a mano, ya ve el 0-0; después inicia el partido, deja pasar los 90 minutos y comprueba que ha visto 0-0, 1-0, 1-1 y 2-1, en ese orden.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
@Test
fun elDispatcherUnconfinedEjecutaSinEsperar() = runTest {
val enVivo = PartidoEnVivo(guion, minuto, StandardTestDispatcher(testScheduler))
val marcadores = mutableListOf<Marcador>()
// Unconfined: la corrutina corre YA, sin esperar a runCurrent().
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { enVivo.marcador.toList(marcadores) }
assertEquals(listOf(Marcador(0, 0)), marcadores)
enVivo.iniciar(backgroundScope)
advanceTimeBy(90 * minuto)
runCurrent()
assertEquals(listOf("0-0", "1-0", "1-1", "2-1"), marcadores.map { it.toString() })
}Fíjate en la diferencia con el test anterior: aquí, justo después de lanzar el espectador, marcadores ya tiene el 0-0 (el StateFlow da su valor actual a quien se suscribe). No hizo falta runCurrent(). Y enVivo.iniciar(backgroundScope) es el iniciar de StateFlow y SharedFlow, con el alcance de fondo del test.
Turbine: probar un flujo evento a evento
El cuarto test es el primero con Turbine: recoge los eventos del partido y comprueba, uno a uno y en orden, que son los del guion, que después el flujo termina y que el reloj virtual marca 90 minutos. El import que hace falta es el de la función test de Turbine, app.cash.turbine.test.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
@Test
fun turbineProbandoElFlowFrioEventoAEvento() = runTest {
PartidoEnVivo(guion, minuto, StandardTestDispatcher(testScheduler)).eventos().test {
for (esperado in guion.eventos) assertEquals(esperado, awaitItem())
awaitComplete()
}
assertEquals(90 * minuto, currentTime)
}flujo.test { … } recoge el flujo dentro de su bloque y te da awaitItem() para pedir el siguiente valor (espera, en tiempo virtual, hasta que llega) y awaitComplete() para exigir que el flujo termine. Un for con awaitItem() comprueba los cinco eventos del guion en orden. Si el bloque termina con eventos sin consumir, Turbine falla y los lista.
El quinto lo aplica al marcador, que nunca termina: comprueba que primero llega el 0-0 actual; inicia el partido en el alcance de fondo; después deben llegar 1-0, 1-1 y 2-1, y al final nada más.
Lo nuevo de este test (expectNoEvents() y cancelAndIgnoreRemainingEvents(), para un flujo que no termina) en un ejemplo con un termo, explicado línea a línea. Como el del horno, es solo para leer: no lo pegues en tu proyecto. Ya comprobamos que pasa:
import app.cash.turbine.test
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class TermoTest {
@Test
fun elTermoAvisaDeCadaCambio() = runTest {
val grados = MutableStateFlow(20)
grados.test { // Turbine: recoge el flujo dentro de este bloque
assertEquals(20, awaitItem()) // un StateFlow entrega primero su valor actual
grados.value = 25 // .value = cambia el estado
assertEquals(25, awaitItem()) // espera el siguiente valor
grados.value = 25 // el mismo valor: un StateFlow no lo repite
expectNoEvents() // comprueba que AHORA no ha llegado nada nuevo
cancelAndIgnoreRemainingEvents() // un StateFlow no termina: cierra sin esperar más
}
}
}Intenta primero. Cuando termines, compara con la solución.Mostrar el código
@Test
fun turbineProbandoElStateFlow() = runTest {
val enVivo = PartidoEnVivo(guion, minuto, StandardTestDispatcher(testScheduler))
enVivo.marcador.test {
assertEquals(Marcador(0, 0), awaitItem()) // el valor actual llega primero
enVivo.iniciar(backgroundScope)
assertEquals(Marcador(1, 0), awaitItem())
assertEquals(Marcador(1, 1), awaitItem())
assertEquals(Marcador(2, 1), awaitItem())
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}Un StateFlow no termina nunca, así que aquí no hay awaitComplete(). expectNoEvents() comprueba que, en ese momento, no llegó nada nuevo. Y cancelAndIgnoreRemainingEvents() cierra el bloque test { } sin exigir que hayas consumido todo lo que pudiera llegar: sin esa línea, Turbine esperaría para siempre un flujo que no termina.
Las cinco pruebas que faltan
Las otras cinco son variaciones de lo que acabas de escribir (los marcadores del flujo frío, los goles del SharedFlow, la Jornada, el cronómetro y el Transmisor), así que en vez de escribirlas las pegas ya hechas. Copia el contenido del bloque de abajo y pégalo dentro de la clase, debajo de las que tienes (clic dentro de la clase, después de la última prueba; Cmd+V). Añade además los import de kotlinx.coroutines.Job, kotlinx.coroutines.cancelAndJoin y kotlinx.coroutines.delay:
@Test
fun turbineProbandoLosMarcadores() = runTest {
PartidoEnVivo(guion, minuto, StandardTestDispatcher(testScheduler)).marcadores().test {
assertEquals(Marcador(0, 0), awaitItem())
assertEquals(Marcador(1, 0), awaitItem())
assertEquals(12 * minuto, currentTime)
assertEquals(Marcador(1, 1), awaitItem())
assertEquals(Marcador(2, 1), awaitItem())
awaitComplete()
}
}
@Test
fun turbineProbandoElSharedFlowDeGoles() = runTest {
val enVivo = PartidoEnVivo(guion, minuto, StandardTestDispatcher(testScheduler))
enVivo.goles.test {
enVivo.iniciar(backgroundScope)
assertEquals(listOf(12, 55, 80), List(3) { awaitItem().minuto })
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun laJornadaConWhileSubscribedEnTiempoVirtual() = runTest {
val despachador = StandardTestDispatcher(testScheduler)
val jornada = Jornada(
PartidoEnVivo(guion, minuto, despachador),
PartidoEnVivo(Ejemplo.lobosContraAguilas, minuto, despachador),
backgroundScope,
)
jornada.marcadores.test {
assertEquals(MarcadoresDeJornada(Marcador(0, 0), Marcador(0, 0)), awaitItem())
jornada.iniciar()
var ultimo = awaitItem()
while (ultimo.golesTotales < 6) ultimo = awaitItem()
assertEquals(MarcadoresDeJornada(Marcador(2, 1), Marcador(1, 2)), ultimo)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun elRelojSeParaAlPitarElFinalEnTiempoVirtual() = runTest {
val minutos = mutableListOf<Int>()
val final = jugarConCronometro(guion, minuto, StandardTestDispatcher(testScheduler)) { minutos += it }
assertEquals(2, final.golesLocal)
val alPitar = minutos.size
assertTrue(alPitar in 89..90, "El reloj marcó $alPitar minutos")
delay(10 * minuto)
assertEquals(alPitar, minutos.size, "El reloj siguió tras el final")
}
@Test
fun elTransmisorSeCancelaAMitadDelPartidoConElRelojVirtual() = runTest {
val terminados = mutableListOf<Partido>()
val transmisor = Transmisor(dispatcher = StandardTestDispatcher(testScheduler))
val trabajo: Job = transmisor.transmitir(guion, minuto) { terminados += it }
advanceTimeBy(45 * minuto)
runCurrent()
transmisor.pitarElFinal()
trabajo.cancelAndJoin()
assertTrue(terminados.isEmpty())
assertEquals(45 * minuto, currentTime)
}Cinco detalles de lo que acabas de pegar:
List(3) { awaitItem().minuto }crea una lista de 3 elementos llamando a la lambda tres veces: tresawaitItem()seguidos, de los que se queda con el minuto.jornada.marcadores.test { … }prueba laJornadade la lección anterior en tiempo virtual: sin espectadores no se calcula nada, y al aparecer Turbine como espectador, elcombinearranca. Es la misma idea que la prueba conWhileSubscribedde la lección deStateFlow, ahora sin esperas reales.while (ultimo.golesTotales < 6) ultimo = awaitItem()sigue pidiendo valores a Turbine, quedándose con el último, hasta que entre los dos partidos suman seis goles; así el test no depende de cuántos marcadores intermedios lleguen.assertTrue(alPitar in 89..90): el cronómetro cuenta minutos al ritmo demsPorMinuto, y según en qué orden caiga el último tic frente al pitido final, marca 89 o 90; no es un fallo.Jobes el «mando» de una corrutina que viste en Alcance, Job y cancelación:cancelAndJoin()la cancela y espera a que acabe.
Ejecuta toda la suite: ./gradlew :torneo:test. Debe terminar en BUILD SUCCESSFUL.
Si tu TiempoVirtualTest.kt no compila y no encuentras por qué, puedes sustituirlo entero por uno que compila, con el botón Copiar fichero (explicado en Clases y objetos: copia el contenido al portapapeles; el fichero ya existe, solo cambias su contenido). Pierdes lo que escribiste, pero te quedas con un fichero que compila:
- Pulsa el botón Copiar fichero de abajo. Aparecerá un aviso de que se ha copiado.
- Haz clic dentro de
TiempoVirtualTest.kt, selecciona todo con Cmd+A y pega con Cmd+V. El contenido pegado ya trae su líneapackage.
Punto de control
- En
Gradle Scripts, el catálogo tieneturbine = "1.2.1"en[versions]yturbineen[libraries];build.gradle.kts (Module :torneo)tiene la líneatestImplementation(libs.turbine). Y./gradlew :torneo:dependencies --configuration runtimeClasspathno la nombra. - En la Terminal,
./gradlew :torneo:testtermina enBUILD SUCCESSFUL: 137 tests en total, 0 fallos, de ellos 10 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 la lección anterior), 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é
advanceUntilIdle()no espera albackgroundScope, y para qué sirvecancelAndIgnoreRemainingEvents().
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Lanzas un partido en
backgroundScopey llamas aadvanceUntilIdle(). El marcador sigue en0-0. ¿Por qué?2.Pruebas con Turbine un flujo creado con
flowOf("uno", "dos"). Después de los dosawaitItem(), ¿qué línea falta para que el test compruebe que el flujo terminó?3.Pruebas con Turbine un
StateFlowy, tras consumir los valores que esperabas, el bloquetest { }no acaba nunca. ¿Cuál es la solución?4.Lanzas una corrutina con
UnconfinedTestDispatcherque recoge unStateFlowen una lista. Justo después, sin llamar arunCurrent(), miras la lista. ¿Qué ves?