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.
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
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
src/testtiene 13 pruebas y pasan sin emulador.src/androidTesttiene 13 pruebas y pasan en el emulador, sin red.- Tocar Publicar dos veces manda un solo aviso, y lo demuestra una prueba.
- En la rama
experimento-mutanteshay cinco mutantes, y sabes qué prueba atrapa a cada uno. - 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 |
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
[versions]
junit = "4.13.2"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
// 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:
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:
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:
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
[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" }
// Quién corre las pruebas instrumentadas (las de src/androidTest) en el emulador.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// 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:
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()))
)
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:
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
[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" }
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:
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.
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):
fun tituloValido(titulo: String): Boolean = titulo.trim().length in TITULO_MIN until TITULO_MAX
Mutante 2. En tiempoRelativo, < por <=:
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í:
fun publicar(alTerminar: () -> Unit) {
viewModelScope.launch {
Mutante 4. En TarjetaAviso, la imagen pierde su descripción:
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.
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:
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
- La descripción buena. Cambia la
contentDescriptiondeTarjetaAvisoa una que diga de qué aviso es, y su prueba. - El tablón. Pruebas de
AvisosViewModelcon laFakeAvisosApi: carga, error de red, un aviso nuevo por el stream. Necesitarás unAvisosStreamde mentira. - Hilt en las pruebas.
@HiltAndroidTesty@TestInstallInpara cambiarRedModulepor un módulo que entregue laFakeAvisosApia toda la app, y una prueba que abra la app completa. - 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
- ¿Por qué
AvisoValidatorTestcorre sin emulador yPublicarViewModelTestno? - Quita el
setMaindelante de mí. ¿Qué falla y por qué? - ¿Cómo hace la
compuertaque el doble envío se pueda probar? - ¿Qué es un mutante que sobrevive? ¿Qué te dice del código?
- ¿Por qué
tiempoRelativorecibeahoracomo parámetro?
Entregables
-
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 --allAgrégame como colaborador.
-
docs/bitacora.mdcon los ejercicios 0 y C3 y la tabla de mutantes.