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

La pantalla de la crónica y sus pruebas

Al terminar: Publicar la crónica desde una pantalla y comprobar con tests los errores de red.

Al terminar esta lección, desde un partido podrás pulsar Crónica, ver una pantalla con un botón Publicar y recibir «Crónica publicada con el n.º 101»: habrás enviado datos a un servidor desde una pantalla tuya. Si algo falla, la pantalla dirá qué pasó con palabras claras, y tendrás tus primeros tests de un repositorio de red, escritos con una API falsa.

Importa porque un botón que publica tiene más estados que una lista: esperando, publicada y fallida; y la crónica ya publicada puede seguir a la vista aunque un intento posterior falle. Y porque probar la red sin red es lo que permite arreglar un fallo sin depender de un servidor. Temas: el estado de una pantalla como data class, getOrThrow() y fold, la pantalla y su navegación, una API falsa y un HttpException fabricado a mano.

Esta lección continúa donde terminó POST y errores de red: ya tienes publicarCronica en LigaApi, el repositorio CronicasRepositoryRed y el error de dominio. Hoy les pones pantalla y tests. Recuerda que JSONPlaceholder no guarda nada: el número que responde es siempre 101.

Abrir el sobre de un Result: getOrThrow y fold

En el ViewModel de hoy aparecen dos formas de abrir un Result que ya viste en Result: errores como valores: getOrThrow() y fold.

  • getOrThrow() devuelve el valor si el Result salió bien, y si falló lanza su excepción. Sirve para «sigue solo si esto salió bien; si no, que el fallo suba».
  • fold(onSuccess, onFailure) ejecuta una de las dos funciones según salió bien o mal (Red en Marcador: la lista de árbitros la usa con un ejemplo de dos líneas).

Juntas dan un patrón: dentro de un bloque intentar { … }, getOrThrow() hace que el fallo de una llamada interior salga del bloque y acabe en el Result de fuera, y fold convierte ese Result en el estado nuevo de la pantalla. Un ejemplo ajeno (enviar un correo) con el estado como data class:

ejemplo mínimo: el estado de un envío con getOrThrow y fold (con comentarios)
import kotlinx.coroutines.*
import kotlin.coroutines.cancellation.CancellationException
 
// El estado de la pantalla de un envío. Es una data class (no un sealed) porque
// «enviando», el recibo anterior y el error pueden convivir.
data class EnvioUiState(
    val enviando: Boolean = false,
    val recibo: String? = null,
    val error: String? = null,
)
 
// Un servicio de correo: devuelve el número del envío, o el fallo dentro de un Result
interface Correo { suspend fun enviar(texto: String): Result<Int> }
 
// Un correo de mentira: numera 1, 2… y falla cuando le pongas `fallar = true`
class CorreoFalso(var fallar: Boolean = false) : Correo {
    private var ultimo = 0
    override suspend fun enviar(texto: String): Result<Int> =
        if (fallar) Result.failure(IllegalStateException("sin red")) else Result.success(++ultimo)
}
 
// La `intentar` de Marcador: atrapa el fallo pero relanza la cancelación
suspend fun <T> intentar(bloque: suspend () -> T): Result<T> =
    try { Result.success(bloque()) } catch (e: CancellationException) { throw e } catch (e: Exception) { Result.failure(e) }
 
// Lo que hace un ViewModel al publicar: recibe el estado de antes y devuelve el de después
suspend fun publicar(correo: Correo, texto: String, antes: EnvioUiState): EnvioUiState {
    // copy: un estado igual al de antes, cambiando solo lo que nombras
    val enviando = antes.copy(enviando = true, error = null)
    println("pantalla: $enviando")
 
    val resultado = intentar {
        // getOrThrow(): si el Result salió bien, te da el valor; si falló, LANZA su
        // excepción. Aquí la recoge el `intentar` de fuera y la deja en `resultado`.
        val numero = correo.enviar(texto).getOrThrow()
        "n.º $numero"
    }
 
    // fold: ejecuta una de las dos funciones según salió bien o mal
    return resultado.fold(
        onSuccess = { enviando.copy(enviando = false, recibo = it) },
        onFailure = { enviando.copy(enviando = false, error = it.message) },
    )
}
 
fun main() = runBlocking {
    val correo = CorreoFalso()
    var estado = EnvioUiState()
 
    estado = publicar(correo, "Hola", estado)
    println("pantalla: $estado")
 
    correo.fallar = true
    estado = publicar(correo, "Hola otra vez", estado)
    println("pantalla: $estado")      // el recibo anterior SIGUE y el error va al lado
}
pantalla: EnvioUiState(enviando=true, recibo=null, error=null)
pantalla: EnvioUiState(enviando=false, recibo=n.º 1, error=null)
pantalla: EnvioUiState(enviando=true, recibo=n.º 1, error=null)
pantalla: EnvioUiState(enviando=false, recibo=n.º 1, error=sin red)

Fíjate en las dos últimas líneas: el recibo del primer envío sigue ahí cuando el segundo falla. Eso es lo que una data class permite y un sealed no.

Reto · Ejercicio: el estado de un préstamo

Fuera de Marcador, en el Kotlin Playground.

Copia este punto de partida: ya trae la biblioteca (una de mentira), la función intentar y el main.

punto de partida: la biblioteca y el main
import kotlinx.coroutines.*
import kotlin.coroutines.cancellation.CancellationException
 
interface Biblioteca { suspend fun prestar(libro: String): Result<Int> }
 
class BibliotecaFalsa(var cerrada: Boolean = false) : Biblioteca {
    private var ultimo = 0
    override suspend fun prestar(libro: String): Result<Int> =
        if (cerrada) Result.failure(IllegalStateException("la biblioteca está cerrada")) else Result.success(++ultimo)
}
 
suspend fun <T> intentar(bloque: suspend () -> T): Result<T> =
    try { Result.success(bloque()) } catch (e: CancellationException) { throw e } catch (e: Exception) { Result.failure(e) }
 
// Aquí escribes PrestamoUiState y pedir
 
fun main() = runBlocking {
    val biblioteca = BibliotecaFalsa()
    var estado = PrestamoUiState()
 
    estado = pedir(biblioteca, "Dune", estado)
    println(estado)
 
    biblioteca.cerrada = true
    estado = pedir(biblioteca, "Emma", estado)
    println(estado)
 
    // Con un préstamo ya en curso, pedir otro no hace nada
    biblioteca.cerrada = false
    println(pedir(biblioteca, "Ficciones", PrestamoUiState(prestando = true)))
}

Escribe una data class PrestamoUiState con tres campos, todos con valor por defecto: prestando (un booleano, falso al empezar), prestado (el texto del último préstamo, o null) y error (el texto del último fallo, o null). Después escribe suspend fun pedir(biblioteca: Biblioteca, libro: String, antes: PrestamoUiState): PrestamoUiState, que devuelve el estado de después:

  • si antes.prestando ya es verdadero, devuelve antes sin llamar a la biblioteca;
  • si no, marca prestando y limpia el error, y pide el préstamo dentro de intentar (con getOrThrow());
  • si sale bien, prestando vuelve a falso y prestado es «Dune» (préstamo n.º 1) (con el título del libro y el número que devolvió la biblioteca);
  • si falla, prestando vuelve a falso, error es el mensaje de la excepción y prestado conserva el valor de antes.

La consola debe mostrar:

PrestamoUiState(prestando=false, prestado=«Dune» (préstamo n.º 1), error=null)
PrestamoUiState(prestando=false, prestado=«Dune» (préstamo n.º 1), error=la biblioteca está cerrada)
PrestamoUiState(prestando=true, prestado=null, error=null)
Ver solución
solución: el estado de un préstamo
data class PrestamoUiState(
    val prestando: Boolean = false,
    val prestado: String? = null,
    val error: String? = null,
)
 
suspend fun pedir(biblioteca: Biblioteca, libro: String, antes: PrestamoUiState): PrestamoUiState {
    if (antes.prestando) return antes          // ya hay un préstamo en curso: no se hace nada
    val prestando = antes.copy(prestando = true, error = null)
    val resultado = intentar { "«$libro» (préstamo n.º ${biblioteca.prestar(libro).getOrThrow()})" }
    return resultado.fold(
        onSuccess = { prestando.copy(prestando = false, prestado = it) },
        onFailure = { prestando.copy(prestando = false, error = it.message) },
    )
}

copy conserva lo que no nombras: por eso prestado sigue siendo el de «Dune» cuando el segundo préstamo falla. En el tercer caso la biblioteca estaba abierta, pero el estado de salida no cambia: la función ni la llamó.

Probar la red sin red

Un test de un repositorio de red comprueba qué hace tu código con lo que la red le devuelve, no la red. Para eso necesitas una API que cumpla la interfaz LigaApi a mano: la API falsa (FakeLigaApi), que guarda lo que le envían y lanza el fallo que el test le pida. Los de Marcador no construyen un OkHttpClient:

Para el caso «el servidor contestó con un 500» hace falta una HttpException. Retrofit la crea cuando recibe una respuesta de error; en el test la fabricas tú: Response.error<Any>(500, "".toResponseBody(null)) construye una respuesta de error de Retrofit con el código 500 y un cuerpo vacío ("" convertido en cuerpo, sin tipo de contenido), y HttpException(esaRespuesta) es lo que lanzaría Retrofit de verdad. Es una receta: no hace falta entender cada pieza, sino saber para qué sirve.

Paso · La pantalla de la crónica y sus pruebas

Objetivo. Crear el ViewModel y la pantalla Crónica, el botón que lleva a ella, y los primeros tests de un repositorio de red. Al terminar, desde un partido pulsas Crónica y Publicar, y ves «Crónica publicada con el n.º 101», el resultado del partido y una línea por gol.

app → kotlin+java
com.sebastiannarvaez.marcador
di
RepositoriosModule.ktse modifica
feature
cronica
uicarpeta nueva
CronicaViewModel.ktse crea
PantallaCronica.ktse crea
marcador
ui
PantallaMarcador.ktse modifica
ui
AppMarcador.ktse modifica
com.sebastiannarvaez.marcador (test)
core
Falsos.ktse modifica
feature
cronica
datacarpeta nueva
CronicasRepositoryTest.ktse crea
Tres ficheros nuevos y cuatro que se modifican, en la vista Android.

Crea los ficheros. Con el selector en Android, despliega app → kotlin+java. La feature cronica ya tiene domain y data; falta lo que se ve: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .feature.cronica.ui al final del nombre que aparece ya escrito. Después, clic derecho sobre esa carpeta ui → New → Kotlin Class/File → File → CronicaViewModel, y lo mismo con PantallaCronica. AppMarcador y RepositoriosModule ya están en sus carpetas (ui y di), y PantallaMarcador en feature → marcador → ui.

El test sigue el mismo reparto que main: en el paquete com.sebastiannarvaez.marcador (test) (el que lleva (test) detrás), clic derecho → New → Package → añade .feature.cronica.data y crea ahí CronicasRepositoryTest.

Piezas nuevas de este paso:

  • CronicaUiState: la data class del estado. No tiene fichero propio: vive dentro de PantallaCronica.kt, encima de la función PantallaCronica.
  • getOrThrow() y fold: los del ejemplo de arriba.
  • Card(…): un recuadro de Material con fondo propio, para agrupar el título y los goles de la crónica.
  • filterIsInstance<Gol>(), sortedBy { it.minuto } y joinToString("\n") { … }: del partido se quedan solo los goles (Lambdas con listas: filter, map y reglas), se ordenan por minuto (Colecciones) y se unen en un texto con un salto de línea entre uno y otro (Guardar y leer JSON con Json).
  • Response.error<Any>(500, "".toResponseBody(null)): la receta de arriba para fabricar una HttpException en un test.

Intenta escribirlo tú, en este orden:

  1. CronicaViewModel (inyección asistida: @HiltViewModel(assistedFactory = …), @AssistedInject, el id con @Assisted y una interfaz Fabrica con @AssistedFactory) y PantallaCronica (con CronicaUiState dentro). El ViewModel de esta lección hace demasiado a propósito: pide el partido, escribe el texto de la crónica y publica. En Casos de uso: cuándo sí y cuándo sobran sacarás esa regla a su propia clase. Se parece a ArbitrosViewModel (viewModelScope.launch, intentar y fold), con una novedad: el estado se cambia con update, que toma el valor actual del StateFlow y lo sustituye por una copia con el campo cambiado (copy).
  2. El @Binds de las crónicas en RepositoriosModule y los cambios de AppMarcador y PantallaMarcador de justo debajo. Desde que añadas onVerCronica en el destino de PartidoKey de AppMarcador, el proyecto estará en rojo hasta que hagas todos los cambios de PantallaMarcador.kt y de AppMarcador.kt que se listan debajo. Es normal.
  3. Los tests: FakeLigaApi y CronicasRepositoryTest (con el primer test escrito por ti).

Debes ver el botón Crónica en el partido, la pantalla nueva con Publicar, y 12 tests que pasan.

Dónde va cada trozo (obligatorio). Los ficheros nuevos van enteros. En los que ya existen:

  • RepositoriosModule.kt: dos import y una función al final.
  • AppMarcador.kt y PantallaMarcador.kt: los trozos listados abajo.
  • Falsos.kt: cinco import y una clase al final.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
app/src/main/java/com/sebastiannarvaez/marcador/feature/cronica/ui/CronicaViewModel.kt
package com.sebastiannarvaez.marcador.feature.cronica.ui
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.marcador.core.domain.resumen
import com.sebastiannarvaez.marcador.core.ui.mensajeParaElUsuario
import com.sebastiannarvaez.marcador.feature.cronica.domain.CronicaPublicada
import com.sebastiannarvaez.marcador.feature.cronica.domain.CronicasRepository
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import com.sebastiannarvaez.marcador.torneo.Gol
import com.sebastiannarvaez.marcador.torneo.intentar
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
 
// Publica la cronica de un partido. OJO: este ViewModel hace DEMASIADO a proposito:
// pide el partido a un repositorio, decide como se escribe la cronica y la publica con
// otro. «Como se escribe una cronica» es una regla del negocio, no de la pantalla.
// INYECCION ASISTIDA, como en PartidoViewModel: Hilt pone lo que sabe construir; el id
// del partido lo pone la pantalla (sale de la CronicaKey). @Assisted marca el que llega de fuera.
@HiltViewModel(assistedFactory = CronicaViewModel.Fabrica::class)
class CronicaViewModel @AssistedInject constructor(
    private val partidos: PartidosRepository,
    private val cronicas: CronicasRepository,
    @Assisted private val partidoId: Int,
) : ViewModel() {
 
    // La fabrica que Hilt ESCRIBE: solo se declara la funcion con lo que falta.
    @AssistedFactory
    interface Fabrica {
        fun crear(partidoId: Int): CronicaViewModel
    }
 
    private val _uiState = MutableStateFlow(CronicaUiState(partidoId))
    val uiState: StateFlow<CronicaUiState> = _uiState.asStateFlow()
 
    fun publicar() {
        if (_uiState.value.publicando) return
        _uiState.update { it.copy(publicando = true, error = null) }
        viewModelScope.launch {
            val resultado = intentar {
                val partido = partidos.partido(partidoId) ?: error("No existe el partido $partidoId")
                val titulo = partido.resumen()
                val goles = partido.eventos.filterIsInstance<Gol>().sortedBy { it.minuto }
                val texto = if (goles.isEmpty()) "Sin goles" else goles.joinToString("\n") { "${it.minuto}' ${it.jugador.nombre} (${it.equipo.nombre})" }
                CronicaPublicada(cronicas.publicar(titulo, texto).getOrThrow(), titulo, texto)
            }
            _uiState.update { estado ->
                resultado.fold(
                    onSuccess = { estado.copy(publicando = false, publicada = it) },
                    onFailure = { estado.copy(publicando = false, error = it.mensajeParaElUsuario()) },
                )
            }
        }
    }
}
app/src/main/java/com/sebastiannarvaez/marcador/feature/cronica/ui/PantallaCronica.kt
package com.sebastiannarvaez.marcador.feature.cronica.ui
 
import androidx.compose.foundation.layout.Arrangement
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.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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.cronica.domain.CronicaPublicada
 
// Una data class y no un sealed: «publicando», la cronica ya publicada y el error
// CONVIVEN (si falla la segunda vez, la primera sigue a la vista).
data class CronicaUiState(
    val partidoId: Int,
    val publicando: Boolean = false,
    val publicada: CronicaPublicada? = null,
    val error: String? = null,
)
 
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PantallaCronica(estado: CronicaUiState, onPublicar: () -> Unit, modifier: Modifier = Modifier) {
    Scaffold(
        modifier = modifier,
        contentWindowInsets = WindowInsets(0),
        topBar = { CenterAlignedTopAppBar(title = { Text("Crónica del partido") }) },
    ) { interior ->
        Column(
            Modifier.padding(interior).fillMaxSize().padding(24.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            Text("Partido n.º ${estado.partidoId}: se publicarán el resultado y los goles.")
            if (estado.publicando) {
                CircularProgressIndicator()
            } else {
                Button(onClick = onPublicar) { Text("Publicar") }
            }
            estado.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
            estado.publicada?.let { cronica ->
                Text("Crónica publicada con el n.º ${cronica.numero}", style = MaterialTheme.typography.titleMedium)
                Card(Modifier.fillMaxWidth()) {
                    Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
                        Text(cronica.titulo, style = MaterialTheme.typography.titleLarge)
                        Text(cronica.texto)
                    }
                }
            }
        }
    }
}
 
@Preview(showBackground = true)
@Composable
private fun PantallaCronicaPreview() {
    MaterialTheme {
        PantallaCronica(
            CronicaUiState(1, publicada = CronicaPublicada(101, "Rayo FC 2-1 Toros", "12' Ana (Rayo FC)\n55' Iván (Toros)\n80' Ana (Rayo FC)")),
            onPublicar = {},
        )
    }
}

En RepositoriosModule.kt (de di), dos import nuevos junto a los demás:

app/src/main/java/com/sebastiannarvaez/marcador/di/RepositoriosModule.kt
import com.sebastiannarvaez.marcador.feature.cronica.data.CronicasRepositoryRed
import com.sebastiannarvaez.marcador.feature.cronica.domain.CronicasRepository

Y una función al final de la clase, debajo de la de los árbitros. Es el mismo @Binds con otra pareja: Hilt ya sabe construir CronicasRepositoryRed porque tiene @Inject constructor, y recibe la LigaApi de RedModule.

app/src/main/java/com/sebastiannarvaez/marcador/di/RepositoriosModule.kt
    @Binds
    @Singleton
    abstract fun cronicas(impl: CronicasRepositoryRed): CronicasRepository

En AppMarcador.kt, la clave nueva debajo de ArbitrosKey:

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
@Serializable
data class CronicaKey(val partidoId: Int) : NavKey

En el destino de PartidoKey (el entry<PartidoKey> { clave -> … }), PantallaMarcador recibe un parámetro nuevo, debajo de onVerGoleadores. clave es el nombre que la lambda le da a la clave de ese destino:

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
                        onVerCronica = { pila.add(CronicaKey(clave.partidoId)) },

Y el destino nuevo, al final de la lista de entry, debajo del de ArbitrosKey:

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
                entry<CronicaKey> { clave ->
                    // La segunda inyeccion asistida: el id de la cronica sale de la clave.
                    val vm = hiltViewModel<CronicaViewModel, CronicaViewModel.Fabrica> { fabrica ->
                        fabrica.crear(clave.partidoId)
                    }
                    val estado by vm.uiState.collectAsStateWithLifecycle()
                    PantallaCronica(estado, onPublicar = vm::publicar)
                }

AppMarcador.kt usa ahora dos piezas de feature → cronica → ui, otra carpeta, así que necesita sus import (junto a los demás del proyecto; Option+Enter sobre cada nombre en rojo también los añade):

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
import com.sebastiannarvaez.marcador.feature.cronica.ui.CronicaViewModel
import com.sebastiannarvaez.marcador.feature.cronica.ui.PantallaCronica

En PantallaMarcador.kt hay cuatro cambios, más la vista previa. Primero, el parámetro nuevo en PantallaMarcador, debajo de onVerGoleadores:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
    onVerCronica: () -> Unit,

Segundo, la llamada a PantallaMarcadorContenido, al final de PantallaMarcador, que ahora lo pasa:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
    PantallaMarcadorContenido(estado, pausas, viewModel::alEvento, onVerGoleadores, onVerCronica, onVolver, modifier)

Tercero, el mismo parámetro nuevo en PantallaMarcadorContenido, debajo de onVerGoleadores:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
    onVerCronica: () -> Unit,

Cuarto, en el slot extras, el botón «Ver goleadores» pasa a ir con el botón nuevo en una fila (sustituye el comentario que ya había y la línea del botón por este bloque):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
                        // Empujan otra clave en la pila de Navigation 3.
                        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                            Button(onClick = onVerGoleadores) { Text("Ver goleadores") }
                            Button(onClick = onVerCronica) { Text("Crónica") }
                        }

Y en la vista previa del final del fichero, la llamada a PantallaMarcadorContenido añade onVerCronica = {}:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
            pausas = 0, onEvento = {}, onVerGoleadores = {}, onVerCronica = {}, onVolver = {},

Con eso el proyecto vuelve a compilar. Los tests. En Falsos.kt van estos cinco import junto a los demás:

app/src/test/java/com/sebastiannarvaez/marcador/core/Falsos.kt
import com.sebastiannarvaez.marcador.core.network.ArbitroDto
import com.sebastiannarvaez.marcador.core.network.CronicaDto
import com.sebastiannarvaez.marcador.core.network.CronicaPublicadaDto
import com.sebastiannarvaez.marcador.core.network.DireccionDto
import com.sebastiannarvaez.marcador.core.network.LigaApi

Y esta clase al final:

app/src/test/java/com/sebastiannarvaez/marcador/core/Falsos.kt
// Una API de Retrofit cumplida A MANO: ni servidor, ni OkHttp (construir un OkHttpClient
// en la JVM escribe un error de `android.util.Log` en la salida). Guarda lo que le envian
// y lanza el fallo que el test le ponga.
class FakeLigaApi(
    var arbitros: List<ArbitroDto> = listOf(ArbitroDto(1, "Leanne Graham", DireccionDto("Gwenborough"))),
    var fallo: Exception? = null,
) : LigaApi {
    val enviadas = mutableListOf<CronicaDto>()
 
    override suspend fun arbitros(): List<ArbitroDto> {
        fallo?.let { throw it }
        return arbitros
    }
 
    override suspend fun publicarCronica(cronica: CronicaDto): CronicaPublicadaDto {
        fallo?.let { throw it }
        enviadas += cronica
        return CronicaPublicadaDto(id = 101)
    }
}
app/src/test/java/com/sebastiannarvaez/marcador/feature/cronica/data/CronicasRepositoryTest.kt
package com.sebastiannarvaez.marcador.feature.cronica.data
 
import com.sebastiannarvaez.marcador.core.FakeLigaApi
import com.sebastiannarvaez.marcador.core.domain.ErrorDeRed
import com.sebastiannarvaez.marcador.core.domain.FalloDeRed
import com.sebastiannarvaez.marcador.core.network.CronicaDto
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
import kotlin.test.Test
import kotlin.test.assertEquals
 
// El repositorio de red, probado con la API falsa: sin servidor y sin esperar a nadie.
// No hace falta setMain: aqui no hay ViewModel ni viewModelScope.
class CronicasRepositoryTest {
 
    @Test
    fun publicaYDevuelveElNumero() = runTest {
        val api = FakeLigaApi()
        val resultado = CronicasRepositoryRed(api).publicar("Rayo FC 2-1 Toros", "12' Ana (Rayo FC)")
        assertEquals(Result.success(101), resultado)
        assertEquals(listOf(CronicaDto("Rayo FC 2-1 Toros", "12' Ana (Rayo FC)", userId = 1)), api.enviadas)
    }
 
    @Test
    fun sinRedElErrorEsSinConexion() = runTest {
        val api = FakeLigaApi(fallo = IOException("sin red"))
        val error = CronicasRepositoryRed(api).publicar("t", "x").exceptionOrNull()
        assertEquals(ErrorDeRed.SinConexion, (error as FalloDeRed).error)
    }
 
    @Test
    fun unErrorDelServidorLlevaSuCodigo() = runTest {
        // Una HttpException se construye con una respuesta de error de Retrofit.
        val api = FakeLigaApi(fallo = HttpException(Response.error<Any>(500, "".toResponseBody(null))))
        val error = CronicasRepositoryRed(api).publicar("t", "x").exceptionOrNull()
        assertEquals(ErrorDeRed.Servidor(500), (error as FalloDeRed).error)
    }
}

Qué comprueban los tres tests. El primero, que publicar devuelve el 101 y que la API recibió exactamente el CronicaDto esperado (con userId = 1). El segundo, que una IOException llega al test como FalloDeRed(SinConexion). El tercero, que una HttpException con código 500 llega como FalloDeRed(Servidor(500)). En los dos últimos, error as FalloDeRed dice «tómala por un FalloDeRed» para poder leer su .error.

Probarlo. Ejecuta la app. Entra en el partido 1 y pulsa Crónica. Aparece «Partido n.º 1: se publicarán el resultado y los goles.» y el botón Publicar. Púlsalo: tras un instante verás «Crónica publicada con el n.º 101» y una tarjeta con el resultado y una línea por gol, como 10' Ana (Rayo FC). Con la base recién creada el resultado es «Rayo FC 3-2 Toros»; si ya has marcado goles a mano en el partido 1, el marcador y las líneas serán otros. Pulsa Publicar otra vez: el número sigue siendo 101, porque el servidor no guarda nada.

Ahora el error: activa el modo avión del emulador (arrastra dos veces desde arriba para abrir los ajustes rápidos y pulsa el icono del avión) y pulsa Publicar. Debe verse «No hay conexión» y la crónica anterior sigue a la vista, porque CronicaUiState es una data class y el error convive con ella. Quita el modo avión y publica de nuevo.

Punto de control

  • En un partido hay dos botones, Ver goleadores y Crónica. Crónica abre la pantalla nueva.
  • Publicar muestra «Crónica publicada con el n.º 101», el resultado del partido y una línea por gol; con el modo avión, «No hay conexión».
  • Árbitros sigue funcionando: la lista, y «No hay conexión» con el modo avión.
  • ./gradlew :app:testGratisDebugUnitTest pasa con 12 tests sin tus ejercicios (los 9 de antes y los 3 de CronicasRepositoryTest), o 15 si hiciste el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine: los ejercicios suman tests propios, y por eso tu cifra puede ser mayor que la de la guía.

Marcador ya habla con internet en las dos direcciones. Pero si cierras la app y no hay red, la lista de árbitros desaparece. Dos fuentes: el almacén y el repositorio arregla eso guardando los árbitros en la base de datos.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Los tests de CronicasRepositoryTest usan FakeLigaApi y no construyen un OkHttpClient. ¿Por qué?

  2. 2.¿Por qué CronicaUiState es una data class y no un sealed interface como ArbitrosUiState?

  3. 3.Dentro de intentar { … }, el código llama a cronicas.publicar(titulo, texto).getOrThrow(). Si publicar devolvió un fallo, ¿qué pasa?

  4. 4.Para probar el caso «el servidor contesta 500», el test hace HttpException(Response.error<Any>(500, "".toResponseBody(null))). ¿Para qué sirve?

  5. 5.Publicas una crónica y ves «n.º 101». Publicas otra y vuelves a ver «n.º 101». Con el modo avión, una tercera vez muestra «No hay conexión» y la crónica anterior sigue visible. ¿Qué es lo correcto?

Faltan 5 de 5