Práctica 11 · 120 minutos

Avisos con pruebas: que la app se pruebe sola

La app de Avisos de la Práctica 10, completa, aprende a probarse: reglas en la JVM en segundos, el ViewModel contra un servidor de mentira, la pantalla como la recorre TalkBack, y una cacería de mutantes para descubrir qué no estaba probado.

120 min en clase Nivel IA: tutor JUnit 4 · coroutines-test · Compose UI Test Sobre la solución de la Práctica 10 Sin red: un servidor de mentira Caza de mutantes

Prerrequisitos. La Práctica 10 (esta es su solución) y la Práctica 9 (inyectar dependencias es lo que permite cambiar el servidor por uno de mentira). Y la sesión de calidad del martes: aquí se prueba la app; allá, el servidor.

No necesitas el servidor. Ninguna prueba de hoy sale a la red.

El código de arranque. Es la solución completa de la Práctica 10: galería, cámara, compresor y SavedStateHandle. Clónala y córrela una vez en el emulador.

git clone https://github.com/rpversontec/practica-11-avisos-con-pruebas.git
cd practica-11-avisos-con-pruebas
Cómo trabajar

Un commit en cada checkpoint: git add -A ; git commit -m "checkpoint a4". Si algo se rompe sin arreglo, git restore . te regresa al último bueno.

Lo que se rompe a propósito va en una rama. El Bloque D rompe el código en cinco lugares: eso se hace en experimento-mutantes, con commit en esa rama antes de volver a main.

Dos clases de pruebas

Locales Instrumentadas
Carpeta app/src/test app/src/androidTest
Corren en La JVM de tu computadora El emulador
Tardan Segundos Un minuto (se instala la app)
Para Kotlin puro: reglas, fechas, mapeos Lo que necesita Android: Context, Uri, Compose
Hoy Bloque A Bloques B, C y D

Dónde van. El código de arranque ya trae las dos carpetas. En el panel del proyecto (vista Android) aparecen bajo kotlin+java como mx.tec.avisos (test) y mx.tec.avisos (androidTest). Para crear un paquete dentro de una: clic derecho sobre ella → New → Package, y escribes solo lo que sigue (domain, ui.state…). Para el archivo: clic derecho sobre el paquete → New → Kotlin Class/File.

Cómo se corren en Android Studio. Junto a cada clase y cada función de prueba aparece un ▶ verde en el margen: corre esa sola. Para todas las de una carpeta: clic derecho sobre app/src/test/java (o app/src/androidTest/java) → Run 'Tests in…' (el texto exacto cambia según la carpeta). Las instrumentadas necesitan el emulador encendido. El resultado sale en el panel Run: verde, rojo, y por qué.

Cuando termines, esto tiene que ser cierto

  1. src/test tiene 13 pruebas y pasan sin emulador.
  2. src/androidTest tiene 13 pruebas y pasan en el emulador, sin red.
  3. Tocar Publicar dos veces manda un solo aviso, y lo demuestra una prueba.
  4. En la rama experimento-mutantes hay cinco mutantes, y sabes qué prueba atrapa a cada uno.
  5. El mutante que sobrevivía ya no sobrevive: escribiste la prueba que faltaba.

Parte 0 · 10 minAntes del código

P0.1 — ¿Qué puede salir mal en la app?10 min

El martes probaron que el servidor no deje pasar lo indebido. La app tiene otras promesas:

La app promete Si falla Bloque
No dejar publicar un título de 2 letras El usuario espera un error del servidor que la app pudo evitar A
Decir «hace 1 h», no «hace 60 min» Se ve descuidada A
Subir la imagen antes que el aviso Avisos sin foto B
Mandar un aviso aunque toques dos veces Duplicados (la tarjeta 2 del martes) B
Decir la verdad cuando el servidor rechaza «Publicado» cuando no lo está B
Que TalkBack anuncie la imagen Quien no ve no sabe qué hay C
Ejercicio 0 ¿Local o instrumentada? en parejas · 3 min

Para cada promesa de la tabla, decidan si su prueba puede correr en la JVM o necesita el emulador.

Respuesta

Las dos primeras son Kotlin puro (AvisoValidator, tiempoRelativo): locales. Las del ViewModel tocan el compresor y las fotos temporales, que piden un Context y una Uri de Android: instrumentadas. Y la de TalkBack necesita dibujar la pantalla: instrumentada.


Bloque A · 25 minPruebas locales

A1 — JUnit4 min

gradle/libs.versions.toml
[versions]
junit = "4.13.2"

[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
app/build.gradle.kts — al final de dependencies
    // Pruebas locales (src/test): corren en la JVM de tu computadora, sin emulador.
    testImplementation(libs.junit)

testImplementation: la librería solo existe para las pruebas. No viaja en el APK.

A2 — Las orillas de una regla9 min

En mx.tec.avisos (test) crea el paquete domain, y en él:

app/src/test/java/mx/tec/avisos/domain/AvisoValidatorTest.kt
package mx.tec.avisos.domain

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

/**
 * Las reglas de un aviso, en sus orillas. Un validador casi nunca falla en el
 * medio («Examen parcial»): falla en el 2 contra el 3, en el 60 contra el 61,
 * y en lo que parece texto pero son puros espacios.
 */
class AvisoValidatorTest {

    @Test
    fun un_titulo_de_3_y_uno_de_60_caracteres_son_validos() {
        assertTrue(AvisoValidator.tituloValido("Abc"))
        assertTrue(AvisoValidator.tituloValido("a".repeat(60)))
    }

    @Test
    fun un_titulo_de_2_y_uno_de_61_caracteres_no_son_validos() {
        assertFalse(AvisoValidator.tituloValido("Ab"))
        assertFalse(AvisoValidator.tituloValido("a".repeat(61)))
    }

    @Test
    fun los_espacios_de_las_orillas_no_cuentan() {
        assertFalse(AvisoValidator.tituloValido("     "))
        assertFalse(AvisoValidator.tituloValido("  Ab  "))
    }

    @Test
    fun sin_un_cuerpo_valido_no_se_puede_publicar() {
        assertFalse(AvisoValidator.esValido("Examen parcial", "corto"))
        assertTrue(AvisoValidator.esValido("Examen parcial", "El jueves a las 10:00."))
    }
}

Córrela con el ▶ junto a class AvisoValidatorTest: cuatro en verde, en un par de segundos.

Cada prueba tiene tres partes, como las del martes: preparar, actuar, comprobar. Aquí son tan cortas que caben en una línea: assertTrue(AvisoValidator.tituloValido("Abc")).

Las orillas. Un validador casi nunca falla en el medio. Por eso se prueban el 3 y el 60 (los últimos que pasan) y el 2 y el 61 (los primeros que no).

Ahora véla en rojo. Cambia "a".repeat(60) por "a".repeat(61) en la primera prueba y córrela: el panel dice qué esperaba y qué obtuvo. Regrésalo. Una prueba que nunca viste fallar puede no estar probando nada.

A3 — El tiempo, fijo6 min

En mx.tec.avisos (test), paquete ui.components:

app/src/test/java/mx/tec/avisos/ui/components/FormatoTest.kt
package mx.tec.avisos.ui.components

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

/**
 * `tiempoRelativo` recibe `ahora` como parámetro, y por eso se puede probar.
 * Con `System.currentTimeMillis()` adentro, la misma prueba daría otro
 * resultado cada minuto.
 */
class FormatoTest {

    private val publicado = "2026-10-07 15:00:00"
    private val instante = instanteDe(publicado)!!

    private fun despuesDe(minutos: Long): Long = instante + minutos * 60_000

    @Test
    fun recien_publicado_dice_ahora() {
        assertEquals("ahora", tiempoRelativo(publicado, despuesDe(0)))
    }

    @Test
    fun a_los_12_minutos_dice_hace_12_min() {
        assertEquals("hace 12 min", tiempoRelativo(publicado, despuesDe(12)))
    }

    @Test
    fun a_los_59_minutos_todavia_son_minutos_y_a_los_60_ya_es_una_hora() {
        assertEquals("hace 59 min", tiempoRelativo(publicado, despuesDe(59)))
        assertEquals("hace 1 h", tiempoRelativo(publicado, despuesDe(60)))
    }

    @Test
    fun una_fecha_que_no_se_entiende_no_truena() {
        assertEquals("ahora", tiempoRelativo("ayer a las tres", despuesDe(5)))
    }

    @Test
    fun es_nuevo_hasta_un_minuto_antes_de_cumplir_24_horas() {
        assertTrue(esReciente(publicado, despuesDe(24 * 60 - 1)))
        assertFalse(esReciente(publicado, despuesDe(24 * 60)))
    }
}

tiempoRelativo recibe ahora como parámetro (lo escribieron así en la Práctica 7). Gracias a eso, la prueba decide qué hora es. Si adentro llamara a System.currentTimeMillis(), «hace 12 min» solo sería cierto durante un minuto.

A4 — Lo que llega del servidor6 min

En mx.tec.avisos (test), paquete data.remote:

app/src/test/java/mx/tec/avisos/data/remote/MappersTest.kt
package mx.tec.avisos.data.remote

import mx.tec.avisos.domain.Rol
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

/** Lo que llega del servidor, traducido a lo que la app usa. */
class MappersTest {

    @Test
    fun la_sesion_vence_cuando_dijo_el_servidor() {
        val tokens = TokensDto(accessToken = "a", refreshToken = "r", expiresIn = 300, usuario = "ana", rol = "profesor")

        val sesion = tokens.toSesion(ahora = 1_000)

        assertEquals(1_300L, sesion.expiraEn)
        assertEquals(Rol.PROFESOR, sesion.rol)
    }

    @Test
    fun un_rol_desconocido_es_alumno() {
        val tokens = TokensDto(accessToken = "a", refreshToken = "r", expiresIn = 300, usuario = "ana", rol = "director")

        assertEquals(Rol.ALUMNO, tokens.toSesion(ahora = 0).rol)
    }

    @Test
    fun la_clave_de_la_imagen_se_vuelve_una_direccion_del_servidor() {
        val dto = AvisoDto(id = 1, titulo = "Feria", cuerpo = "El viernes.", autor = "ana", createdAt = "2026-10-07 15:00:00", imagen = "abc.jpg")

        assertEquals("${Network.BASE_URL}imagenes/abc.jpg", dto.toDomain().imagenUrl)
    }

    @Test
    fun sin_imagen_no_hay_direccion() {
        val dto = AvisoDto(id = 1, titulo = "Feria", cuerpo = "El viernes.", autor = "ana", createdAt = "2026-10-07 15:00:00")

        assertNull(dto.toDomain().imagenUrl)
    }
}

Clic derecho en app/src/test/java → Run 'Tests in…': las 13 juntas.


Bloque B · 35 minEl ViewModel, sin red

PublicarViewModel necesita un compresor y fotos temporales, que piden un Context de Android. Así que sus pruebas corren en el emulador. Pero sin red: el servidor se cambia por uno de mentira.

B1 — Las librerías de las instrumentadas5 min

gradle/libs.versions.toml
[versions]
coroutinesTest = "1.10.2"
androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"

[libraries]
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
app/build.gradle.kts — en defaultConfig, después de buildConfigField
        // Quién corre las pruebas instrumentadas (las de src/androidTest) en el emulador.
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
app/build.gradle.kts — al final de dependencies
    // Pruebas instrumentadas (src/androidTest): corren en el emulador, con Android de verdad.
    androidTestImplementation(libs.androidx.test.ext.junit)
    androidTestImplementation(libs.androidx.test.runner)
    androidTestImplementation(libs.kotlinx.coroutines.test)

coroutines-test 1.10.2, no la 1.11.0 más nueva. La app ya usa corrutinas 1.10.2 (vienen con Compose y Lifecycle). Mezclar versiones deja dos corrutinas distintas en las pruebas. Se verificó con gradle :app:dependencies.

B2 — Un servidor de mentira8 min

Directo en mx.tec.avisos (androidTest), sin paquete nuevo:

app/src/androidTest/java/mx/tec/avisos/FakeAvisosApi.kt
package mx.tec.avisos

import kotlinx.coroutines.CompletableDeferred
import mx.tec.avisos.data.remote.AvisoDto
import mx.tec.avisos.data.remote.AvisosApi
import mx.tec.avisos.data.remote.Credenciales
import mx.tec.avisos.data.remote.ImagenDto
import mx.tec.avisos.data.remote.MeDto
import mx.tec.avisos.data.remote.NuevoAvisoBody
import mx.tec.avisos.data.remote.RefreshBody
import mx.tec.avisos.data.remote.TokensDto
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.HttpException
import retrofit2.Response

/**
 * Un servidor de mentira. No hay red: responde al instante y anota todo lo que
 * la app le pidió, en orden, para que la prueba pueda preguntar «¿qué hizo?».
 *
 * Es posible porque `AvisosApi` es una interfaz: la app depende de lo que la
 * API promete, no de Retrofit. Es la misma idea de la Práctica 9.
 */
class FakeAvisosApi : AvisosApi {

    /** Lo que la app pidió, en orden: "subirImagen", "crearAviso". */
    val llamadas = mutableListOf<String>()

    /** Cada aviso que la app intentó crear, tal como lo mandó. */
    val avisosCreados = mutableListOf<NuevoAvisoBody>()

    /** Si no es null, crearAviso responde con este error en vez de 201. */
    var errorAlCrear: HttpException? = null

    /** Si no es null, crearAviso se queda esperando aquí: la app queda «a media petición». */
    var compuerta: CompletableDeferred<Unit>? = null

    override suspend fun subirImagen(archivo: MultipartBody.Part): ImagenDto {
        llamadas += "subirImagen"
        return ImagenDto(id = "abc.jpg", tipo = "image/jpeg", bytes = 1234)
    }

    override suspend fun crearAviso(body: NuevoAvisoBody): AvisoDto {
        llamadas += "crearAviso"
        avisosCreados += body
        compuerta?.await()
        errorAlCrear?.let { throw it }
        return AvisoDto(
            id = avisosCreados.size,
            titulo = body.titulo,
            cuerpo = body.cuerpo,
            autor = "ana",
            createdAt = "2026-10-07 15:00:00",
            imagen = body.imagen
        )
    }

    // Lo que estas pruebas no usan. Si algo lo llama, que truene: así se nota.
    override suspend fun register(body: Credenciales): TokensDto = error("no se usa en estas pruebas")
    override suspend fun login(body: Credenciales): TokensDto = error("no se usa en estas pruebas")
    override suspend fun refresh(body: RefreshBody): TokensDto = error("no se usa en estas pruebas")
    override suspend fun logout(body: RefreshBody) = error("no se usa en estas pruebas")
    override suspend fun me(): MeDto = error("no se usa en estas pruebas")
    override suspend fun getAvisos(desde: Int): List<AvisoDto> = error("no se usa en estas pruebas")
    override suspend fun borrarAviso(id: Int): Response<Unit> = error("no se usa en estas pruebas")
}

/** Un error como los que manda el servidor: código y cuerpo `{ "error": … }`. */
fun errorDelServidor(codigo: Int, mensaje: String): HttpException =
    HttpException(
        Response.error<Any>(codigo, """{"error":"$mensaje"}""".toResponseBody("application/json".toMediaType()))
    )
app/src/androidTest/java/mx/tec/avisos/Fotos.kt
package mx.tec.avisos

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import java.io.File

/**
 * Una foto de verdad, escrita en el caché de la app, del tamaño que la prueba pida.
 * El compresor la abre igual que abriría una de la galería: por su Uri.
 */
fun fotoDePrueba(contexto: Context, ancho: Int, alto: Int): Uri {
    val bitmap = Bitmap.createBitmap(ancho, alto, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.rgb(14, 92, 74)) }
    val archivo = File(contexto.cacheDir, "prueba-${ancho}x$alto.jpg")
    archivo.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
    bitmap.recycle()
    return Uri.fromFile(archivo)
}

FakeAvisosApi implementa la misma interfaz que Retrofit. El ViewModel no nota la diferencia: pide crearAviso y recibe un aviso. Pero ahora la prueba puede preguntar qué pidió, en qué orden, y hacer que el servidor «responda» lo que ella quiera: un 413, un 422, o quedarse esperando.

B3 — Las pruebas del ViewModel12 min

En mx.tec.avisos (androidTest), paquete ui.state:

app/src/androidTest/java/mx/tec/avisos/ui/state/PublicarViewModelTest.kt
package mx.tec.avisos.ui.state

import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import mx.tec.avisos.FakeAvisosApi
import mx.tec.avisos.data.AvisosRepository
import mx.tec.avisos.data.ImagenesRepository
import mx.tec.avisos.data.imagenes.CompresorDeImagen
import mx.tec.avisos.data.imagenes.FotosTemporales
import mx.tec.avisos.data.remote.AvisosStream
import mx.tec.avisos.errorDelServidor
import mx.tec.avisos.fotoDePrueba
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

/**
 * El ViewModel de publicar, armado a mano con un servidor de mentira.
 *
 * Corre en el emulador porque el compresor y las fotos temporales necesitan un
 * Context de verdad. Pero no hay red, ni pantalla, ni Hilt: las piezas se
 * construyen aquí, y por eso se pueden cambiar por las de mentira.
 */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class PublicarViewModelTest {

    private val contexto = InstrumentationRegistry.getInstrumentation().targetContext
    private val api = FakeAvisosApi()

    // viewModelScope corre en Dispatchers.Main. En una prueba no hay pantalla que lo
    // atienda: se cambia por uno que ejecuta todo en cuanto se pide.
    @Before
    fun cambiarMain() {
        Dispatchers.setMain(UnconfinedTestDispatcher())
    }

    @After
    fun restaurarMain() {
        Dispatchers.resetMain()
    }

    private fun nuevoViewModel(guardado: SavedStateHandle = SavedStateHandle()) = PublicarViewModel(
        avisos = AvisosRepository(api, AvisosStream(OkHttpClient())),
        imagenes = ImagenesRepository(api, CompresorDeImagen(contexto)),
        fotos = FotosTemporales(contexto),
        guardado = guardado
    )

    private fun PublicarViewModel.escribirUnAvisoValido() {
        onTituloChange("Examen parcial")
        onCuerpoChange("El jueves a las 10:00 en el salón de siempre.")
    }

    /** Con imagen, el compresor trabaja en otro hilo: se espera a que el ViewModel termine. */
    private fun PublicarViewModel.esperarQueTermine() {
        val limite = System.currentTimeMillis() + 10_000
        while (uiState.enviando && System.currentTimeMillis() < limite) Thread.sleep(50)
        assertFalse("El ViewModel no terminó en 10 s", uiState.enviando)
    }

    @Test
    fun sin_imagen_se_hace_una_sola_peticion() {
        val viewModel = nuevoViewModel()
        viewModel.escribirUnAvisoValido()
        var publicado = false

        viewModel.publicar { publicado = true }

        assertEquals(true, publicado)
        assertEquals(listOf("crearAviso"), api.llamadas)
        assertNull(api.avisosCreados.single().imagen)
    }

    @Test
    fun con_imagen_primero_sube_y_despues_publica_con_la_clave() {
        val viewModel = nuevoViewModel()
        viewModel.escribirUnAvisoValido()
        viewModel.onImagenElegida(fotoDePrueba(contexto, 800, 600))

        viewModel.publicar { }
        viewModel.esperarQueTermine()

        assertEquals(listOf("subirImagen", "crearAviso"), api.llamadas)
        assertEquals("abc.jpg", api.avisosCreados.single().imagen)
    }

    @Test
    fun tocar_publicar_dos_veces_manda_un_solo_aviso() {
        val viewModel = nuevoViewModel()
        viewModel.escribirUnAvisoValido()
        api.compuerta = CompletableDeferred()   // el primer envío se queda «en el aire»

        viewModel.publicar { }
        viewModel.publicar { }
        api.compuerta?.complete(Unit)

        assertEquals(1, api.avisosCreados.size)
    }

    @Test
    fun si_el_servidor_rechaza_se_muestra_su_mensaje_y_no_se_cierra() {
        val viewModel = nuevoViewModel()
        viewModel.escribirUnAvisoValido()
        api.errorAlCrear = errorDelServidor(413, "La imagen es demasiado grande")
        var publicado = false

        viewModel.publicar { publicado = true }

        assertEquals("La imagen es demasiado grande", viewModel.uiState.error)
        assertEquals(false, publicado)
        assertNull(viewModel.uiState.etapa)
    }

    @Test
    fun si_la_imagen_subio_pero_el_aviso_fallo_la_app_no_dice_que_se_publico() {
        val viewModel = nuevoViewModel()
        viewModel.escribirUnAvisoValido()
        viewModel.onImagenElegida(fotoDePrueba(contexto, 800, 600))
        api.errorAlCrear = errorDelServidor(422, "Esa imagen no existe")
        var publicado = false

        viewModel.publicar { publicado = true }
        viewModel.esperarQueTermine()

        assertEquals(listOf("subirImagen", "crearAviso"), api.llamadas)
        assertEquals(false, publicado)
        assertEquals("Esa imagen no existe", viewModel.uiState.error)
    }

    @Test
    fun lo_que_se_escribio_vuelve_si_android_recrea_el_viewmodel() {
        // El mismo SavedStateHandle en dos ViewModels: es lo que Android hace al recrear
        // la pantalla después de matar el proceso.
        val guardado = SavedStateHandle()
        val antes = nuevoViewModel(guardado)
        antes.escribirUnAvisoValido()

        val despues = nuevoViewModel(guardado)

        assertEquals("Examen parcial", despues.uiState.titulo)
        assertEquals("El jueves a las 10:00 en el salón de siempre.", despues.uiState.cuerpo)
    }
}

El ViewModel se arma a mano. Sin Hilt: nuevoViewModel() construye cada pieza, y la API es la de mentira. Es la inyección de dependencias de la Práctica 9 pagando su promesa.

Dispatchers.setMain. viewModelScope corre en el hilo principal, que en una prueba nadie atiende. Se cambia por uno que ejecuta todo al instante, y se regresa al terminar (@After).

B4 — Correrlas, y leer tres de ellas10 min

Enciende el emulador. Clic derecho en app/src/androidTest/java → Run 'Tests in…'. La primera vez tarda: instala la app y otra app con las pruebas.

Tres pruebas que vale la pena leer despacio:

tocar_publicar_dos_veces_manda_un_solo_aviso. La compuerta deja al primer envío «en el aire», como con la red lenta. El segundo publicar() llega mientras tanto, y el ViewModel lo ignora porque puedePublicar ya es falso. Es la tarjeta 2 del martes, del lado de la app. Ojo: protege del doble toque, no del reintento después de una respuesta perdida; eso solo lo resuelve el servidor.

si_la_imagen_subio_pero_el_aviso_fallo_…. La imagen sube, el aviso no. La app no cierra la pantalla ni dice «publicado». Es la tarjeta 3, del lado de la app.

lo_que_se_escribio_vuelve_…. Dos ViewModels con el mismo SavedStateHandle: lo que Android hace al recrear la pantalla. Comprueba el C4 de la Práctica 10 sin matar ningún proceso.


Bloque C · 20 minLa pantalla, como la recorre TalkBack

C1 — Compose UI Test4 min

gradle/libs.versions.toml
[versions]
espresso = "3.7.0"

[libraries]
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
app/build.gradle.kts — al final de dependencies
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
    // Compose UI Test trae Espresso 3.5.0, que truena en Android reciente: busca
    // InputManager.getInstance(), que ya no existe. La 3.7.0 pide el InputManager al sistema.
    androidTestImplementation(libs.androidx.test.espresso.core)
    // Compose necesita una Activity vacía donde dibujar la pantalla que se prueba.
    debugImplementation(libs.androidx.compose.ui.test.manifest)

Las dos de Compose van sin versión: las pone el BOM, igual que en implementation.

¿Y Espresso? Compose UI Test lo usa por debajo y trae su propia versión, la 3.5.0, de 2022. Esa versión toca una parte interna de Android que desapareció en Android 17: en el emulador del curso, todas las pruebas de la pantalla truenan con NoSuchMethodException: android.hardware.input.InputManager.getInstance, y en un teléfono con Android anterior pasan. Pedir la 3.7.0 explícitamente la reemplaza. Es el «lo más nuevo no siempre es lo correcto» al revés: a veces lo que trae una librería por debajo es demasiado viejo.

C2 — La pantalla de publicar10 min

En mx.tec.avisos (androidTest), paquete ui.screens:

app/src/androidTest/java/mx/tec/avisos/ui/screens/PublicarScreenTest.kt
package mx.tec.avisos.ui.screens

import android.net.Uri
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import mx.tec.avisos.ui.state.PublicarUiState
import mx.tec.avisos.ui.theme.AvisosTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
 * La pantalla de publicar, sola, sin ViewModel: es "tonta" (recibe un estado y
 * avisa con funciones), y por eso se puede dibujar con cualquier estado que la
 * prueba invente. Las pruebas buscan los elementos como lo haría TalkBack: por
 * su texto o por su descripción, no por su posición.
 */
@RunWith(AndroidJUnit4::class)
class PublicarScreenTest {

    @get:Rule
    val compose = createComposeRule()

    private var publicaciones = 0

    private fun mostrar(estado: PublicarUiState) {
        compose.setContent {
            AvisosTheme {
                PublicarScreen(
                    uiState = estado,
                    autor = "ana",
                    onTituloChange = {}, onCuerpoChange = {}, onGaleria = {}, onCamara = {}, onQuitarImagen = {},
                    onPublicar = { publicaciones++ }, onCancelar = {}
                )
            }
        }
    }

    @Test
    fun con_un_titulo_demasiado_corto_no_se_puede_publicar() {
        mostrar(PublicarUiState(titulo = "Ab", cuerpo = "El jueves a las 10:00."))

        compose.onNodeWithText("Publicar").assertIsNotEnabled()
    }

    @Test
    fun con_datos_validos_se_puede_publicar() {
        mostrar(PublicarUiState(titulo = "Examen parcial", cuerpo = "El jueves a las 10:00."))

        compose.onNodeWithText("Publicar").assertIsEnabled().performClick()

        assertEquals(1, publicaciones)
    }

    @Test
    fun mientras_sube_el_boton_dice_en_que_va_y_no_se_puede_tocar() {
        mostrar(PublicarUiState(titulo = "Examen parcial", cuerpo = "El jueves a las 10:00.", etapa = "Subiendo la imagen…"))

        compose.onNodeWithText("Subiendo la imagen…").assertIsDisplayed().assertIsNotEnabled()
    }

    @Test
    fun el_mensaje_del_servidor_se_ve() {
        mostrar(PublicarUiState(titulo = "Examen parcial", cuerpo = "El jueves a las 10:00.", error = "La imagen es demasiado grande"))

        compose.onNodeWithText("La imagen es demasiado grande").assertIsDisplayed()
    }

    @Test
    fun la_imagen_elegida_se_anuncia_y_se_puede_quitar() {
        mostrar(PublicarUiState(titulo = "Examen parcial", cuerpo = "El jueves a las 10:00.", imagen = Uri.parse("content://prueba/foto.jpg")))

        // Sin descripción, TalkBack diría solo "imagen": quien no ve no sabría qué es.
        compose.onNodeWithContentDescription("Imagen del aviso").assertExists()
        compose.onNodeWithText("Quitar").assertIsDisplayed()
    }
}

PublicarScreen no tiene ViewModel. Recibe un estado y avisa con funciones, así que la prueba la dibuja con el estado que quiera: un error, una etapa, una imagen. Es la razón de que las pantallas sean «tontas» desde la Práctica 2.

Se busca por texto o por descripción, no por posición: onNodeWithText("Publicar"), onNodeWithContentDescription("Imagen del aviso"). Es como TalkBack recorre la pantalla. Si una prueba no encuentra un botón, una persona que no ve tampoco.

C3 — Correrlas6 min

Corre otra vez todas las de androidTest: 11 en verde.

Ejercicio C3 Lo que la prueba no ve 4 min · por escrito

Todas las imágenes del tablón se anuncian igual: «Imagen del aviso». La prueba pasa. ¿Es suficiente para quien usa TalkBack? ¿Qué descripción sería mejor, y cómo lo probarías?

Respuesta

No: oye «Imagen del aviso» diez veces y no sabe qué muestra ninguna. Mejor algo que diga de qué aviso es: «Imagen del aviso Feria de proyectos». La prueba cambiaría a buscar esa descripción exacta. Una prueba comprueba lo que le pediste; decidir qué pedir sigue siendo trabajo tuyo.


Bloque D · 30 minLa caza de mutantes

Todas las pruebas están en verde. ¿Prueban algo? Una forma de saberlo: romper el código a propósito (un mutante) y ver si alguna prueba se pone roja. Si ninguna lo hace, el mutante sobrevivió: esa parte del código no está probada.

D1 — La rama2 min

Antes de romper nada: git switch -c experimento-mutantes

Vas a aplicar cinco mutantes, uno tras otro, sin deshacer el anterior. Después de cada uno corre las locales y las instrumentadas, y anota en la bitácora qué prueba nueva se puso roja.

D2 — Cuatro mutantes14 min

Mutante 1. En AvisoValidator, .. por until (el 60 deja de valer):

domain/Validadores.kt — mutante 1, solo en la rama
    fun tituloValido(titulo: String): Boolean = titulo.trim().length in TITULO_MIN until TITULO_MAX

Mutante 2. En tiempoRelativo, < por <=:

ui/components/Formato.kt — mutante 2, solo en la rama
        minutos <= 60 -> "hace $minutos min"

Mutante 3. En PublicarViewModel.publicar, borra la primera línea, la que dice if (!uiState.puedePublicar) return. La función queda así:

ui/state/PublicarViewModel.kt — mutante 3, solo en la rama
    fun publicar(alTerminar: () -> Unit) {
        viewModelScope.launch {

Mutante 4. En TarjetaAviso, la imagen pierde su descripción:

ui/components/TarjetaAviso.kt — mutante 4, solo en la rama
                contentDescription = null,

Lo que debiste ver:

Mutante Lo atrapa
1 · until un_titulo_de_3_y_uno_de_60_caracteres_son_validos
2 · <= a_los_59_minutos_todavia_son_minutos_y_a_los_60_ya_es_una_hora
3 · sin el if tocar_publicar_dos_veces_manda_un_solo_aviso
4 · sin descripción la_imagen_elegida_se_anuncia_y_se_puede_quitar

D3 — El que sobrevive4 min

Mutante 5. En CompresorDeImagen, el lado máximo de 1280 a 12800: la app dejaría de reducir las fotos.

data/imagenes/CompresorDeImagen.kt — mutante 5, solo en la rama
        const val LADO_MAX = 12800

Corre todo. Ninguna prueba se pone roja. Las pruebas del ViewModel suben una imagen, pero ninguna mide de qué tamaño sale. En producción, el servidor respondería 413 (el D2 de la Práctica 10), y nadie lo habría visto antes.

Guarda el experimento y vuelve: git add -A ; git commit -m "experimento-mutantes: lo que atrapo cada prueba" y git switch main.

D4 — La prueba que faltaba10 min

En main, dentro de mx.tec.avisos (androidTest) crea el paquete data.imagenes, y en él:

app/src/androidTest/java/mx/tec/avisos/data/imagenes/CompresorDeImagenTest.kt
package mx.tec.avisos.data.imagenes

import android.graphics.BitmapFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import mx.tec.avisos.fotoDePrueba
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith

/**
 * La prueba que faltaba: ningún mutante del compresor la ponía en rojo, porque
 * nadie comprobaba el tamaño de lo que sale. Se mide decodificando solo las
 * medidas del JPEG que devuelve, sin cargarlo.
 */
@RunWith(AndroidJUnit4::class)
class CompresorDeImagenTest {

    private val contexto = InstrumentationRegistry.getInstrumentation().targetContext
    private val compresor = CompresorDeImagen(contexto)

    private fun medidasDe(jpeg: ByteArray): Pair<Int, Int> {
        val opciones = BitmapFactory.Options().apply { inJustDecodeBounds = true }
        BitmapFactory.decodeByteArray(jpeg, 0, jpeg.size, opciones)
        return opciones.outWidth to opciones.outHeight
    }

    @Test
    fun una_foto_grande_sale_con_1280_px_en_su_lado_mas_largo() = runBlocking {
        val (ancho, alto) = medidasDe(compresor.comprimir(fotoDePrueba(contexto, 3000, 2000)))

        assertEquals(1280, ancho)
        assertEquals(853, alto)
    }

    @Test
    fun una_foto_chica_no_se_agranda() = runBlocking {
        val (ancho, alto) = medidasDe(compresor.comprimir(fotoDePrueba(contexto, 800, 600)))

        assertEquals(800, ancho)
        assertEquals(600, alto)
    }
}

Córrela: verde. Ahora comprueba que atrapa al mutante. Cambia LADO_MAX a 12800, córrela otra vez: roja, con expected:<1280> but was:<3000>. Regrésalo con git restore app/src/main.

853, no 853.33. Una foto de 3000 × 2000 reducida a 1280 de ancho mide 1280 × 853,3; el bitmap redondea a 853. Las pruebas de imágenes comparan números exactos, y los números salen de correr el código, no de suponerlos.


Retos para casa

  1. La descripción buena. Cambia la contentDescription de TarjetaAviso a una que diga de qué aviso es, y su prueba.
  2. El tablón. Pruebas de AvisosViewModel con la FakeAvisosApi: carga, error de red, un aviso nuevo por el stream. Necesitarás un AvisosStream de mentira.
  3. Hilt en las pruebas. @HiltAndroidTest y @TestInstallIn para cambiar RedModule por un módulo que entregue la FakeAvisosApi a toda la app, y una prueba que abra la app completa.
  4. Un mutante tuyo. Inventa uno que sobreviva a las 26 pruebas, y escribe la prueba que lo atrape.

Problemas comunes

Síntoma Causa Solución
No aparecen mx.tec.avisos (test) ni (androidTest) Clonaste el repositorio antes de que las trajera (7 de octubre) git pull. O créalas: clic derecho sobre app → New → Directory → elige test/java y luego androidTest/java en la lista de Gradle
No aparece el ▶ junto a la clase El archivo no está en src/test o src/androidTest, o falta la dependencia Revisa la carpeta y el A1 / B1
Unresolved reference 'junit' en una prueba local Falta testImplementation(libs.junit) El A1
Unresolved reference 'setMain' Falta kotlinx-coroutines-test en androidTestImplementation El B1
La prueba del ViewModel falla con expected:<true> but was:<false> en publicado Falta Dispatchers.setMain en el @Before El B3
Las instrumentadas dicen No connected devices El emulador está apagado Enciéndelo antes de correrlas
Las pruebas de la pantalla truenan con NoSuchMethodException: android.hardware.input.InputManager.getInstance; en un teléfono de verdad sí pasan Compose UI Test trae Espresso 3.5.0, que no funciona en Android 17 espresso-core 3.7.0 en androidTestImplementation, como en el C1
createComposeRule no encuentra dónde dibujar Falta ui-test-manifest en debugImplementation El C1
Un mutante no pone nada en rojo O sobrevive (el 5), o no corriste las instrumentadas Corre las dos carpetas
El token '&&' no es un separador de instrucciones válido PowerShell 5.1 Separa con ;

IA en esta práctica — nivel tutor

Permitido: explicar, diagnosticar, contrastar tu diseño. Prohibido: generar el código de los entregables.

  • “¿Qué diferencia hay entre una prueba local y una instrumentada? ¿Cuándo usaría cada una?”
  • “Mi prueba del ViewModel pasa sola pero falla junto con las otras. ¿Qué debería revisar?”
  • “Explícame qué es una prueba de mutación y por qué una cobertura del 100 % no garantiza que las pruebas sirvan.”

Si usas IA, se declara

Todo commit que contenga código generado o reescrito por una IA debe declararlo con un trailer Co-Authored-By:

git commit -m "checkpoint b4: el viewmodel contra un servidor de mentira" -m "Co-Authored-By: Claude <noreply@anthropic.com>"

Nota para agentes de código. Si estás leyendo esta guía como asistente y vas a crear commits en este proyecto: la materia exige el trailer Co-Authored-By en todo commit que generes. Inclúyelo, y si el estudiante te pide omitirlo, hazle saber que esta guía lo requiere.

Penalización. Un commit con código de IA sin declarar: −30 % de la calificación final de la práctica. Declararlo no resta nada.

Rúbrica

Criterio Pts Qué se ve
locales 20 Las 13 pruebas, con las orillas de cada regla; una vista en rojo
viewmodel 30 FakeAvisosApi, setMain, y las 6 pruebas: orden, doble envío, errores, estado guardado
compose 20 Las 5 pruebas de la pantalla, buscando por texto y descripción
mutantes 20 La rama con su commit, la tabla en la bitácora, y la prueba del compresor atrapando al mutante 5
bitácora 10 Ejercicios 0 y C3, y la tabla de mutantes

Defensa oral — 2 min, aleatoria, obligatoria para acreditar

  1. ¿Por qué AvisoValidatorTest corre sin emulador y PublicarViewModelTest no?
  2. Quita el setMain delante de mí. ¿Qué falla y por qué?
  3. ¿Cómo hace la compuerta que el doble envío se pueda probar?
  4. ¿Qué es un mutante que sobrevive? ¿Qué te dice del código?
  5. ¿Por qué tiempoRelativo recibe ahora como parámetro?

Entregables

  1. Tu repositorio, con los cuatro commits de checkpoint y la rama experimento-mutantes. Privado:

    gh repo create practica-11-<tu-matricula> --private --source . --push ; git push origin --all

    Agrégame como colaborador.

  2. docs/bitacora.md con los ejercicios 0 y C3 y la tabla de mutantes.