Dos fuentes: la pantalla y las pruebas
Al terminar: Pintar siempre lo guardado, avisar cuando la red falla y probar el repositorio de dos fuentes.
Al terminar esta lección, la pantalla de árbitros de Marcador enseña siempre lo que hay guardado en el móvil. Al abrirla ves los diez árbitros al instante, aunque no haya internet, y la red solo sirve para ponerlos al día: al entrar y cada vez que pulsas «Actualizar». Si la red falla, un aviso aparece encima de la lista y la lista no se vacía. Y tendrás dos tests nuevos del repositorio, y los dos del ViewModel reescritos, que lo comprueban todo sin tocar internet.
Importa porque es la mitad que el usuario ve: de nada sirve guardar los árbitros si la pantalla no sabe pintar «hay lista y además está refrescando» o «hay lista y además falló la red». Temas: el UiState como data class; combine y when sin sujeto; el aviso sobre la lista; un DAO falso; y las pruebas del repositorio y del ViewModel.
El UiState pasa de sealed a data class
Antes, la pantalla de árbitros estaba o cargando, o con la lista, o con un error: un sealed. Con dos fuentes, eso ya no vale. Ahora «hay una lista», «se está refrescando» y «hubo un error» pueden pasar a la vez: la lista guardada con una barra de progreso encima, o la lista guardada con un aviso de error. Las situaciones ya no se excluyen, así que una data class con tres campos es mejor que tres variantes de un sealed.
El ViewModel tiene tres fuentes de información: lo guardado (un Flow que viene de Room), «refrescando» y el error (dos cajas MutableStateFlow suyas). combine las junta en un solo estado: cuando cualquiera de las tres cambia, vuelve a juntar los tres valores y emite un estado nuevo.
La pantalla, entonces, decide qué dibujar mirando el estado con un when sin sujeto (Condiciones: if y when): cada rama es una condición completa y gana la primera que sea verdadera. Por eso el orden importa: lo más concreto va arriba.
Un ejemplo con recetas
Sin Android: el mismo estado, el mismo combine y el mismo when, con una app de recetas. La «pantalla» es un collect que escribe en la consola qué dibujaría.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// UN solo estado con tres campos que pueden darse a la vez.
data class RecetasUiState(
val recetas: List<String> = emptyList(),
val refrescando: Boolean = false,
val error: String? = null,
)
// when SIN sujeto: cada rama es una condición y gana la primera que sea verdadera.
// Por eso el ORDEN importa: lo más concreto va arriba.
fun queSeVe(e: RecetasUiState): String = when {
e.recetas.isNotEmpty() && e.error != null -> "la lista y, encima, el aviso: ${e.error}"
e.recetas.isNotEmpty() && e.refrescando -> "la lista y una barra de progreso"
e.recetas.isNotEmpty() -> "solo la lista"
e.refrescando -> "una rueda de carga"
e.error != null -> "el error y el botón Reintentar"
else -> "nada"
}
fun main() = runBlocking {
val guardadas = MutableStateFlow(emptyList<String>()) // lo guardado (la fuente de verdad)
val refrescando = MutableStateFlow(false) // ¿hay una petición en marcha?
val error = MutableStateFlow<String?>(null) // ¿falló la última?
// combine: cuando CUALQUIERA de los tres cambia, junta los tres valores en un estado nuevo.
val estado = combine(guardadas, refrescando, error) { r, c, e -> RecetasUiState(r, c, e) }
val pantalla = launch { estado.collect { println(queSeVe(it)) } } // la «pantalla»: pinta cada estado nuevo
delay(50) // un momento para que pinte el estado inicial
refrescando.value = true; delay(50) // abro la pantalla por primera vez
guardadas.value = listOf("Tortilla", "Arepa"); refrescando.value = false; delay(50) // llega la red
refrescando.value = true; delay(50) // pulso «Actualizar»
error.value = "Sin conexión"; refrescando.value = false; delay(50) // y falla
pantalla.cancel()
}nada
una rueda de carga
solo la lista
la lista y una barra de progreso
la lista y, encima, el aviso: Sin conexiónFíjate en la última línea: la red falló y la lista sigue ahí, con el aviso encima. Con un sealed habría que elegir entre «lista» y «error»; con la data class caben los dos.
Reto · Ejercicio: la pantalla de un reproductor
Fuera de la app, en el Kotlin Playground (play.kotlinlang.org).
Un reproductor de música guarda las canciones en el móvil y las sincroniza con un servidor. Su estado tiene tres campos: canciones (una lista de textos, vacía por defecto), sincronizando (un Boolean, falso por defecto) y aviso (un texto que puede ser null).
Escribe la data class MusicaUiState, una función queSeVe que reciba el estado y devuelva un texto con un when sin sujeto, y un main que junte tres MutableStateFlow con combine y una «pantalla» que imprima queSeVe de cada estado nuevo. El texto que debe dar cada situación:
| ¿Hay canciones? | ¿Sincronizando? | ¿Hay aviso? | Texto |
|---|---|---|---|
| Sí | Sí | Sí | canciones, barra y aviso |
| Sí | No | Sí | canciones y aviso |
| Sí | Sí | No | canciones y barra |
| Sí | No | No | solo canciones |
| No | Sí | Sí o No | rueda |
| No | No | Sí | aviso y Reintentar |
| No | No | No | vacío: pulsa Sincronizar |
En main, después de crear la «pantalla» y esperar un momento (delay(50)), haz estos cambios, uno por uno, esperando delay(50) después de cada uno:
- Pon
sincronizandoen verdadero. - Pon las canciones «Intro» y «Final», y
sincronizandoen falso (los dos en la misma línea). - Pon
sincronizandoen verdadero. - Pon el aviso «Sin conexión».
- Pon
sincronizandoen falso.
La consola debe mostrar:
vacío: pulsa Sincronizar
rueda
solo canciones
canciones y barra
canciones, barra y aviso
canciones y avisoPiensa: si pones solo canciones como primera rama del when, ¿qué texto saldría en el último cambio?
Ver solución
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
data class MusicaUiState(
val canciones: List<String> = emptyList(),
val sincronizando: Boolean = false,
val aviso: String? = null,
)
fun queSeVe(e: MusicaUiState): String = when {
e.canciones.isNotEmpty() && e.sincronizando && e.aviso != null -> "canciones, barra y aviso"
e.canciones.isNotEmpty() && e.aviso != null -> "canciones y aviso"
e.canciones.isNotEmpty() && e.sincronizando -> "canciones y barra"
e.canciones.isNotEmpty() -> "solo canciones"
e.sincronizando -> "rueda"
e.aviso != null -> "aviso y Reintentar"
else -> "vacío: pulsa Sincronizar"
}
fun main() = runBlocking {
val canciones = MutableStateFlow(emptyList<String>())
val sincronizando = MutableStateFlow(false)
val aviso = MutableStateFlow<String?>(null)
val estado = combine(canciones, sincronizando, aviso) { c, s, a -> MusicaUiState(c, s, a) }
val pantalla = launch { estado.collect { println(queSeVe(it)) } }
delay(50)
sincronizando.value = true; delay(50)
canciones.value = listOf("Intro", "Final"); sincronizando.value = false; delay(50)
sincronizando.value = true; delay(50)
aviso.value = "Sin conexión"; delay(50)
sincronizando.value = false; delay(50)
pantalla.cancel()
}Las ramas van de lo más concreto a lo más general: las que miran tres campos, primero; las que miran uno, después. Si solo canciones fuera la primera, ganaría siempre que hubiera canciones y el aviso nunca se vería: el when se queda con la primera condición verdadera.
¿Cuándo refrescar?
| Momento | Cómo |
|---|---|
| Al abrir la pantalla | En el init del ViewModel (lo que haces hoy) |
| Cuando el usuario lo pide | Un botón «Actualizar» |
| Cada cierto tiempo, aunque la app esté cerrada | Un trabajo en segundo plano con WorkManager, que ya conoces de nombre por Trabajo en segundo plano. Marcador no lo necesita |
Paso · La pantalla de árbitros y sus pruebas
Objetivo. Dar contrato al repositorio, cambiar el @Binds de RepositoriosModule para que Hilt dé el de dos fuentes (es la única línea de Hilt que se toca; el resto son los ficheros de la lista de abajo), reescribir el UiState, el ViewModel y la pantalla, y probarlo todo con dos tests del repositorio y dos del ViewModel. Debes ver: con red, la lista de diez árbitros ordenada por nombre; sin red, al reabrir la pantalla, los diez guardados con un aviso encima.
Crea y borra. Con el selector en Android, ArbitrosRepositoryTest.kt va en el espejo de su fichero de main: en el paquete com.sebastiannarvaez.marcador (test), clic derecho → New → Package → añade .feature.arbitros.data; después, clic derecho sobre esa carpeta → New → Kotlin Class/File → File. El único fichero que se borra, ArbitrosRepositoryRed.kt, se borra en el paso 3.
Piezas nuevas de este paso:
combine(a, b, c) { … }: junta tresFlowen uno que emite cada vez que cualquiera cambia (el ejemplo de las recetas; más en Flow: scan y combine).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), inicial): convierte eseFlowen unStateFlowcon valor inicial; solo trabaja mientras la pantalla lo observa (y 5 segundos después, para que girar el móvil no lo reinicie).5_000es el número 5000 con un guion bajo para leerlo mejor. Se explica a fondo en stateIn, shareIn y los flujos calientes.LinearProgressIndicator: la barra fina de progreso.Surface(color = MaterialTheme.colorScheme.errorContainer): un fondo con el color de error del tema, que es el aviso.- Un
whensin sujeto dentro de la pantalla (el ejemplo de las recetas), yifque dibuja o no una pieza según el estado. - En los tests, el DAO falso: una clase que implementa
ArbitroDaoguardando las filas en memoria. UsasortedBy(ArbitroEntity::nombre)(ArbitroEntity::nombrees la propiedadnombrepasada como si fuera una función; equivale a{ it.nombre }),associateBy { it.id }(convierte la lista en un mapa deida fila),filterNot { it.id in nuevos }(quita las filas cuyoidya está en ese mapa) y+(une dos listas): juntas hacen el mismo «actualiza si existe, inserta si no» que@Upsert.
Ya vistas: @OptIn(ExperimentalMaterial3Api::class), Scaffold, items(…, key = …), CircularProgressIndicator, Arrangement.spacedBy y el bloque init del ViewModel (Red en Marcador: la lista de árbitros); mensajeParaElUsuario y ErrorDeRed (POST y errores de red); setMain, runTest y Turbine (Tests de un ViewModel: fakes, setMain y Turbine); FakeLigaApi (La pantalla de la crónica y sus pruebas).
Intenta escribirlo tú, en este orden:
- En
feature/arbitros/domain/Arbitros.kt: la interfazArbitrosRepositorypasa a pedirobservarArbitros()(unFlowde lista deArbitro) yrefrescar()(suspend, devuelve unResult<Unit>). (El proyecto se pone en rojo.) - En
feature/arbitros/data/ArbitrosRepositoryDosFuentes.kt: la clase declara que cumpleArbitrosRepository, y sus dos funciones llevanoverride. - En
di/RepositoriosModule.kt, el@Bindsde los árbitros ahora apunta al repositorio de dos fuentes. Es la única línea de Hilt que cambia para que entregue otro repositorio: el DAO de la misma base de datos de los partidos (una sola base, otra tabla) ya lo daDatosModule. Después borraArbitrosRepositoryRed.kt: clic derecho sobre él enfeature → arbitros → data→ Delete → OK. Como ya nadie lo usa, Android Studio no pondrá pegas; si aun así avisara de que se usa en algún sitio, pulsa Delete Anyway (borrar de todos modos): sería un resto del código que sigue en rojo y que arreglas en el paso 4. ArbitrosUiStatecomodata classcon la lista, si se está refrescando y el error; yArbitrosViewModel, que combina lo guardado con esos dos estados, refresca al abrirse y tieneactualizar().PantallaArbitroscon el botón «Actualizar» en la barra, una barra fina de progreso si refresca con datos, el aviso encima de la lista, y «Reintentar» solo cuando no hay nada guardado y la red falla. EnAppMarcador.kt, la pantalla recibevm::actualizar. (El código de la app vuelve a compilar.)- Los tests: en
Falsos.kt, el repositorio falso con las dos fuentes y un DAO falso en memoria;ArbitrosRepositoryTest(dos tests: refrescar guarda y la pantalla lo ve; sin red conserva lo guardado y el error es de dominio) y elArbitrosViewModelTestreescrito (dos tests: al abrir refresca y pinta lo de la red; sin red conserva lo guardado y avisa).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.feature.arbitros.domain
import kotlinx.coroutines.flow.Flow
// Un arbitro de la liga, como lo usa la app: en espanol y solo con lo que se pinta.
// No es el JSON (eso es `ArbitroDto`, en core/network): si manana el servidor cambia un
// nombre de campo, cambia el DTO y su traduccion, y esta clase ni se entera.
data class Arbitro(val id: Int, val nombre: String, val ciudad: String)
// DOS FUENTES, UNA VERDAD. La pantalla OBSERVA lo guardado (Flow: llega al instante y
// tambien sin red) y pide REFRESCAR, que trae la lista de la red y la guarda. Nunca pinta
// la respuesta de la red directamente: si refrescar falla, lo guardado sigue ahi.
interface ArbitrosRepository {
fun observarArbitros(): Flow<List<Arbitro>>
suspend fun refrescar(): Result<Unit>
}En ArbitrosRepositoryDosFuentes.kt, cambia la cabecera de la clase, añade override a las dos funciones y borra las dos líneas de comentario que empiezan por «HOY» (ya no son ciertas) y deja el resto igual (el import de ArbitrosRepository va con los demás):
import com.sebastiannarvaez.marcador.feature.arbitros.domain.ArbitrosRepositoryclass ArbitrosRepositoryDosFuentes @Inject constructor(
private val api: LigaApi,
private val dao: ArbitroDao,
) : ArbitrosRepository {
// Lo que ve la pantalla sale SIEMPRE de la base, traducido a dominio.
override fun observarArbitros(): Flow<List<Arbitro>> =
dao.observarArbitros().map { filas -> filas.map { Arbitro(it.id, it.nombre, it.ciudad) } }
// Red -> base. Al guardar, el Flow de arriba vuelve a emitir y la pantalla se entera
// sola. Si la red falla, la base no se toca y el fallo sale traducido.
override suspend fun refrescar(): Result<Unit> = intentar {
val dtos = api.arbitros()
dao.guardar(dtos.map { ArbitroEntity(id = it.id, nombre = it.name, ciudad = it.address.city) })
}.recoverCatching { throw it.aErrorDeRed() }
}En RepositoriosModule.kt, el import de ArbitrosRepositoryRed se cambia por este, y el @Binds de los árbitros apunta ahora al repositorio de dos fuentes:
import com.sebastiannarvaez.marcador.feature.arbitros.data.ArbitrosRepositoryDosFuentes // Quien pida ArbitrosRepository recibe ArbitrosRepositoryDosFuentes. Cambiar de implementacion es
// cambiar ESTA linea: ni el ViewModel ni la pantalla se enteran.
@Binds
@Singleton
abstract fun arbitros(impl: ArbitrosRepositoryDosFuentes): ArbitrosRepositorypackage com.sebastiannarvaez.marcador.feature.arbitros.ui
import com.sebastiannarvaez.marcador.feature.arbitros.domain.Arbitro
// Ahora una data class y no un sealed: la lista guardada, «refrescando» y el error
// CONVIVEN. Es el caso «recargando con los datos viejos a la vista»: mientras llega la
// red se ve lo guardado, y si falla se avisa sin vaciar la lista.
data class ArbitrosUiState(
val arbitros: List<Arbitro> = emptyList(),
val refrescando: Boolean = false,
val error: String? = null,
)package com.sebastiannarvaez.marcador.feature.arbitros.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.marcador.core.ui.mensajeParaElUsuario
import com.sebastiannarvaez.marcador.feature.arbitros.domain.ArbitrosRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
// La lista sale de lo guardado (observarArbitros) y se refresca al abrir y con
// «Actualizar». Tres flujos -> un UiState con `combine`.
@HiltViewModel
class ArbitrosViewModel @Inject constructor(private val repositorio: ArbitrosRepository) : ViewModel() {
private val refrescando = MutableStateFlow(false)
private val error = MutableStateFlow<String?>(null)
val uiState: StateFlow<ArbitrosUiState> =
combine(repositorio.observarArbitros(), refrescando, error) { arbitros, cargando, fallo ->
ArbitrosUiState(arbitros, cargando, fallo)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ArbitrosUiState(refrescando = true))
init {
actualizar()
}
fun actualizar() {
if (refrescando.value) return
refrescando.value = true
error.value = null
viewModelScope.launch {
// `refrescar` ya devuelve Result (no lanza): solo hay que mirar si fallo.
repositorio.refrescar().onFailure { error.value = it.mensajeParaElUsuario() }
refrescando.value = false
}
}
}package com.sebastiannarvaez.marcador.feature.arbitros.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sebastiannarvaez.marcador.feature.arbitros.domain.Arbitro
// Sin estado: recibe el UiState y avisa de «Actualizar» hacia arriba.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PantallaArbitros(estado: ArbitrosUiState, onActualizar: () -> Unit, modifier: Modifier = Modifier) {
Scaffold(
modifier = modifier,
contentWindowInsets = WindowInsets(0),
topBar = {
CenterAlignedTopAppBar(
title = { Text("Árbitros") },
actions = { TextButton(onClick = onActualizar, enabled = !estado.refrescando) { Text("Actualizar") } },
)
},
) { interior ->
Column(Modifier.padding(interior).fillMaxSize()) {
// Refrescando CON datos a la vista: una barra fina arriba, la lista no se toca.
if (estado.refrescando && estado.arbitros.isNotEmpty()) {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
// El aviso va ENCIMA de la lista: lo guardado sigue visible.
if (estado.error != null && estado.arbitros.isNotEmpty()) {
Surface(color = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.fillMaxWidth()) {
Text(
"${estado.error}. Se muestran los árbitros guardados.",
Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
when {
estado.arbitros.isNotEmpty() -> LazyColumn {
items(estado.arbitros, key = { it.id }) { arbitro ->
ListItem(
headlineContent = { Text(arbitro.nombre) },
supportingContent = { Text(arbitro.ciudad) },
)
HorizontalDivider()
}
}
// Nada guardado todavia (primera vez): o esperando a la red, o sin ella.
estado.refrescando -> Box(Modifier.fillMaxSize(), Alignment.Center) { CircularProgressIndicator() }
estado.error != null -> Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(estado.error, color = MaterialTheme.colorScheme.error)
Button(onClick = onActualizar) { Text("Reintentar") }
}
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PantallaArbitrosPreview() {
MaterialTheme {
PantallaArbitros(
ArbitrosUiState(
arbitros = listOf(Arbitro(1, "Leanne Graham", "Gwenborough"), Arbitro(2, "Ervin Howell", "Wisokyburgh")),
error = "No hay conexión",
),
onActualizar = {},
)
}
}En AppMarcador.kt, la línea de la pantalla de árbitros (dentro de entry<ArbitrosKey>) se sustituye por esta:
PantallaArbitros(estado, onActualizar = vm::actualizar)Los tests. En Falsos.kt, dos import nuevos (con los demás; Flow, MutableStateFlow y map ya estaban):
import com.sebastiannarvaez.marcador.core.database.ArbitroDao
import com.sebastiannarvaez.marcador.core.database.ArbitroEntityy el repositorio falso de árbitros se sustituye por estos dos (el resto del fichero no cambia):
// Las dos fuentes, de mentira: `guardados` hace de base y `deLaRed` de servidor.
// `refrescar` copia la red en la base, o falla con lo que el test le ponga.
class FakeArbitrosRepository(
guardados: List<Arbitro> = emptyList(),
var deLaRed: List<Arbitro> = listOf(Arbitro(1, "Leanne Graham", "Gwenborough")),
var fallo: Exception? = null,
) : ArbitrosRepository {
private val base = MutableStateFlow(guardados)
override fun observarArbitros(): Flow<List<Arbitro>> = base
override suspend fun refrescar(): Result<Unit> {
fallo?.let { return Result.failure(it) }
base.value = deLaRed
return Result.success(Unit)
}
}
// La tabla `arbitro`, en memoria: mismo orden y mismo upsert que la de verdad.
class FakeArbitroDao(guardados: List<ArbitroEntity> = emptyList()) : ArbitroDao {
private val filas = MutableStateFlow(guardados)
// Siempre ordenadas por nombre, como el ORDER BY de la consulta de verdad.
override fun observarArbitros(): Flow<List<ArbitroEntity>> = filas.map { it.sortedBy(ArbitroEntity::nombre) }
// Upsert a mano: quita las filas cuyo id llega de nuevo y añade las que llegan.
override suspend fun guardar(arbitros: List<ArbitroEntity>) {
val nuevos = arbitros.associateBy { it.id }
filas.value = filas.value.filterNot { it.id in nuevos } + arbitros
}
}package com.sebastiannarvaez.marcador.feature.arbitros.data
import com.sebastiannarvaez.marcador.core.FakeArbitroDao
import com.sebastiannarvaez.marcador.core.FakeLigaApi
import com.sebastiannarvaez.marcador.core.database.ArbitroEntity
import com.sebastiannarvaez.marcador.core.domain.ErrorDeRed
import com.sebastiannarvaez.marcador.core.domain.FalloDeRed
import com.sebastiannarvaez.marcador.feature.arbitros.domain.Arbitro
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import java.io.IOException
import kotlin.test.Test
import kotlin.test.assertEquals
// Las dos fuentes, falsas: FakeLigaApi (la red) y FakeArbitroDao (la base).
class ArbitrosRepositoryTest {
@Test
fun refrescarGuardaYLaPantallaLoVe() = runTest {
val repositorio = ArbitrosRepositoryDosFuentes(FakeLigaApi(), FakeArbitroDao())
assertEquals(Result.success(Unit), repositorio.refrescar())
assertEquals(listOf(Arbitro(1, "Leanne Graham", "Gwenborough")), repositorio.observarArbitros().first())
}
@Test
fun sinRedConservaLoGuardadoYElErrorEsDeDominio() = runTest {
val dao = FakeArbitroDao(listOf(ArbitroEntity(7, "Árbitro guardado", "Cali")))
val repositorio = ArbitrosRepositoryDosFuentes(FakeLigaApi(fallo = IOException("sin red")), dao)
val error = repositorio.refrescar().exceptionOrNull()
assertEquals(ErrorDeRed.SinConexion, (error as FalloDeRed).error)
assertEquals(listOf(Arbitro(7, "Árbitro guardado", "Cali")), repositorio.observarArbitros().first())
}
}package com.sebastiannarvaez.marcador.feature.arbitros.ui
import app.cash.turbine.test
import com.sebastiannarvaez.marcador.core.FakeArbitrosRepository
import com.sebastiannarvaez.marcador.core.domain.ErrorDeRed
import com.sebastiannarvaez.marcador.core.domain.FalloDeRed
import com.sebastiannarvaez.marcador.feature.arbitros.domain.Arbitro
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
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
// Las mismas tres piezas que PartidoViewModelTest: setMain, runTest y Turbine. La red no
// se toca: el ViewModel recibe un repositorio falso.
@OptIn(ExperimentalCoroutinesApi::class)
class ArbitrosViewModelTest {
private val despachador = StandardTestDispatcher()
@BeforeTest
fun antes() = Dispatchers.setMain(despachador)
@AfterTest
fun despues() = Dispatchers.resetMain()
private val leanne = Arbitro(1, "Leanne Graham", "Gwenborough")
@Test
fun alAbrirRefrescaYPintaLoDeLaRed() = runTest {
val viewModel = ArbitrosViewModel(FakeArbitrosRepository())
viewModel.uiState.test {
assertEquals(ArbitrosUiState(refrescando = true), awaitItem())
assertEquals(ArbitrosUiState(arbitros = listOf(leanne)), awaitItem())
}
}
@Test
fun sinRedConservaLoGuardadoYAvisa() = runTest {
val repositorio = FakeArbitrosRepository(guardados = listOf(leanne), fallo = FalloDeRed(ErrorDeRed.SinConexion))
val viewModel = ArbitrosViewModel(repositorio)
viewModel.uiState.test {
assertEquals(ArbitrosUiState(refrescando = true), awaitItem())
// La lista NO se vacia: lo guardado sigue y el error va al lado.
assertEquals(ArbitrosUiState(arbitros = listOf(leanne), error = "No hay conexión"), awaitItem())
}
}
}Dos detalles de los tests. En ArbitrosViewModelTest solo se esperan dos estados por test: el inicial (refrescando = true) y el final. Los pasos intermedios (la lista llega, luego refrescando pasa a falso) se agrupan, porque un StateFlow solo guarda el último valor y el despachador de test no ejecuta nada entre una asignación y la siguiente. Y en ArbitrosRepositoryTest, el repositorio recibe el DAO falso directamente: la clase no sabe si la base es Room o una lista en memoria.
Fíjate en lo que no has tocado: ni ArbitrosViewModel pide otro repositorio, ni AppMarcador sabe qué implementación hay detrás. El ViewModel sigue diciendo «necesito un ArbitrosRepository» y Hilt, mirando RepositoriosModule, le entrega el de dos fuentes. Para Hilt, cambiar de fuente de datos ha sido cambiar una línea (el @Binds): eso es lo que compra la inyección.
Qué pasa cuando abres la pantalla:
| Paso | Quién | Qué hace |
|---|---|---|
| 0 | Hilt | Entrega al ArbitrosViewModel el repositorio de dos fuentes (el que dice RepositoriosModule), con la LigaApi de RedModule y el DAO de DatosModule |
| 1 | ArbitrosViewModel | Empieza a observar observarArbitros() y llama a actualizar() |
| 2 | ArbitrosRepositoryDosFuentes | Pide GET /users a la red |
| 3 | ArbitroDao | @Upsert guarda los diez en la tabla arbitro |
| 4 | Room | La tabla cambió: el Flow emite la lista nueva |
| 5 | PantallaArbitros | Pinta la lista; si algo falló en el paso 2, solo añade el aviso |
Probarlo. Ejecuta la app (Run, variante gratisDebug) sin desinstalar y comprueba:
- En Partidos, el partido 1 conserva el mismo resultado que anotaste al empezar la lección anterior.
- Pulsa «Árbitros»: la lista sale ordenada por nombre (Chelsey Dietrich, Clementina DuBuque, Clementine Bauch…), ya no en el orden del servidor, porque ahora la ordena la base.
- Apaga la red del emulador: despliega los ajustes rápidos (desliza dos veces hacia abajo desde el borde superior de la pantalla) y activa Modo avión. Con la Terminal también vale
adb shell cmd connectivity airplane-mode enablesi tu emulador lo acepta; si no, usa los ajustes rápidos. - Sal de la pantalla de árbitros, cierra la app del todo (en el botón de apps recientes, desliza la app hacia arriba) y ábrela otra vez. Entra en «Árbitros»: aparece «No hay conexión. Se muestran los árbitros guardados.» encima de los diez de antes.
- Quita el modo avión y pulsa «Actualizar»: el aviso desaparece.
Si JSONPlaceholder no responde cuando pruebas con red, verás el mismo aviso: es un servicio gratuito de pruebas. Espera unos minutos.
Punto de control
./gradlew :app:assembleGratisDebugtermina enBUILD SUCCESSFUL../gradlew :app:testGratisDebugUnitTestcuenta 17 tests con tus ejercicios y 14 sin ellos, y 0 fallos (12 de antes más los 2 deArbitrosRepositoryTest; los 2 deArbitrosViewModelTestse reescriben, no se suman; el ejercicio del temporizador suma 3).- «Árbitros» lista diez árbitros ordenados por nombre; con el modo avión, tras reabrir la pantalla, siguen los diez con el aviso «No hay conexión. Se muestran los árbitros guardados.» y «Actualizar» lo quita al volver la red.
ArbitrosRepositoryRed.ktya no existe.
En Casos de uso: cuándo sí y cuándo sobran decides dónde meter la regla de publicar una crónica.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Por qué
ArbitrosUiStatepasa desealed interfaceadata class?2.El
ViewModeltiene tres fuentes de información (lo guardado, «refrescando» y el error). ¿Qué hacecombinecon ellas?3.En un
whensin sujeto que decide qué dibujar, ¿por qué importa el orden de las ramas?4.Sin red, ¿qué debe hacer la pantalla de árbitros si ya hay árbitros guardados?
5.¿Por qué
ArbitrosRepositoryTestusa unFakeArbitroDaoen lugar de la base de Room de verdad?6.Para que la pantalla de árbitros use el repositorio de dos fuentes, ¿qué cambia en la inyección?