Saltar al contenido
K
Jetpack y arquitecturaPaso a paso40 min de lectura · 8 min de máquina Avanzado

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.

ejemplo mínimo: un estado con tres campos y un when sin sujeto (con comentarios)
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ón

Fí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íNoSícanciones y aviso
SíSíNocanciones y barra
SíNoNosolo canciones
NoSíSí o Norueda
NoNoSíaviso y Reintentar
NoNoNovací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:

  1. Pon sincronizando en verdadero.
  2. Pon las canciones «Intro» y «Final», y sincronizando en falso (los dos en la misma línea).
  3. Pon sincronizando en verdadero.
  4. Pon el aviso «Sin conexión».
  5. Pon sincronizando en falso.

La consola debe mostrar:

vacío: pulsa Sincronizar
rueda
solo canciones
canciones y barra
canciones, barra y aviso
canciones y aviso

Piensa: si pones solo canciones como primera rama del when, ¿qué texto saldría en el último cambio?

Ver solución
solución: MusicaUiState y queSeVe
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?

MomentoCómo
Al abrir la pantallaEn el init del ViewModel (lo que haces hoy)
Cuando el usuario lo pideUn botón «Actualizar»
Cada cierto tiempo, aunque la app esté cerradaUn 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.

app → kotlin+java
com.sebastiannarvaez.marcador
di
RepositoriosModule.ktse modifica
feature
arbitros
domain
Arbitros.ktse modifica
data
ArbitrosRepositoryDosFuentes.ktse modifica
ArbitrosRepositoryRed.ktse borra
ui
ArbitrosUiState.ktse modifica
ArbitrosViewModel.ktse modifica
PantallaArbitros.ktse modifica
ui
AppMarcador.ktse modifica
com.sebastiannarvaez.marcador (test)
core
Falsos.ktse modifica
feature
arbitros
datacarpeta nueva
ArbitrosRepositoryTest.ktse crea
ui
ArbitrosViewModelTest.ktse modifica
Un fichero nuevo y nueve que se modifican, y uno que se borra, en la vista Android.

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 tres Flow en 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 ese Flow en un StateFlow con valor inicial; solo trabaja mientras la pantalla lo observa (y 5 segundos después, para que girar el móvil no lo reinicie). 5_000 es 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 when sin sujeto dentro de la pantalla (el ejemplo de las recetas), y if que dibuja o no una pieza según el estado.
  • En los tests, el DAO falso: una clase que implementa ArbitroDao guardando las filas en memoria. Usa sortedBy(ArbitroEntity::nombre) (ArbitroEntity::nombre es la propiedad nombre pasada como si fuera una función; equivale a { it.nombre }), associateBy { it.id } (convierte la lista en un mapa de id a fila), filterNot { it.id in nuevos } (quita las filas cuyo id ya 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:

  1. En feature/arbitros/domain/Arbitros.kt: la interfaz ArbitrosRepository pasa a pedir observarArbitros() (un Flow de lista de Arbitro) y refrescar() (suspend, devuelve un Result<Unit>). (El proyecto se pone en rojo.)
  2. En feature/arbitros/data/ArbitrosRepositoryDosFuentes.kt: la clase declara que cumple ArbitrosRepository, y sus dos funciones llevan override.
  3. En di/RepositoriosModule.kt, el @Binds de 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 da DatosModule. Después borra ArbitrosRepositoryRed.kt: clic derecho sobre él en feature → 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.
  4. ArbitrosUiState como data class con la lista, si se está refrescando y el error; y ArbitrosViewModel, que combina lo guardado con esos dos estados, refresca al abrirse y tiene actualizar().
  5. PantallaArbitros con 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. En AppMarcador.kt, la pantalla recibe vm::actualizar. (El código de la app vuelve a compilar.)
  6. 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 el ArbitrosViewModelTest reescrito (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
app/src/main/java/com/sebastiannarvaez/marcador/feature/arbitros/domain/Arbitros.kt
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):

app/src/main/java/com/sebastiannarvaez/marcador/feature/arbitros/data/ArbitrosRepositoryDosFuentes.kt
import com.sebastiannarvaez.marcador.feature.arbitros.domain.ArbitrosRepository
app/src/main/java/com/sebastiannarvaez/marcador/feature/arbitros/data/ArbitrosRepositoryDosFuentes.kt
class 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:

app/src/main/java/com/sebastiannarvaez/marcador/di/RepositoriosModule.kt
import com.sebastiannarvaez.marcador.feature.arbitros.data.ArbitrosRepositoryDosFuentes
app/src/main/java/com/sebastiannarvaez/marcador/di/RepositoriosModule.kt
    // 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): ArbitrosRepository
app/src/main/java/com/sebastiannarvaez/marcador/feature/arbitros/ui/ArbitrosUiState.kt
package 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,
)
app/src/main/java/com/sebastiannarvaez/marcador/feature/arbitros/ui/ArbitrosViewModel.kt
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
        }
    }
}
app/src/main/java/com/sebastiannarvaez/marcador/feature/arbitros/ui/PantallaArbitros.kt
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:

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
                    PantallaArbitros(estado, onActualizar = vm::actualizar)

Los tests. En Falsos.kt, dos import nuevos (con los demás; Flow, MutableStateFlow y map ya estaban):

app/src/test/java/com/sebastiannarvaez/marcador/core/Falsos.kt
import com.sebastiannarvaez.marcador.core.database.ArbitroDao
import com.sebastiannarvaez.marcador.core.database.ArbitroEntity

y el repositorio falso de árbitros se sustituye por estos dos (el resto del fichero no cambia):

app/src/test/java/com/sebastiannarvaez/marcador/core/Falsos.kt
// 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
    }
}
app/src/test/java/com/sebastiannarvaez/marcador/feature/arbitros/data/ArbitrosRepositoryTest.kt
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())
    }
}
app/src/test/java/com/sebastiannarvaez/marcador/feature/arbitros/ui/ArbitrosViewModelTest.kt
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:

PasoQuiénQué hace
0HiltEntrega al ArbitrosViewModel el repositorio de dos fuentes (el que dice RepositoriosModule), con la LigaApi de RedModule y el DAO de DatosModule
1ArbitrosViewModelEmpieza a observar observarArbitros() y llama a actualizar()
2ArbitrosRepositoryDosFuentesPide GET /users a la red
3ArbitroDao@Upsert guarda los diez en la tabla arbitro
4RoomLa tabla cambió: el Flow emite la lista nueva
5PantallaArbitrosPinta 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:

  1. En Partidos, el partido 1 conserva el mismo resultado que anotaste al empezar la lección anterior.
  2. 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.
  3. 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 enable si tu emulador lo acepta; si no, usa los ajustes rápidos.
  4. 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.
  5. 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:assembleGratisDebug termina en BUILD SUCCESSFUL.
  • ./gradlew :app:testGratisDebugUnitTest cuenta 17 tests con tus ejercicios y 14 sin ellos, y 0 fallos (12 de antes más los 2 de ArbitrosRepositoryTest; los 2 de ArbitrosViewModelTest se 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.kt ya 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. 1.¿Por qué ArbitrosUiState pasa de sealed interface a data class?

  2. 2.El ViewModel tiene tres fuentes de información (lo guardado, «refrescando» y el error). ¿Qué hace combine con ellas?

  3. 3.En un when sin sujeto que decide qué dibujar, ¿por qué importa el orden de las ramas?

  4. 4.Sin red, ¿qué debe hacer la pantalla de árbitros si ya hay árbitros guardados?

  5. 5.¿Por qué ArbitrosRepositoryTest usa un FakeArbitroDao en lugar de la base de Room de verdad?

  6. 6.Para que la pantalla de árbitros use el repositorio de dos fuentes, ¿qué cambia en la inyección?

Faltan 6 de 6