Tests de un ViewModel: fakes, setMain y Turbine
Al terminar: Probar un ViewModel con un fake, runTest y Turbine.
Al terminar esta lección sabrás probar un ViewModel en tu ordenador, sin emulador y en pocos segundos, con un fake (un repositorio de mentira), un reloj virtual que salta las esperas y Turbine, que recoge lo que emite un flujo. Instalarás las librerías de test de :app y escribirás un ViewModel de práctica, un temporizador, con sus tests en tu carpeta ejercicios. Los tests del ViewModel de Marcador llegan en la lección siguiente.
Importa porque un ViewModel con lógica y sin tests solo se prueba a mano, mirando la pantalla, y los fallos que dependen del tiempo (dos cuentas que corren a la vez y se pisan) casi nunca se dejan ver así. Temas: tests unitarios del ViewModel, fake frente a mock, runTest y Turbine.
Un test unitario corre en la JVM
Un test unitario comprueba una pieza aislada y corre en la JVM (la máquina virtual de Java: el programa que ejecuta el código Kotlin y Java ya compilado) de tu ordenador, sin instalar nada en un móvil. Es rápido, pero tiene un límite: en la JVM no existe el hilo principal de Android ni las clases del sistema.
Dónde viven los tests de :app: (test) frente a (androidTest)
En app → kotlin+java hay tres paquetes con el mismo nombre; la etiqueta los distingue (Tu primer proyecto en Kotlin):
- sin etiqueta: el código de la app, ningún test;
(test): tests unitarios (ExampleUnitTest), en la JVM de tu ordenador, en segundos; sus librerías llevantestImplementation;(androidTest): tests instrumentados (ExampleInstrumentedTest), que necesitan Android de verdad (móvil o emulador, minutos); sus librerías llevanandroidTestImplementation.
Los de esta lección van en (test): un ViewModel con fakes no necesita Android. Una pantalla dibujada o la base real exigirían (androidTest), que no escribimos aquí.
Para que la pieza probada no arrastre a sus colaboradores (base de datos, preferencias) se le entrega un doble de prueba: un objeto que ocupa su lugar. Hay dos familias muy distintas.
| Fake | Mock | |
|---|---|---|
| Qué es | Implementación real y simple (una lista en memoria) | Objeto programado con respuestas y expectativas |
| Qué comprueba el test | El resultado: «el gol quedó guardado» | Las llamadas: «se llamó a registrarGol una vez» |
| Si refactorizas (reorganizas el código sin cambiar lo que hace) | El test sigue pasando | Puede romperse |
| Se reutiliza | En todos los tests | Se reprograma en cada uno |
Un fake exige que la dependencia sea una interfaz. Ese trabajo ya está hecho en Marcador: PartidosRepository lo es desde Repository: una sola puerta a los datos. Lo mismo vale cuando los datos vienen de internet: la red queda detrás de esa interfaz y el test sigue recibiendo un fake. Lo harás con una API falsa en La pantalla de la crónica y sus pruebas.
// Ilustración, no es un fichero de Marcador.
interface Almacen {
fun guardar(nombre: String)
fun todos(): List<String>
}
// El fake: una lista en memoria. Funciona de verdad, pero no sirve para producción.
class AlmacenFalso : Almacen {
private val nombres = mutableListOf<String>()
override fun guardar(nombre: String) { nombres += nombre }
override fun todos(): List<String> = nombres
}
// El test mira el RESULTADO: qué quedó guardado, no cuántas veces se llamó a guardar.Las tres piezas para probar un ViewModel
1. Sustituir Main. viewModelScope corre en Dispatchers.Main.immediate, el hilo principal de Android, que en la JVM no existe.
Dispatchers.setMain(...) se llama antes de cada test y Dispatchers.resetMain() después, para no contaminar a los demás. Con un StandardTestDispatcher en Main, runTest comparte su planificador y el mismo reloj virtual mueve al viewModelScope.
2. El reloj virtual. Un temporizador que espera 60 segundos entre un paso y otro tardaría un minuto real en cada test. Con advanceTimeBy esos segundos pasan en milisegundos reales.
3. Turbine sobre el estado que expone el ViewModel: awaitItem() espera la siguiente emisión, skipItems(n) se salta las n primeras y expectMostRecentItem() toma la última ya emitida.
Piezas nuevas de esta lección:
@BeforeTesty@AfterTest: una función marcada con@BeforeTestse ejecuta antes de cada test, y una con@AfterTest, después de cada uno. Ahí vansetMainyresetMain.Dispatchers.setMainyDispatchers.resetMain, que acabas de ver.skipItems(n)yexpectMostRecentItem()de Turbine.SavedStateHandle()sin argumentos crea un handle vacío, sin Android (SavedStateHandle).
Ya vistas en Tests de corrutinas: tiempo virtual: StandardTestDispatcher, advanceTimeBy, runCurrent, advanceUntilIdle y @OptIn(ExperimentalCoroutinesApi::class); awaitItem() de Turbine, en Tests con backgroundScope y Turbine.
Un test de ViewModel, con un temporizador de cocina
Un temporizador de cocina inventado, TemporizadorViewModel, cuenta un segundo cada 1.000 ms. Su estado es una clase de datos, EstadoTemporizador, con el segundo actual (empieza en 0) y si está corriendo (empieza parado), y lo expone como un StateFlow. No es un fichero de Marcador: el ejercicio de esta lección es escribirlo. Así se prueba:
package com.sebastiannarvaez.marcador.ejercicios.temporizador_con_tests
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class) // las funciones de test son experimentales
class TemporizadorViewModelTest {
private val despachador = StandardTestDispatcher() // un dispatcher con reloj virtual
@BeforeTest
fun antes() = Dispatchers.setMain(despachador) // antes de CADA test: Main pasa a ser el de prueba
@AfterTest
fun despues() = Dispatchers.resetMain() // después de cada test: se deja como estaba
// Una función de ayuda: construye el ViewModel con un handle vacío, sin Android
private fun crear(segundos: Int = 60) = TemporizadorViewModel(SavedStateHandle(), segundos)
@Test
fun empiezaParado() = runTest { // runTest comparte el reloj virtual con Main
crear().estado.test { // Turbine: recoge el Flow dentro del bloque
assertEquals(EstadoTemporizador(0, false), awaitItem()) // espera la siguiente emisión y la compara
}
}
@Test
fun alCabo10SegundosMarca10() = runTest {
val viewModel = crear()
viewModel.estado.test {
skipItems(1) // se salta la emisión inicial
viewModel.arrancar()
advanceTimeBy(10 * 1_000L + 1) // adelanta 10 s virtuales (+1 para cruzar el instante exacto)
runCurrent() // ejecuta lo que quedó pendiente en ese instante
assertEquals(EstadoTemporizador(10, true), expectMostRecentItem()) // la última emisión, sin esperar más
}
}
}El + 1 de 10 * 1_000L + 1 cruza el instante exacto del décimo segundo; junto con runCurrent() asegura que ese segundo ya se emitió.
Prepara el proyecto · Instala las librerías de test de :app
Objetivo: que los tests de :app vean kotlin-test-junit, kotlinx-coroutines-test y Turbine, y que la app que se instala no las lleve.
Misma receta que en Los ficheros de Gradle de tu proyecto y Tests con backgroundScope y Turbine, ahora en :app.
1 · Qué instalas. Cuatro librerías de test, solo una nueva en el catálogo; las usarán los tests de :app, empezando por los del ejercicio:
kotlin-test-junit:@Test,@BeforeTest,@AfterTestyassertEqualssobre JUnit. En:torneobastabakotlin-test; en:apphace falta esta variante (trampa de abajo). Es la nueva.- JUnit 4: el motor del AGP para los tests unitarios; ya viene en la plantilla.
kotlinx-coroutines-testy Turbine:runTest, el reloj virtual y la recogida de unFlow; ya están en el catálogo.
2 · Catálogo. Abre Gradle Scripts → libs.versions.toml (Version Catalog "libs"). Sin cambios en [versions] ni en [plugins]; una línea al final de [libraries]:
# Se publica junto al compilador, con su mismo número: version.ref = "kotlin"
kotlin-test-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" }3 · Dependencias. Abre Gradle Scripts → build.gradle.kts (Module :app), busca testImplementation(libs.junit) y añade tres líneas debajo:
testImplementation(libs.junit) // ya estaba
// testImplementation: solo las ven los tests de (test), no la app que se instala
// ni los de (androidTest), que usan androidTestImplementation (como espresso)
testImplementation(libs.kotlin.test.junit) // nueva
testImplementation(libs.kotlinx.coroutines.test) // nueva
testImplementation(libs.turbine) // nueva4 · Sync Now. Debe terminar sin errores rojos; la primera vez descarga kotlin-test-junit y necesita internet.
5 · Comprueba. En la Terminal de Android Studio (View → Tool Windows → Terminal), primero lo que ven los tests de la variante gratisDebug:
./gradlew :app:dependencies --configuration gratisDebugUnitTestRuntimeClasspathDeben salir org.jetbrains.kotlin:kotlin-test-junit:2.4.20, org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0 y app.cash.turbine:turbine:1.2.1 (la salida es larga; si quieres ver solo esas líneas, añade al final | grep -E "kotlin-test|coroutines-test|turbine", que deja pasar solo las líneas que contienen alguno de esos tres nombres). Repite con gratisDebugRuntimeClasspath (lo que ve la app): no debe salir ninguna. Es implementation frente a testImplementation, medido.
Ejercicio · el temporizador y sus tests
Crea una subcarpeta nueva dentro de ejercicios (la carpeta que creaste en Rotación y muerte del proceso): clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .ejercicios.temporizador_con_tests al final del nombre que aparece ya escrito. Después, clic derecho sobre temporizador_con_tests → New → Kotlin Class/File → File → TemporizadorViewModel. Para el test, clic derecho sobre com.sebastiannarvaez.marcador (test) → New → Package → añade .ejercicios.temporizador_con_tests al final del nombre que aparece ya escrito → y dentro, New → Kotlin Class/File → File → TemporizadorViewModelTest.
Reto · Ejercicio: el temporizador y sus tests
El ejemplo de arriba prueba un TemporizadorViewModel que no existe: escríbelo en TemporizadorViewModel.kt. Tiene un estado con el segundo actual y si está corriendo (empieza en 0 y parado), lo expone como flujo de estado y tiene una función para arrancar: mientras no llegue a los segundos totales (60 si no se dicen otros), espera un segundo y suma uno; al llegar, se para. Cada vez que sube el segundo, guárdalo en el SavedStateHandle que recibe con la clave "segundo" (estadoGuardado["segundo"] = siguiente), y al crear el estado inicial léelo de ahí (estadoGuardado["segundo"] ?: 0).
Después, en TemporizadorViewModelTest.kt, copia la clase de test del ejemplo (con sus import) y añade un tercer test, seParaAlLlegarAlFinal: con 5 segundos totales, tras arrancar y adelantar 8 segundos virtuales, el estado es «segundo 5, parado».
Ejecuta solo estos tests con ./gradlew :app:testGratisDebugUnitTest --tests "*TemporizadorViewModelTest" en la Terminal de Android Studio (o clic derecho sobre la clase de test → Run). Debes ver BUILD SUCCESSFUL y, en el informe, 3 tests y 0 fallos.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.temporizador_con_tests
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class EstadoTemporizador(val segundo: Int = 0, val corriendo: Boolean = false)
class TemporizadorViewModel(
private val estadoGuardado: SavedStateHandle,
private val segundosTotales: Int = 60,
) : ViewModel() {
private val _estado = MutableStateFlow(EstadoTemporizador(segundo = estadoGuardado["segundo"] ?: 0))
val estado: StateFlow<EstadoTemporizador> = _estado
fun arrancar() {
if (_estado.value.corriendo) return
_estado.value = _estado.value.copy(corriendo = true)
viewModelScope.launch {
while (_estado.value.segundo < segundosTotales) {
delay(1_000)
val siguiente = _estado.value.segundo + 1
estadoGuardado["segundo"] = siguiente
_estado.value = _estado.value.copy(segundo = siguiente)
}
_estado.value = _estado.value.copy(corriendo = false)
}
}
}En TemporizadorViewModelTest.kt va la clase del ejemplo con este tercer test, dentro de la clase:
@Test
fun seParaAlLlegarAlFinal() = runTest {
val viewModel = crear(segundos = 5)
viewModel.estado.test {
skipItems(1)
viewModel.arrancar()
advanceTimeBy(8 * 1_000L)
runCurrent()
assertEquals(EstadoTemporizador(5, false), expectMostRecentItem())
}
}Con 5 segundos totales el bucle se detiene al llegar a 5 y marca corriendo = false; los 3 segundos virtuales que sobran no cambian nada.
Los ficheros del ejercicio se quedan en su carpeta: los tests de ejercicios seguirán corriendo con los demás de :app.
Punto de control
- En la Terminal de Android Studio,
./gradlew :app:testGratisDebugUnitTest --tests "*TemporizadorViewModelTest"termina enBUILD SUCCESSFULy el informe cuenta 3 tests y 0 fallos. Para ver el informe, abre en tu navegador el ficheroapp/build/reports/tests/testGratisDebugUnitTest/index.html(en la Terminal de un Mac:open app/build/reports/tests/testGratisDebugUnitTest/index.html). ./gradlew :app:dependencies --configuration gratisDebugUnitTestRuntimeClasspathmuestrakotlin-test-junit,kotlinx-coroutines-testyturbine, y congratisDebugRuntimeClasspathno sale ninguna de las tres../gradlew :torneo:testsigue pasando los 137 tests de:torneo(los que dejaste en Tests de corrutinas: tiempo virtual).
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.¿Cuál es la diferencia principal entre un fake y un mock del repositorio?
2.Un test unitario crea
PartidoViewModely falla porqueviewModelScopeno encuentra su dispatcher. ¿Qué falta?3.En
:appponestestImplementation(libs.kotlin.test), la librería que usa:torneo, y el compilador diceUnresolved reference 'AfterTest'. ¿Cómo se arregla?4.Con
runTest, ¿qué hace falta después deadvanceTimeBy(ms)para ejecutar lo que cae exactamente en el instante final?