Tests del ViewModel de Marcador
Al terminar: Escribir los tests del ViewModel de Marcador con fakes, setMain y Turbine.
Al terminar esta lección Marcador tendrá siete tests del ViewModel que se ejecutan en tu ordenador, sin emulador y en pocos segundos. Escribes tres (el paso de Cargando a Exito, el gol a mano y el aviso de cada 15 minutos) y copias cuatro con el botón Copiar fichero (el error, el partido de 90 minutos, la duración y el caso de uso). Entre los siete probarán que el estado pasa de Cargando a Exito, que un partido inexistente sale como Error, que un gol se ve y se guarda, que un partido de 90 minutos se juega en tiempo virtual y que el aviso de cada 15 minutos coincide con el marcador de la pantalla.
Importa porque un ViewModel con lógica y sin tests solo se prueba a mano, mirando la pantalla, y un fallo que depende del momento exacto casi nunca se deja ver así. Temas: fakes de un repositorio, un guion con goles y los tests del PartidoViewModel.
Por qué estos tests no usan Hilt
PartidoViewModel lleva @HiltViewModel, @AssistedInject y @Assisted, y podrías pensar que el test tiene que arrancar Hilt para construirlo. No hace falta. Esas anotaciones son etiquetas que solo lee Hilt, al compilar la app; el constructor sigue siendo un constructor normal, y el test lo llama a mano con los fakes, con el id escrito directamente: PartidoViewModel(SavedStateHandle(), repo, RegistrarGol(repo), preferencias, partidoInicial). El papel de la fábrica de El id del partido: inyección asistida lo hace el propio test.
Por eso ninguno de los siete tests necesita Hilt, y por eso se ejecutan en tu ordenador en segundos, sin emulador. Escribes estos tests por primera vez ahora, con la app ya montada con Hilt, y los construyes directamente con los fakes. Hilt tiene su propia librería de pruebas (hilt-android-testing, con @HiltAndroidTest) para tests que arrancan la app entera en un emulador; en Marcador no hace falta.
Un guion con goles, o el test no ve el fallo
El partido por defecto del fake no tiene goles. Si el aviso de cada 15 minutos leyera el marcador de un segundo reloj, desfasado un gol, el texto diría 0-0 mientras la pantalla ya muestra 1-0. Con un guion sin goles esa diferencia no existe: el aviso sale con 0-0 siempre, y el test pasaría con el fallo dentro.
Por eso el fake trae partidoConGoles(): goles en los minutos 15, 30 y 60. Los del 15 y el 30 caen justo en un aviso, el caso que un segundo reloj resolvería con un gol de retraso. El test elAvisoDeCada15MinutosCoincideConElMarcador recorre los seis avisos y comprueba que el texto y el marcador de la pantalla dicen lo mismo.
| Minuto | Marcador esperado |
|---|---|
| 15 | 1-0 (gol del local) |
| 30 | 1-1 (gol del visitante) |
| 45 | 1-1 |
| 60 | 2-1 (gol del local) |
| 75 y 90 | 2-1 |
En este ViewModel (Turbine, skipItems: los de la lección anterior) las dos primeras emisiones de uiState son siempre Cargando y el primer Exito, así que skipItems(2) deja el estado listo para empezar el test con el partido ya cargado.
Reto · Ejercicio: el test que aguanta un refactor
En el Kotlin Playground, fuera de tu proyecto. Ahí no hay JUnit, así que se comprueba con check(condición) { "mensaje" }: si la condición es falsa, el programa se detiene con ese mensaje; si es verdadera, no hace nada.
Un refactor es reescribir el código sin cambiar lo que hace. Aquí, dos formas de sumar goles que dan el mismo resultado.
- Una interfaz
Almacenconguardar(goles: Int)ytotal(): Int, y un fakeAlmacenFalsoque guarde el total en memoria. - Una clase
Contador(almacen)consumarGol()(guarda el total actual más uno) ysumarGoles(n)(guarda el total actual másn, con una sola llamada aguardar). - En
main, con un fake nuevo cada vez: suma dos goles llamando dos veces asumarGol, comprueba concheckque el total es 2 e imprímelo; después, con otro fake, llama asumarGoles(2), comprueba e imprime. Debe salir:
con sumarGol: 2
con sumarGoles: 2Piensa (un mock es el objeto de mentira que, en vez de comportarse, apunta qué llamadas recibe y las comprueba; lo viste en Tests de un ViewModel: fakes, setMain y Turbine): un mock que verificara «guardar se llamó dos veces», ¿qué haría con sumarGoles(2)?
Ver solución
interface Almacen {
fun guardar(goles: Int)
fun total(): Int
}
class Contador(private val almacen: Almacen) {
fun sumarGol() = almacen.guardar(almacen.total() + 1)
fun sumarGoles(n: Int) = almacen.guardar(almacen.total() + n)
}
class AlmacenFalso : Almacen {
private var goles = 0
override fun guardar(goles: Int) { this.goles = goles }
override fun total() = goles
}
fun main() {
val a = AlmacenFalso()
Contador(a).sumarGol()
Contador(a).sumarGol()
check(a.total() == 2) { "esperaba 2 y hay ${a.total()}" }
println("con sumarGol: ${a.total()}") // con sumarGol: 2
val b = AlmacenFalso()
Contador(b).sumarGoles(2)
check(b.total() == 2) { "esperaba 2 y hay ${b.total()}" }
println("con sumarGoles: ${b.total()}") // con sumarGoles: 2
}El fake comprueba el resultado (el total), así que las dos formas de llegar a 2 lo satisfacen. Un mock con verify de «dos llamadas a guardar» fallaría con sumarGoles(2), que llama una sola vez, aunque el comportamiento sea correcto.
Paso · Tests del ViewModel con un repositorio falso
Objetivo. Crear el fake y escribir PartidoViewModelTest. Al terminar, ./gradlew :app:testGratisDebugUnitTest (en la Terminal de Android Studio) pasa con 7 tests y 0 fallos: los de esta lección (el ExampleUnitTest que trae la plantilla de Android Studio lo borras por el camino). Si hiciste el ejercicio del temporizador, el informe cuenta 10: los 7 de Marcador y los 3 del ejercicio. De los siete, tres los escribes tú y cuatro los copias.
| Test | Quién lo escribe | Qué enseña |
|---|---|---|
pasaDeCargandoAExito | Tú | La preparación (setMain, crear) y Turbine sobre uiState |
unGolAManoSeVeYSeGuardaEnElRepositorio | Tú | Comprobar el fake: el resultado guardado, no las llamadas |
elAvisoDeCada15MinutosCoincideConElMarcador | Tú | El reloj virtual y el guion con goles |
unPartidoQueNoExisteSaleComoError, unPartidoDe90MinutosSeJuegaEnTiempoVirtual, cambiarLaDuracionLaGuardaYRecargaElPartido, elGolDelCasoDeUsoLoMarcaElJugadorPorTurno | Se copian | Repiten el mismo patrón con otros datos |
Crear los ficheros. Los tests copian las carpetas de main: el de PartidoViewModel va donde va PartidoViewModel (feature/marcador/ui), y lo que comparten varios tests, los falsos, va en core. En app → kotlin+java, clic derecho sobre com.sebastiannarvaez.marcador (test) (el que lleva (test) detrás; ahí está ExampleUnitTest) → New → Package; el campo ya trae com.sebastiannarvaez.marcador: escribe .core al final (con el punto delante). Después, clic derecho sobre core, dentro de ese mismo (test) → New → Kotlin Class/File → File → Falsos. Para el otro, de nuevo clic derecho sobre com.sebastiannarvaez.marcador (test) → New → Package y escribe .feature.marcador.ui al final; clic derecho sobre ese ui (si lo ves en una línea, feature.marcador.ui, es la misma carpeta) y crea PartidoViewModelTest igual que antes. Cada uno se abre con su línea package ya escrita. Cuando tengas los dos, borra el ejemplo de la plantilla: clic derecho sobre ExampleUnitTest.kt → Delete → OK. Era un test de muestra; a partir de aquí los tests de :app son los tuyos.
Los bloques de abajo llevan como título la ruta del fichero en tu proyecto (app/src/test/java/…); en la vista Android están en los paquetes (test) que acabas de crear.
Piezas nuevas de este paso:
private fun crear(…): una función normal de ayuda que construye elViewModelcon los fakes, para no repetir en cada test cómo se construye.uiState.test { … }(Turbine);skipItems(2)se saltaCargandoy el primerExito, para empezar con el partido ya cargado, yexpectMostRecentItem()toma la última emisión.x as MarcadorUiState.Exito: le dice a Kotlin «confía en mí, este estado es unExito», y así puedes leermarcadorominuto, que solo existen enExito. Si no lo fuera, el test falla con un error de conversión, que es justo lo que quieres.- El
+ 1deminuto * MS_POR_MINUTO + 1cruza el instante exacto del minuto; junto conrunCurrent()asegura que el aviso ya se emitió.
Ya vistas: @BeforeTest, @AfterTest, Dispatchers.setMain, resetMain y SavedStateHandle() en Tests de un ViewModel: fakes, setMain y Turbine; StandardTestDispatcher, advanceTimeBy, runCurrent, advanceUntilIdle y @OptIn(ExperimentalCoroutinesApi::class) en Tests de corrutinas: tiempo virtual; awaitItem() en Tests con backgroundScope y Turbine; a to b y su desestructuración en Colecciones; !! (Nulabilidad) y filterIsInstance (Lambdas con listas: filter, map y reglas).
Intenta escribirlo tú, en este orden.
- En
Falsos.kt:FakePartidosRepository, un repositorio de mentira que guarda en memoria los partidos que le den (si no, uno solo, el 1, Rayo contra Toros sin goles), con esos dos equipos, y que avisa a quien observe cuando registra un gol; como la interfazPartidosRepositorytambién pideobservarGoleadores, el fake lo implementa contando los goles por jugador;FakePreferencias, con una duración que empieza en 90 si no se dice otra y se puede cambiar; ypartidoConGoles, un Rayo contra Toros con goles en los minutos 15 (Rayo), 30 (Toros) y 60 (Rayo). - En
PartidoViewModelTest.kt, la preparación: antes de cada test, el hilo principal pasa a ser uno de prueba con reloj virtual, y después se deja como estaba; y una función de ayuda,crear, que construye elViewModelcon los dos fakes y el partido 1. - Tres tests:
pasaDeCargandoAExito(primero «cargando» y después éxito con el partido 1, 0-0 y 90 minutos);unGolAManoSeVeYSeGuardaEnElRepositorio(tras un gol del local, la pantalla ve 1-0 y el repositorio guarda un gol del Rayo); yelAvisoDeCada15MinutosCoincideConElMarcador(conpartidoConGoles, en los minutos 15, 30, 45, 60, 75 y 90 el aviso dice «minuto 15 con 1-0», «minuto 30 con 1-1», «minuto 45 con 1-1», «minuto 60 con 2-1» y así, y coincide con el marcador).
Lo que debes ver: los tres en verde. Cuando pasen, más abajo copias un fichero entero que trae los tuyos y otros cuatro.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
El fake del repositorio guarda los partidos en un MutableStateFlow, así que observarPartidos y observarGoleadores emiten cuando algo cambia, como haría Room.
package com.sebastiannarvaez.marcador.core
import com.sebastiannarvaez.marcador.feature.marcador.domain.PreferenciasRepository
import com.sebastiannarvaez.marcador.feature.partidos.domain.Goleador
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidoDeLista
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import com.sebastiannarvaez.marcador.torneo.Ejemplo
import com.sebastiannarvaez.marcador.torneo.Equipo
import com.sebastiannarvaez.marcador.torneo.Gol
import com.sebastiannarvaez.marcador.torneo.Partido
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
class FakePartidosRepository(
partidos: List<PartidoDeLista> = listOf(PartidoDeLista(1, Partido(Ejemplo.rayo, Ejemplo.toros))),
) : PartidosRepository {
private val lista = MutableStateFlow(partidos)
override val equipos: List<Equipo> = listOf(Ejemplo.rayo, Ejemplo.toros)
override fun observarPartidos(): Flow<List<PartidoDeLista>> = lista
override suspend fun partido(id: Int): Partido? = lista.value.firstOrNull { it.id == id }?.partido
override suspend fun registrarGol(partidoId: Int, gol: Gol) {
lista.value = lista.value.map { if (it.id == partidoId) it.copy(partido = it.partido.registrar(gol)) else it }
}
override fun observarGoleadores(): Flow<List<Goleador>> = lista.map { filas ->
filas.flatMap { it.partido.eventos.filterIsInstance<Gol>() }
.groupingBy { it.jugador.nombre }.eachCount().map { Goleador(it.key, it.value) }
}
}
fun partidoConGoles() = Partido(
Ejemplo.rayo, Ejemplo.toros,
eventos = listOf(
Gol(15, Ejemplo.rayo.plantilla[0], Ejemplo.rayo),
Gol(30, Ejemplo.toros.plantilla[0], Ejemplo.toros),
Gol(60, Ejemplo.rayo.plantilla[0], Ejemplo.rayo),
),
)
class FakePreferencias(inicial: Int = 90) : PreferenciasRepository {
val duracion = MutableStateFlow(inicial)
override val duracionDelPartido: Flow<Int> = duracion
override suspend fun cambiarDuracion(minutos: Int) {
duracion.value = minutos
}
}PartidoViewModelTest.kt con la preparación y tus tres tests.
package com.sebastiannarvaez.marcador.feature.marcador.ui
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.sebastiannarvaez.marcador.core.FakePartidosRepository
import com.sebastiannarvaez.marcador.core.FakePreferencias
import com.sebastiannarvaez.marcador.core.partidoConGoles
import com.sebastiannarvaez.marcador.feature.marcador.domain.Lado
import com.sebastiannarvaez.marcador.feature.marcador.domain.RegistrarGol
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidoDeLista
import com.sebastiannarvaez.marcador.torneo.Ejemplo
import com.sebastiannarvaez.marcador.torneo.Gol
import com.sebastiannarvaez.marcador.torneo.Marcador
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
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)
class PartidoViewModelTest {
private val despachador = StandardTestDispatcher()
@BeforeTest
fun antes() = Dispatchers.setMain(despachador)
@AfterTest
fun despues() = Dispatchers.resetMain()
private fun crear(
repo: FakePartidosRepository = FakePartidosRepository(),
preferencias: FakePreferencias = FakePreferencias(),
partidoInicial: Int = 1,
) = PartidoViewModel(SavedStateHandle(), repo, RegistrarGol(repo), preferencias, partidoInicial)
@Test
fun pasaDeCargandoAExito() = runTest {
val viewModel = crear()
viewModel.uiState.test {
assertEquals(MarcadorUiState.Cargando, awaitItem())
val exito = awaitItem() as MarcadorUiState.Exito
assertEquals(1, exito.partidoId)
assertEquals(Marcador(0, 0), exito.marcador)
assertEquals(90, exito.duracion)
}
}
@Test
fun unGolAManoSeVeYSeGuardaEnElRepositorio() = runTest {
val repo = FakePartidosRepository()
val viewModel = crear(repo)
viewModel.uiState.test {
skipItems(2)
viewModel.alEvento(MarcadorEvento.Gol(Lado.LOCAL))
assertEquals(Marcador(1, 0), (awaitItem() as MarcadorUiState.Exito).marcador)
advanceUntilIdle()
val guardados = repo.partido(1)!!.eventos.filterIsInstance<Gol>()
assertEquals(listOf(Ejemplo.rayo), guardados.map { it.equipo })
}
}
@Test
fun elAvisoDeCada15MinutosCoincideConElMarcador() = runTest {
val repo = FakePartidosRepository(listOf(PartidoDeLista(1, partidoConGoles())))
val viewModel = crear(repo)
viewModel.uiState.test {
skipItems(2)
viewModel.alEvento(MarcadorEvento.Empezar)
val esperado = listOf(15 to "1-0", 30 to "1-1", 45 to "1-1", 60 to "2-1", 75 to "2-1", 90 to "2-1")
var transcurrido = 0L
for ((minuto, marcador) in esperado) {
val hasta = minuto * PartidoViewModel.MS_POR_MINUTO + 1
advanceTimeBy(hasta - transcurrido)
transcurrido = hasta
runCurrent()
val estado = expectMostRecentItem() as MarcadorUiState.Exito
assertEquals(minuto, estado.minuto)
assertEquals("minuto $minuto con $marcador", estado.ultimoAviso)
assertEquals(marcador, estado.marcador.toString())
}
}
}
}Copia el fichero completo, con los otros cuatro tests
Cuando tus tres tests pasen, abre PartidoViewModelTest.kt, pulsa Copiar fichero, haz clic dentro del editor, selecciona todo con Cmd+A y pega con Cmd+V. El que pegas trae tus tres tests y cuatro más (siete en total) y sustituye al tuyo.
En el fichero pegado, crear se escribe TestScope.crear: es una función de extensión (Extensiones) sobre el alcance de runTest. Dentro no usa ese alcance, así que es lo mismo que tu crear normal: no cambia nada.
De los cuatro que copias, este es el que enseña el reloj virtual a mayor escala: un partido de 90 minutos, a 250 ms por minuto, son 22,5 segundos virtuales que se «juegan» en milisegundos reales.
@Test
fun unPartidoDe90MinutosSeJuegaEnTiempoVirtual() = runTest {
val viewModel = crear()
viewModel.uiState.test {
skipItems(2)
viewModel.alEvento(MarcadorEvento.Empezar)
runCurrent()
assertEquals(true, (expectMostRecentItem() as MarcadorUiState.Exito).corriendo)
advanceTimeBy(90 * PartidoViewModel.MS_POR_MINUTO + 1)
runCurrent()
val final = expectMostRecentItem() as MarcadorUiState.Exito
assertEquals(90, final.minuto)
assertEquals(false, final.corriendo)
assertEquals("minuto 90 con 0-0", final.ultimoAviso)
}
}Un paso aparte: limpiar la pantalla del partido
Los tests ya cubren lo que hacía el botón «Provocar error», así que la pantalla del partido se limpia. Es una limpieza que no enseña nada nuevo, y por eso se copia. Cambia esto:
- las demos de Compose (estado, orden de los modifiers, insignia) salen de la pantalla del partido y ya no se muestran;
- las demos de Android siguen, tras un botón de la barra superior de la lista de partidos que dice «Pruebas de Android» (así se llama);
PantallaMarcadordeja de recibir dos lambdas, la que abría las demos (onVerDemos) y la que provocaba el error (onProvocarError): ya no las necesita.
Son dos ficheros que ya tienes: AppMarcador, en app → kotlin+java → com.sebastiannarvaez.marcador → ui, y PantallaMarcador, en … → com.sebastiannarvaez.marcador → feature → marcador → ui. Para cada uno, ábrelo con doble clic, pulsa su botón, haz clic dentro del editor, selecciona todo con Cmd+A y pega con Cmd+V.
AppMarcador.kt (gana el botón «Pruebas de Android» en la barra y deja de pasar dos lambdas al partido):
PantallaMarcador.kt (pierde el botón «Provocar error», la barra inferior de demos y dos parámetros):
Copia los dos antes de compilar: cada uno pide al otro lo que le falta.
Punto de control
- En la Terminal de Android Studio,
./gradlew :app:testGratisDebugUnitTesttermina enBUILD SUCCESSFULy el informe de tests cuenta 7 tests y 0 fallos: los siete de la lección (y 3 más, 10 en total, si hiciste el ejercicio del temporizador de la lección anterior). Para ver el informe, abre en tu navegadorapp/build/reports/tests/testGratisDebugUnitTest/index.html(en la Terminal de un Mac:open app/build/reports/tests/testGratisDebugUnitTest/index.html)../gradlew :torneo:testsigue pasando los 137 tests de:torneo(los que dejaste en Tests de corrutinas: tiempo virtual). - Comprueba que el test sirve: en
Falsos.kt, cambia enpartidoConGoles()el gol del minuto 15 al minuto 16 y ejecuta los tests. Falla solo el de los avisos (7 tests completed, 1 failed, o10 tests completed, 1 failedsi hiciste el ejercicio del temporizador), con unComparisonFailureque el informe detalla comoexpected:<minuto 15 con [1]-0> but was:<minuto 15 con [0]-0>. Devuélvelo a 15. ./gradlew :app:assembleGratisDebugcompila. Para ejecutar la app: Run, conappen el selector y la variantegratisDebug. La pantalla del partido ya no tiene las demos de Compose ni «Provocar error»; la barra de la lista tiene un botón «Pruebas de Android» con las de Android.- Con el emulador, elige «60 min», pulsa «Empezar partido» y gira el móvil a mitad del partido: el partido sigue sin reiniciarse.
Con las pantallas navegables y los tests en verde, Marcador está listo para salir a internet. La lección siguiente, Red: HTTP y JSON, explica qué viaja por la red; la de después, Retrofit y la primera llamada, instala Retrofit y da el primer paso; los fakes y setMain de esta lección los usarás otra vez para probar el código de red.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Por qué el guion de prueba
partidoConGoles()tiene goles en los minutos 15 y 30?2.Un test de
ViewModelcomprueba que un gol quedó guardado. ¿Qué doble de prueba lo hace sobrevivir a un refactor que cambia cuántas veces se llama aguardar?3.En los tests del
PartidoViewModel, ¿para qué sirveskipItems(2)al principio de un test deuiState?4.¿Por qué el test de los avisos suma
+ 1al minuto (minuto * MS_POR_MINUTO + 1) y llama arunCurrent()?5.
PartidoViewModellleva@HiltViewModely@AssistedInject. ¿Qué hay que hacer en sus tests de:apppara que sigan funcionando?