Pruebas automatizadas en Android: JUnit, fakes, Compose UI Test y mutación
Pruebas locales, instrumentadas, corrutinas y calidad de las comprobaciones
Introducción
Una aplicación puede compilar, ejecutarse y superar una demostración sin cumplir correctamente sus reglas. Los defectos suelen aparecer al introducir valores límite, recibir una respuesta inesperada, ejecutar dos acciones muy seguidas o restaurar un formulario. Las pruebas automatizadas permiten expresar esas expectativas como comprobaciones que pueden ejecutarse repetidamente después de cada cambio.
Esta guía estudia cómo elegir el entorno adecuado para cada prueba, construir casos con JUnit, controlar entradas variables como el tiempo, sustituir dependencias mediante implementaciones de prueba, verificar el comportamiento de un ViewModel y probar la interfaz de Jetpack Compose. Termina con pruebas de mutación, que permiten evaluar si las comprobaciones realmente detectan defectos. Los ejemplos usan productos, registros y formularios genéricos, sin requerir ningún proyecto de laboratorio previo.
Resultados de aprendizaje
Al estudiar este material se espera que el aprendedor pueda:
- Distinguir pruebas locales e instrumentadas y seleccionar el entorno según las dependencias del código.
- Construir pruebas con la secuencia preparar, actuar y comprobar, especialmente para valores límite.
- Controlar el tiempo, las respuestas externas y las corrutinas para obtener pruebas repetibles.
- Verificar el comportamiento de ViewModels y pantallas Compose sin depender de un servidor real.
- Interpretar el resultado de una prueba de mutación e identificar comprobaciones faltantes.
Lo imprescindible para el examen
Seleccionar pruebas locales e instrumentadas; diseñar límites y fakes; probar corrutinas y Compose; precisar la garantía de cada prueba; interpretar mutantes y cobertura.
Se evalúa la capacidad de explicar, interpretar fragmentos y justificar decisiones. Las versiones puntuales de librerías, URLs, nombres de proyecto y comandos accesorios quedan como contexto de consulta.
Criterios transversales para interpretar esta guía
Principio transferible. Conviene distinguir la responsabilidad de una tecnología de la forma concreta en que se utilizó en la práctica. Un ejemplo de ViewModel, Retrofit, Hilt o WebSocket ilustra una solución posible; su configuración exacta no es una regla universal.
Decisión del proyecto. Los nombres, rutas, versiones, intervalos, tamaños y elecciones de librerías del caso original sirven para interpretar el código. Salvo indicación expresa, no se exige memorizarlos para el examen.
Alcance del código. Los fragmentos son recursos didácticos; pueden omitir imports, dependencias, permisos, detalles de manejo de errores o infraestructura. Comprender un fragmento no equivale a comprobar que compila como proyecto independiente.
Referencias cruzadas. La síntesis transversal de la colección reúne las distinciones entre estado y evento, persistencia, reintentos, idempotencia y límites de las pruebas.
1. ¿Qué significa probar una aplicación?
Probar significa comprobar un comportamiento observable frente a una expectativa definida. Por ejemplo, una regla de negocio puede establecer que un nombre debe tener entre 3 y 60 caracteres, después de eliminar los espacios exteriores. La prueba prepara una entrada de 60 caracteres, ejecuta la regla y verifica que se acepte. Otra prueba utiliza 61 caracteres y verifica que se rechace.
Una prueba útil debe poder dar una respuesta clara a tres preguntas: ¿qué comportamiento se está comprobando?, ¿bajo qué condiciones?, ¿qué evidencia demuestra que funciona?
El patrón habitual se conoce como AAA (Arrange, Act, Assert):
- Preparar: crear el objeto y las condiciones iniciales.
- Actuar: ejecutar la operación que se quiere comprobar.
- Comprobar: contrastar el resultado con el esperado.
@Test
fun nombre_de_tres_caracteres_es_valido() {
// Preparar
val nombre = "Sol"
// Actuar
val resultado = NombreValidator.esValido(nombre)
// Comprobar
assertTrue(resultado)
}
Separar esas partes no exige crear tres bloques en cada prueba. En casos pequeños pueden ocupar una sola expresión, pero conviene reconocer qué se está preparando, qué operación se ejecuta y qué resultado se comprueba.
1.1 El valor de ver una prueba fallar
Una prueba que aparece en verde no necesariamente comprueba lo que afirma. Puede estar llamando a otra función, contener una comparación incorrecta o no ejecutar la condición importante. Una verificación útil consiste en introducir temporalmente un defecto controlado y observar si la prueba falla. Después se restablece el código correcto.
Un resultado rojo esperado durante un experimento no equivale a un fallo del trabajo final: demuestra que la prueba tiene capacidad de detectar, al menos, ese defecto.
2. Pruebas locales e instrumentadas
En Android existen dos entornos especialmente importantes para este curso.
| Aspecto | Prueba local | Prueba instrumentada |
|---|---|---|
| Ubicación habitual | app/src/test/ |
app/src/androidTest/ |
| Entorno | JVM de la computadora | Dispositivo o emulador Android |
| Dependencias habituales | testImplementation(...) |
androidTestImplementation(...) |
| Usos típicos | Validadores, mapeadores, cálculos, clases desacopladas | UI Compose, ContentResolver, Uri, Context, recursos Android |
| Ejecución | Generalmente más rápida | Generalmente más lenta: instalación y ejecución en dispositivo |
La decisión se toma por las necesidades del código que se prueba. Un ViewModel que utiliza repositorios inyectados y lógica independiente de Android puede probarse localmente. Si se construye con un compresor que utiliza APIs del sistema y se quiere probar esa implementación real, puede ser razonable una prueba instrumentada. No existe una regla general según la cual todos los ViewModels deben probarse en el emulador.
Una forma de decidir es preguntar: ¿necesito un componente real del sistema Android para comprobar este comportamiento? Si la respuesta es no, comienza por una prueba local.
2.1 Dependencias de Gradle
En una configuración común con JUnit 4:
// app/build.gradle.kts
dependencies {
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:<version-compatible>")
androidTestImplementation("androidx.test:runner:<version-compatible>")
}
Los números de las librerías AndroidX deben mantenerse compatibles con la configuración del proyecto. Para ejecutar instrumentadas se declara un runner en defaultConfig:
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
Estas dependencias se utilizan en la compilación y ejecución de pruebas; no son funcionalidades de la aplicación distribuida al usuario.
2.2 Qué demuestra cada nivel
Un test del validador puede demostrar que una cadena de 61 caracteres se rechaza. Un test del ViewModel puede demostrar que una respuesta 422 no dispara navegación de éxito. Un test de Compose puede demostrar que, cuando la pantalla recibe un estado de error, muestra un mensaje visible. Ninguno de esos tests, por sí solo, demuestra que el servidor real funciona o que toda la aplicación está correctamente integrada.
3. JUnit: reglas de negocio y valores límite
Supongamos este contrato:
- Un título, después de aplicar
trim(), debe tener entre 3 y 60 caracteres, incluidos los extremos. - Un título formado únicamente por espacios no es válido.
object TituloValidator {
fun esValido(titulo: String): Boolean =
titulo.trim().length in 3..60
}
Las pruebas buscan primero los puntos donde el comportamiento cambia:
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class TituloValidatorTest {
@Test
fun acepta_los_extremos_validos() {
assertTrue(TituloValidator.esValido("Sol"))
assertTrue(TituloValidator.esValido("a".repeat(60)))
}
@Test
fun rechaza_los_valores_inmediatos_fuera_del_rango() {
assertFalse(TituloValidator.esValido("No"))
assertFalse(TituloValidator.esValido("a".repeat(61)))
}
@Test
fun descarta_los_espacios_exteriores() {
assertFalse(TituloValidator.esValido(" "))
assertFalse(TituloValidator.esValido(" No "))
assertTrue(TituloValidator.esValido(" Sol "))
}
}
Aquí se prueban 2, 3, 60 y 61: el último valor rechazado antes del mínimo, el primero aceptado, el último aceptado y el primero rechazado después del máximo. Este procedimiento se denomina análisis de valores límite.
3.1 Particiones de equivalencia
Los valores posibles también pueden organizarse en categorías con el mismo comportamiento esperado: longitud insuficiente, longitud válida, longitud excesiva y texto vacío tras quitar espacios. Elegir valores representativos de esas categorías es útil, pero no sustituye los límites cuando un error de comparación (< frente a <=) puede alterar el resultado.
3.2 No confundir un caso con toda la regla
Probar únicamente "Producto nuevo" demuestra poco: cualquier implementación que devuelva true para todo también superaría el test. Un conjunto que incluya valores válidos e inválidos restringe mejor el comportamiento permitido.
4. Pruebas deterministas: controlar el tiempo
Hay funciones cuyo resultado depende del instante en que se ejecutan. Por ejemplo, una interfaz puede mostrar "hace 59 min" antes de cumplir una hora y "hace 1 h" a partir del minuto 60.
Si la función consulta el reloj por dentro, la prueba puede producir resultados diferentes de una ejecución a otra. Una opción sencilla consiste en recibir el tiempo actual como argumento.
fun tiempoTranscurrido(publicadoEnMs: Long, ahoraMs: Long): String {
val minutos = ((ahoraMs - publicadoEnMs).coerceAtLeast(0L)) / 60_000
return when {
minutos < 1 -> "ahora"
minutos < 60 -> "hace $minutos min"
else -> "hace ${minutos / 60} h"
}
}
@Test
fun cambia_de_minutos_a_horas_en_el_minuto_60() {
val publicado = 1_000_000L
assertEquals("hace 59 min", tiempoTranscurrido(publicado, publicado + 59 * 60_000))
assertEquals("hace 1 h", tiempoTranscurrido(publicado, publicado + 60 * 60_000))
}
Al controlar ahoraMs, la prueba es reproducible. En una aplicación más grande puede inyectarse un reloj mediante una interfaz o una abstracción de tiempo. La idea central se mantiene: las dependencias variables deben poder controlarse durante la prueba.
Este enfoque también es útil para vencimientos de sesión, fechas de promociones, ventanas de reserva y decisiones que dependen de la hora.
5. Mapeadores y contratos de datos
La transformación entre datos de una API y modelos que utiliza la aplicación es otra candidata a pruebas locales. Un mapeador puede construir una dirección a partir de una clave o convertir un campo opcional en null.
data class ArchivoDto(val clave: String?)
data class Archivo(val url: String?)
fun ArchivoDto.toDomain(baseUrl: String): Archivo = Archivo(
url = clave?.let { "$baseUrl/archivos/$it" }
)
@Test
fun transforma_la_clave_en_url() {
val resultado = ArchivoDto("foto.jpg").toDomain("https://api.ejemplo.test")
assertEquals("https://api.ejemplo.test/archivos/foto.jpg", resultado.url)
}
@Test
fun conserva_la_ausencia_de_archivo() {
val resultado = ArchivoDto(null).toDomain("https://api.ejemplo.test")
assertNull(resultado.url)
}
La prueba no necesita hacer una petición HTTP. Comprueba la traducción de un contrato, bajo entradas controladas. Otros casos relevantes son los nombres de campos, fechas, valores opcionales, roles desconocidos y unidades de medida.
6. Inyección de dependencias y dobles de prueba
Un ViewModel que crea internamente su propio cliente Retrofit queda acoplado a esa construcción. Para probarlo sin red habría que modificar código productivo o introducir mecanismos adicionales.
Una alternativa consiste en que el ViewModel reciba un contrato:
interface RegistroRepository {
suspend fun guardar(titulo: String): Int
}
class RegistroViewModel(
private val repository: RegistroRepository
) : ViewModel() {
// ...
}
En producción, el repositorio puede conectarse a Retrofit. En la prueba se le entrega una implementación controlada. Esto enlaza directamente la inyección de dependencias de la Guía 09 con la capacidad de verificar el comportamiento.
6.1 ¿Qué es un fake?
Un fake es una implementación funcional y simplificada del contrato, diseñada para facilitar las pruebas. Puede devolver valores configurables y registrar las llamadas que recibe.
class FakeRegistroRepository : RegistroRepository {
val titulosRecibidos = mutableListOf<String>()
var proximoId = 10
var error: Exception? = null
override suspend fun guardar(titulo: String): Int {
titulosRecibidos += titulo
error?.let { throw it }
return proximoId++
}
}
Una prueba puede inspeccionar titulosRecibidos y determinar si se llamó una vez, con qué título y en qué orden. También puede configurar un fallo. No hace falta conectarse a un servicio real.
Los términos fake, stub, spy y mock describen técnicas relacionadas pero distintas. En este curso importa sobre todo identificar el contrato y qué comportamientos controla el doble de prueba. Un fake que registra llamadas también puede desempeñar una función de spy.
6.2 La prueba no debería depender de internet
Si una prueba necesita que el servidor de producción esté disponible, su resultado puede cambiar por latencia, datos compartidos, credenciales o caídas del servicio. Para probar las decisiones del ViewModel conviene reemplazar ese servicio. Las pruebas de integración con un servidor tienen otro propósito y pueden ejecutarse por separado.
7. Probar operaciones asíncronas con corrutinas
Los ViewModels suelen iniciar operaciones desde viewModelScope, normalmente sobre Dispatchers.Main. En pruebas locales puede reemplazarse ese dispatcher mediante kotlinx-coroutines-test y utilizar un planificador controlable.
El siguiente ejemplo comprueba que una operación cambia de estado y termina con los datos esperados:
class RegistroViewModel(
private val repository: RegistroRepository
) : ViewModel() {
var guardando by mutableStateOf(false)
private set
var idGuardado by mutableStateOf<Int?>(null)
private set
fun guardar(titulo: String) {
if (guardando) return
viewModelScope.launch {
guardando = true
try {
idGuardado = repository.guardar(titulo)
} finally {
guardando = false
}
}
}
}
Configuración ilustrativa para JUnit 4:
@OptIn(ExperimentalCoroutinesApi::class)
class RegistroViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun preparar() {
Dispatchers.setMain(dispatcher)
}
@After
fun limpiar() {
Dispatchers.resetMain()
}
@Test
fun registra_una_vez_y_guarda_el_id() = runTest(dispatcher) {
val fake = FakeRegistroRepository()
val vm = RegistroViewModel(fake)
vm.guardar("Nuevo producto")
advanceUntilIdle()
assertEquals(listOf("Nuevo producto"), fake.titulosRecibidos)
assertEquals(10, vm.idGuardado)
assertFalse(vm.guardando)
}
}
Los imports relevantes provienen de kotlinx.coroutines.test, kotlinx.coroutines, org.junit y Compose Runtime. Este ejemplo muestra el patrón; requiere las dependencias de AndroidX Lifecycle y kotlinx-coroutines-test configuradas en el proyecto.
7.1 StandardTestDispatcher y UnconfinedTestDispatcher
StandardTestDispatcherpermite controlar cuándo se ejecutan las tareas pendientes; es apropiado cuando interesa comprobar estados intermedios y avanzar explícitamente el planificador.UnconfinedTestDispatcherinicia tareas de forma más inmediata y puede simplificar algunos escenarios; no representa todos los órdenes de ejecución posibles.
runTest y advanceUntilIdle() ayudan a evitar esperas reales y hacen las pruebas más deterministas. No conviene reemplazar esa coordinación con bucles Thread.sleep() cuando es posible utilizar el planificador de pruebas.
7.2 Probar el doble toque con una compuerta
Una prueba que llama dos veces a guardar() cuando el fake responde inmediatamente puede pasar incluso si la interfaz permite duplicados durante una petición lenta. Para comprobar la simultaneidad se crea una compuerta:
class FakeRegistroLento : RegistroRepository {
val compuerta = CompletableDeferred<Unit>()
var llamadas = 0
override suspend fun guardar(titulo: String): Int {
llamadas++
compuerta.await()
return 7
}
}
@Test
fun dos_toques_mientras_guarda_originan_una_sola_operacion() = runTest(dispatcher) {
val fake = FakeRegistroLento()
val vm = RegistroViewModel(fake)
vm.guardar("Producto")
runCurrent() // comienza la primera operación y queda suspendida
vm.guardar("Producto")
runCurrent() // la segunda llamada se rechaza por guardando = true
fake.compuerta.complete(Unit)
advanceUntilIdle()
assertEquals(1, fake.llamadas)
}
Este test comprueba una protección local contra toques repetidos durante una operación. No garantiza que un reintento después de perder una respuesta HTTP no duplique el registro remoto. Para ese caso pueden necesitarse operaciones idempotentes y claves de idempotencia en el servidor.
8. Probar secuencias y errores del servidor
Supongamos que publicar un registro con archivo requiere dos operaciones: subir el archivo y crear el registro con la clave devuelta. Se quieren comprobar tres comportamientos:
- Con archivo, subir antes de publicar.
- Si la subida falla, no publicar.
- Si la publicación falla después de subir, no informar éxito ni cerrar el formulario.
Un fake puede registrar "subir" y "publicar" para comprobar el orden. Otro parámetro puede hacer que publicar() lance una excepción. La prueba debe verificar tanto el estado final como las llamadas efectuadas.
La respuesta de error merece una comprobación específica. Si el servidor devuelve 413, el ViewModel puede mostrar un mensaje sobre el tamaño permitido; si devuelve 422, puede mostrar la validación correspondiente. Una respuesta rechazada no debe activar el callback de éxito.
Para un sistema real también debe estudiarse el caso en que se sube el archivo, pero falla la creación del registro. Puede quedar un archivo huérfano, problema que requiere reglas de limpieza o expiración en el backend; una prueba del cliente por sí sola no lo resuelve.
9. Estado guardado y restauración
Una pantalla puede conservar campos de formulario en un SavedStateHandle. Para comprobar que el ViewModel lee y escribe las claves esperadas, se pueden construir dos instancias con el mismo estado de prueba:
val estado = SavedStateHandle()
val primero = FormularioViewModel(estado)
primero.actualizarTitulo("Registro pendiente")
val segundo = FormularioViewModel(estado)
assertEquals("Registro pendiente", segundo.titulo)
El ejemplo presupone un ViewModel cuyos métodos escriben y leen ese valor en SavedStateHandle. Es una prueba del contrato de almacenamiento del ViewModel; no reproduce de manera íntegra la terminación real del proceso, la serialización del estado ni todo el mecanismo de restauración del sistema Android.
Conviene reservar las pruebas instrumentadas de recreación real para los escenarios donde el comportamiento del sistema sea indispensable. Esta distinción evita concluir que simular dos constructores verifica todos los casos de muerte del proceso.
10. Pruebas de interfaz con Jetpack Compose
Una pantalla Compose bien separada recibe un estado y funciones de respuesta, y puede dibujarse sin crear el ViewModel real. Así es posible introducir directamente un estado válido, inválido, cargando o de error.
data class FormularioUiState(
val titulo: String = "",
val guardando: Boolean = false,
val error: String? = null
)
@Composable
fun FormularioScreen(
uiState: FormularioUiState,
onGuardar: () -> Unit
) {
Column {
Button(
enabled = uiState.titulo.length >= 3 && !uiState.guardando,
onClick = onGuardar
) {
Text(if (uiState.guardando) "Guardando…" else "Guardar")
}
uiState.error?.let { Text(it) }
}
}
Una prueba instrumentada con Compose UI Test puede verificar el estado del botón:
@get:Rule
val compose = createComposeRule()
@Test
fun titulo_corto_deshabilita_guardar() {
compose.setContent {
MaterialTheme {
FormularioScreen(
uiState = FormularioUiState(titulo = "No"),
onGuardar = {}
)
}
}
compose.onNodeWithText("Guardar").assertIsNotEnabled()
}
Para una acción válida puede configurarse un contador y usar performClick(); después se verifica que el callback se ejecutó exactamente una vez. Para un error se busca el texto y se comprueba que se muestra.
10.1 Árbol de semántica
Compose UI Test localiza nodos por propiedades semánticas, entre ellas texto, descripción, estado habilitado y etiquetas de prueba. Métodos como estos expresan expectativas observables:
compose.onNodeWithText("Guardar").assertIsEnabled()
compose.onNodeWithContentDescription("Foto del producto").assertExists()
compose.onNodeWithText("No fue posible guardar").assertIsDisplayed()
Buscar por texto o descripción suele ser más estable que depender de coordenadas. Además, muchas de esas propiedades son relevantes para las tecnologías de accesibilidad. Sin embargo, localizar un nodo en una prueba no equivale a demostrar que TalkBack lo anuncia correctamente ni que la pantalla es accesible: la experiencia real requiere verificaciones complementarias, incluidas pruebas con lectores de pantalla.
10.2 Descripciones significativas
Que diez imágenes tengan contentDescription = "Imagen" puede hacer que un test de existencia pase, pero proporciona escasa información al usuario. Una descripción como "Fotografía del producto Café molido" ofrece contexto. Las imágenes decorativas pueden requerir contentDescription = null para evitar ruido. Lo correcto depende del propósito de cada imagen.
10.3 Por qué conviene separar pantalla y ViewModel
Si FormularioScreen instancia directamente su ViewModel, controlar sus estados exige preparar las dependencias de ese ViewModel. Si recibe uiState y callbacks, las pruebas se concentran en la representación y las interacciones. La prueba del ViewModel se realiza aparte. Esta es una ventaja verificable de la separación de responsabilidades de la Guía 02.
11. Pruebas de imágenes y recursos de Android
Para comprobar un compresor que utiliza BitmapFactory, Uri y ContentResolver puede crearse una imagen controlada en el entorno de prueba. Después de comprimirla, se decodifican solo sus dimensiones para verificar una regla, por ejemplo, lado máximo de 1280 píxeles.
val opciones = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeByteArray(jpeg, 0, jpeg.size, opciones)
assertTrue(maxOf(opciones.outWidth, opciones.outHeight) <= 1280)
El fragmento presupone que jpeg contiene el resultado del compresor. La prueba debe utilizar una foto de entrada cuyo lado más largo supere los 1280 píxeles, de lo contrario no detectará que el compresor dejó de reducir imágenes grandes.
También es recomendable probar que una imagen pequeña no se amplíe sin necesidad, que la orientación se aplique correctamente y que los metadatos que deban eliminarse no permanezcan en el archivo exportado.
La comprobación de medidas es distinta de afirmar que la imagen tiene buena calidad visual. Son requisitos diferentes y necesitan evidencias diferentes.
12. Pruebas de mutación
Después de obtener todas las pruebas en verde, surge otra pregunta: ¿son capaces de detectar errores relevantes? Una prueba de mutación introduce un cambio pequeño y deliberado en el código de producción para evaluar si una prueba lo detecta.
| Mutación introducida | Defecto provocado | Prueba que debería detectarlo |
|---|---|---|
3..60 se cambia por 3 until 60 |
Rechaza el límite superior válido | Título de 60 caracteres |
minutos < 60 se cambia por minutos <= 60 |
Muestra «hace 60 min» | Cambio exacto en el minuto 60 |
Se elimina if (guardando) return |
Permite solicitudes simultáneas | Dos toques con primera llamada bloqueada |
| Se elimina una descripción de imagen | Falta información semántica | Existencia de descripción específica |
| El límite de compresión pasa de 1280 a 12800 | Deja de reducir muchas fotos grandes | Verificación de dimensiones de salida |
Un mutante está atrapado si provoca el fallo de una prueba; sobrevive si las pruebas ejecutadas continúan pasando. La supervivencia señala que el conjunto no detecta ese cambio. Puede deberse a una comprobación faltante, a que la ruta afectada no se ejecuta o a que la mutación no modifica el comportamiento observable relevante (mutante equivalente). Por tanto, sobrevivir no demuestra automáticamente que una línea «no está probada».
12.1 Ejemplo: el límite de imagen
Si todas las pruebas verifican únicamente que comprimir() devuelve un arreglo de bytes no vacío, cambiar el máximo de 1280 a 12800 puede pasar inadvertido. Para atrapar el mutante se añade una prueba que:
- Genera una imagen de entrada de 3000 × 2000 píxeles.
- Ejecuta el compresor.
- Lee las dimensiones de la imagen resultante.
- Comprueba que el lado más largo sea de 1280 píxeles como máximo.
Una vez que se introduce esa comprobación, la mutación debe hacer fallar el test. Es importante restablecer el código correcto tras el experimento.
12.2 Cobertura no es capacidad de detección
Una cobertura de líneas alta indica que se ejecutó mucho código durante las pruebas. No asegura que se hayan formulado buenas expectativas. La mutación complementa la cobertura al evaluar si ciertos defectos provocan resultados rojos.
Tampoco una puntuación de mutación alta garantiza por sí sola la ausencia de errores. Lo importante es que el conjunto incluya comportamientos relevantes del dominio, de las interacciones y de la seguridad.
Conexión transversal: alcance y decisiones
Matriz de garantías de pruebas
| Prueba | Qué verifica | Qué no demuestra por sí sola |
|---|---|---|
| JUnit local con validador | Regla de dominio para las entradas ejercitadas | Integración de toda la aplicación |
| ViewModel con fake | Decisiones y secuencia de llamadas al contrato simulado | Que la API real, red o autenticación funcionen |
| Compose UI Test | Interacciones y semántica seleccionadas | Accesibilidad completa con TalkBack |
Dos ViewModels con SavedStateHandle |
Lectura/escritura de estado recuperable | Restauración real tras todas las formas de muerte del proceso |
| Prueba de integración | Colaboración entre componentes reales incluidos | Infraestructura de producción no incluida |
| End-to-end | Un recorrido bajo un entorno concreto | Ausencia de defectos fuera de ese recorrido |
| Mutación | Si los casos existentes detectan cambios concretos | Corrección general de todo el programa |
Criterio de examen: la conclusión nunca debe superar la evidencia disponible. Una prueba verde con fake no demuestra un servidor real correcto; encontrar una descripción semántica no acredita toda la accesibilidad.
13. Diagnóstico de problemas habituales
| Síntoma | Causa posible | Qué comprobar |
|---|---|---|
Unresolved reference: Test o assertEquals |
JUnit no está configurado para ese origen de pruebas | testImplementation o androidTestImplementation según corresponda |
| No se encuentran dispositivos | Se está ejecutando una prueba instrumentada sin emulador | Dispositivo conectado y configuración de androidTest |
| El ViewModel todavía no ha actualizado el estado | La corrutina no se ha ejecutado o terminado | Dispatcher de prueba, runTest, runCurrent, advanceUntilIdle |
| Las pruebas dependen de la hora del día | El reloj se consulta directamente | Parámetro ahora o reloj inyectado |
| La pantalla no permite buscar un elemento | Propiedad semántica ausente o diferente | Texto, descripción, estado y jerarquía semántica |
| El fake no registra la llamada esperada | Se inyectó otra instancia o no llegó a ejecutarse | Constructor, grafo de dependencias y coordinación asíncrona |
| Solo fallan las pruebas instrumentadas | Incompatibilidad de versiones o configuración del runner | Versiones de AndroidX Test, Compose, Espresso y dispositivo |
| Un mutante no hace fallar ninguna prueba | Falta comprobar su efecto o es equivalente | Entrada que active el defecto y aserción observable |
Un error procedente de una librería no siempre significa que la lógica de negocio sea incorrecta. Conviene distinguir errores de configuración del entorno, errores en las pruebas y defectos del código productivo.
14. Vocabulario esencial
| Concepto | Significado |
|---|---|
| Prueba local | Prueba ejecutada en la JVM, sin dispositivo Android por defecto |
| Prueba instrumentada | Prueba ejecutada con el entorno Android en un dispositivo o emulador |
| AAA | Preparar, actuar y comprobar |
| Valor límite | Punto en que una regla cambia entre válido e inválido |
| Determinismo | Mismas condiciones controladas producen el mismo resultado esperado |
| Fake | Implementación simplificada de un contrato utilizada durante pruebas |
viewModelScope |
Ámbito de corrutinas asociado al ciclo de vida del ViewModel |
| Dispatcher de prueba | Controla la programación de corrutinas durante tests |
runTest |
Entorno de prueba para ejecutar y coordinar corrutinas |
| Compose UI Test | Herramientas para inspeccionar semántica e interacciones Compose |
| Mutante | Modificación deliberada del código para evaluar una prueba |
| Mutante sobreviviente | Cambio que las pruebas ejecutadas no detectaron |
| Cobertura | Medida del código ejecutado por pruebas, no de su corrección |
15. Preguntas de autoevaluación
Comprensión conceptual
- ¿Qué diferencia práctica existe entre
src/testysrc/androidTest? ¿Cómo se decide dónde colocar una prueba? - Explica las tres etapas del patrón AAA y aplícalas a una regla de validación.
- Para títulos de 3 a 60 caracteres, ¿por qué conviene probar
2,3,60y61? - ¿Por qué una prueba de fecha relativa debería controlar la hora actual?
- ¿Qué aporta inyectar una interfaz de repositorio al probar un ViewModel?
- ¿En qué se diferencia un fake de llamar a un servidor de pruebas real?
- ¿Por qué una prueba verde no garantiza que haya comprobado el comportamiento deseado?
Interpretación de código
- En
assertFalse(TituloValidator.esValido(" No ")), ¿qué comportamiento específico se está comprobando? - ¿Qué ventaja ofrece
advanceUntilIdle()frente a esperar conThread.sleep()cuando todas las corrutinas relevantes utilizan el planificador de pruebas? - Si un fake registra las llamadas
"subir"y"publicar", ¿qué evidencia aporta esa lista sobre la operación completa? - Una prueba busca
onNodeWithText("Guardar")y compruebaassertIsNotEnabled(). ¿Qué demuestra y qué no demuestra? - ¿Para qué sirve una compuerta
CompletableDeferred<Unit>en una prueba de doble toque? - Se crean dos ViewModels con el mismo
SavedStateHandle. ¿Qué se comprueba y qué escenario real no queda completamente reproducido? - Una prueba mide la anchura y altura de un JPEG después de comprimirlo. ¿Por qué es más útil que comprobar únicamente que el arreglo resultante no está vacío?
Aplicación y diagnóstico
- Un ViewModel siempre construye su propia API con Retrofit. ¿Qué dificultad aparece al querer probar sus errores sin conexión?
- Una operación de publicación falla con
422, pero la pantalla se cierra como si hubiera terminado correctamente. ¿Qué debería comprobar un test del ViewModel? - Una prueba local falla porque el estado todavía es
Cargando. ¿Qué revisarías antes de concluir que el código productivo tiene un defecto? - Un test localiza correctamente una imagen mediante
contentDescription = "Imagen". ¿Por qué eso no basta para afirmar que la pantalla es accesible? - Cambias
3..60por3 until 60y ningún test falla. ¿Qué caso falta probablemente? - Todas las líneas del compresor tienen cobertura, pero el cambio de máximo
1280a12800pasa inadvertido. ¿Qué indica y cómo lo corregirías?
16. Respuestas posibles
src/testcorre en la JVM y sirve para código que no exige un dispositivo real;src/androidTestejecuta con Android y se utiliza cuando se necesitan componentes o comportamientos de la plataforma. La decisión depende de las dependencias efectivas del código, no del nombre de la clase.- Preparar crea las condiciones y objetos, actuar ejecuta la función, comprobar contrasta el resultado con la regla. Por ejemplo: preparar
"No", llamar al validador y verificar que devuelvafalse. - Son los cuatro valores alrededor de las dos fronteras. Capturan errores de inclusión o exclusión de extremos que podrían pasar desapercibidos con valores centrales.
- Para obtener resultados repetibles. Si la hora depende del reloj real, el resultado cambia aunque no cambie el código; controlarla permite probar con exactitud los umbrales.
- Permite sustituir la implementación remota por un fake configurable. Así se comprueban decisiones del ViewModel y respuestas de error sin depender de red ni credenciales.
- El fake ejecuta una implementación local controlada del contrato y permite decidir respuestas y registrar llamadas. Un servidor de pruebas puede introducir latencia, fallas de infraestructura y estado externo.
- Puede contener una aserción incompleta, no ejecutar la condición relevante o aceptar una implementación incorrecta. Ver una falla controlada ayuda a verificar su capacidad de detección.
- Comprueba que, después de aplicar
trim(), quedan dos caracteres y el título debe rechazarse. No está comprobando que el título original mida seis caracteres. - Avanza las tareas pendientes de manera controlada sin introducir demoras del reloj real. Solo coordina las corrutinas que utilizan el planificador correspondiente; no resuelve automáticamente trabajo ejecutado por dispatchers externos.
- Permite verificar el orden de las operaciones y detectar que se intentó publicar antes de terminar la subida. No demuestra por sí solo que el archivo llegó correctamente a un servidor real.
- Demuestra que, con ese estado de entrada, existe un nodo semántico con texto
Guardarque está deshabilitado. No comprueba el ViewModel, el backend ni toda la accesibilidad de la pantalla. - Mantiene la primera petición suspendida para ejecutar una segunda acción durante el estado
guardando. Es una forma de reproducir la condición de red lenta que provoca dobles envíos. - Comprueba que el ViewModel utiliza las claves del estado guardado para restaurar valores. No simula por completo el guardado y restauración del sistema ni todas las condiciones de terminación del proceso.
- El tamaño del arreglo no prueba que el archivo cumpla el límite de resolución. Leer las dimensiones permite verificar justamente ese requisito.
- El ViewModel queda acoplado a la construcción real y no puede recibir fácilmente un fake. La solución es inyectar una dependencia configurable, preferiblemente a través de un contrato cuando existan implementaciones alternativas relevantes.
- Debe comprobar que el estado refleja el error, que no se invoca el callback de éxito y que la pantalla puede conservar la información necesaria para corregir o reintentar.
- Revisaría que
Dispatchers.Mainse haya sustituido correctamente, querunTesty el dispatcher compartan planificador cuando corresponde y que se haya avanzado hasta la operación relevante. - Localizar el nodo demuestra la presencia de una propiedad semántica, pero
"Imagen"no describe su contenido y la experiencia de TalkBack depende de otros factores. Se necesitan descripciones contextuales y verificaciones adicionales. - Falta probablemente el caso de exactamente 60 caracteres, que debe aceptarse con un rango cerrado y se rechaza con
until. - Indica que las pruebas actuales no detectan ese cambio, aunque ejecuten las líneas. Debe añadirse una prueba con imagen grande que mida el resultado y verifique el límite máximo. Después se comprueba que el mutante la haga fallar.
Cierre. Preguntas para reflexionar
- ¿Qué comportamientos de una aplicación móvil requieren pruebas locales y cuáles dependen de la plataforma Android?
- ¿Qué decisiones de arquitectura facilitarían sustituir un servicio externo por un fake?
- ¿Cómo diseñarías una prueba capaz de reproducir un fallo que solo aparece con una red lenta?
- ¿Qué aspectos de accesibilidad no quedan verificados aunque todas las pruebas de Compose estén en verde?
- ¿Qué mutación introducirías en una función propia para comprobar si sus pruebas detectan errores relevantes?