Tests de UI en Compose: semántica, estado fijo y sincronización
Los tests de interfaz en Compose no consultan píxeles sino un árbol de semántica, y entender esa mediación explica a la vez su potencia y sus fallos más desconcertantes. Esta lección construye el test canónico con `composeTestRule`, defiende la búsqueda por rol y descripción frente a la búsqueda por texto literal, muestra por qué en MVI lo correcto es componer la pantalla sin ViewModel pasándole un estado fijo, y disecciona la sincronización automática para explicar por qué un test se cuelga cuando hay una animación infinita o una corrutina que nunca duerme.
Un test de interfaz parece el más intuitivo de todos y es en realidad el más indirecto. Cuando escribes que se busque un nodo con cierto texto y se pulse, no estás mirando la pantalla ni tocándola: estás consultando una estructura paralela que Compose mantiene mientras dibuja, el árbol de semántica, y enviando por ella una acción que el sistema traducirá a un gesto. Todo lo que puedes afirmar en esta capa pasa por esa mediación, y de ella se derivan tanto sus virtudes —los tests hablan el mismo lenguaje que los lectores de pantalla, y por tanto probar y hacer accesible son la misma tarea— como sus patologías: si un elemento no publica semántica, para el test no existe, por muy visible que resulte a simple vista. En una aplicación MVI hay además una simplificación decisiva, y es que la pantalla es una función del estado; eso permite probarla sin ViewModel, sin repositorio y sin red, componiéndola directamente sobre el estado que quieras examinar.
- Montar el test canónico con
composeTestRuley entender qué monta exactamente la regla. - Buscar nodos por semántica estable en vez de por texto literal o por posición.
- Componer la pantalla con un estado fijo para probar presentación sin orquestación.
- Explicar la sincronización automática y diagnosticar por qué un test se queda colgado.
La regla y el árbol de semántica
El punto de entrada es una regla de test que crea un anfitrión de composición y expone la interfaz para consultarlo. La variante que no arranca ninguna Activity es la preferible siempre que se pueda, porque compone únicamente lo que le entregas.
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `el boton de reintentar aparece en estado de error`() {
composeTestRule.setContent {
CarritoPantalla(estado = CarritoState(error = "Sin conexion"), alEnviar = {})
}
composeTestRule.onNodeWithText("Reintentar").assertIsDisplayed()
}
Lo que ocurre bajo esa aparente simplicidad merece precisión. La llamada que fija el contenido ejecuta la composición y produce dos árboles: el de nodos de dibujo y el de semántica. Las consultas del test recorren el segundo, que contiene únicamente lo que los composables han declarado —texto, rol, estado de selección, descripción de contenido, acciones disponibles— y nada de lo puramente visual. Un icono decorativo sin descripción no aparece en ese árbol; un contenedor con un modificador de semántica sí, aunque no pinte nada.
De ahí sale el consejo más rentable de toda la lección: cuando un test no encuentra un nodo, imprime el árbol antes de tocar el código de producción. Ver la estructura real ahorra horas de conjeturas sobre jerarquías y fusiones de nodos, que es donde se pierde la mayor parte del tiempo en esta capa.
Si un elemento es difícil de localizar desde un test, es exactamente igual de difícil de localizar para un lector de pantalla. La solución correcta casi nunca es añadir una etiqueta de test artificial, sino dotar al elemento de la descripción o el rol que le faltaba. El test deja de ser una carga y se convierte en el detector de defectos de accesibilidad más barato que tendrás.
Buscar por semántica, no por literal
La estabilidad de un test de interfaz depende casi por completo de cómo localiza sus nodos, y hay una jerarquía clara de calidad entre las opciones disponibles.
Por rol y estado
Lo más estable. Un nodo con rol de botón y estado habilitado sobrevive a rediseños y a cambios de copia.
Por descripción
Buena para iconos y controles sin texto. Además mejora la accesibilidad real de la pantalla.
Por etiqueta de test
Aceptable como último recurso en listas dinámicas. No aporta nada al usuario y hay que mantenerla.
Por texto literal
Frágil. Se rompe con cada cambio de copia, con cada traducción y con cada matiz de mayúsculas.
La objeción habitual es que buscar por texto resulta más legible. Es cierto y sigue siendo mala idea en cuanto la aplicación se traduce o el equipo de contenido cambia una palabra, porque entonces el test falla sin que nada se haya roto. Un test que falla cuando el producto está bien es un test que el equipo aprende a ignorar, y un test ignorado es peor que ninguno porque además cuesta tiempo de ejecución.
El caso donde la disciplina cuesta más es el de las listas: quieres pulsar el tercer elemento y no sabes qué texto lleva. La solución robusta consiste en que cada fila publique una etiqueta derivada de su identificador de dominio, de modo que el test localice la fila del artículo concreto y no la fila que ocupa cierta posición, que puede cambiar con cualquier reordenación.
Componer con un estado fijo
Aquí es donde MVI transforma esta capa. Si la pantalla es una función que recibe estado y una devolución de llamada para emitir intents, entonces probarla no requiere ViewModel ni dobles de repositorio: basta con invocarla con el estado que interese.
@Test
fun `pulsar aplicar emite el intent con el cupon escrito`() {
val intents = mutableListOf<CarritoIntent>()
composeTestRule.setContent {
CarritoPantalla(
estado = CarritoState(cupon = "VERANO"),
alEnviar = { intents += it },
)
}
composeTestRule.onNodeWithContentDescription("Aplicar cupon").performClick()
assertEquals(listOf(CarritoIntent.AplicarCuponPulsado("VERANO")), intents)
}
Ese test comprueba las dos únicas responsabilidades legítimas de la vista: que dado un estado dibuja lo que corresponde y que dado un gesto emite el intent correcto. Nada más le pertenece. La lógica de qué debe pasar después ya está comprobada en el reducer y la orquestación en el ViewModel; repetirlas aquí solo duplica el coste sin añadir información.
flowchart LR S[Estado fijo del test] --> P[Pantalla componible] P --> A[Arbol de semantica] A --> Q[Consultas y aserciones] G[Gesto simulado] --> P P --> I[Lista de intents capturados] style S fill:#a6e3a1,color:#11111b style I fill:#cba6f7,color:#11111b
La consecuencia organizativa de este patrón es que conviene mantener siempre dos capas de composable: una sin estado que recibe el estado y la devolución de llamada, y otra que se limita a obtener el ViewModel y conectarlo con la primera. La segunda no se prueba, porque no contiene decisiones; la primera se prueba entera y sin infraestructura. Cuando ambas responsabilidades están en la misma función, no hay forma de componerla con un estado arbitrario y el test se ve obligado a subir de estrato.
El contraste con el patrón alternativo es instructivo. Un test que construye el ViewModel real, le inyecta un repositorio doble y navega hasta el estado de error atraviesa cuatro capas para comprobar una propiedad de una sola, y falla por cualquiera de ellas.
// Costoso y ambiguo: el fallo puede venir del repositorio, del ViewModel o de la vista.
composeTestRule.setContent {
CarritoRuta(viewModel = CarritoViewModel(RepositorioFake(exito = false)))
}
composeTestRule.onNodeWithText("Sin conexion").assertIsDisplayed()
// Barato e inequivoco: si falla, el defecto esta en la funcion que dibuja.
composeTestRule.setContent {
CarritoPantalla(estado = CarritoState(error = "Sin conexion"), alEnviar = {})
}
composeTestRule.onNodeWithText("Sin conexion").assertIsDisplayed()
Un beneficio adicional, y no menor, es la cobertura de estados raros. Probar la pantalla vacía, la de error, la de lista con un solo elemento o la de nombre desmesuradamente largo cuesta una línea cada una, porque el estado es un valor que construyes a mano. Alcanzar esos mismos casos a través de un repositorio doble y una secuencia de intents costaría diez veces más y sería más frágil.
Sincronización y colgados
Compose no obliga a esperas manuales porque la regla de test sincroniza sola: antes de cada consulta espera a que el sistema quede inactivo, es decir, sin recomposiciones pendientes, sin animaciones en curso y sin trabajo encolado en el hilo principal. Esa comodidad es también la fuente del fallo más desconcertante de esta capa, el test que se queda parado hasta agotar el tiempo.
La causa casi siempre es que algo nunca termina. Una animación en bucle infinito mantiene el sistema perpetuamente ocupado; una corrutina que emite sin pausa en el hilo principal, también. La solución no es aumentar el tiempo límite sino desactivar la sincronización automática para ese caso concreto y avanzar el reloj de composición a mano, o bien parametrizar el composable para que en pruebas no arranque la animación indefinida.
@Test
fun `el indicador de carga aparece aunque anime en bucle`() {
composeTestRule.mainClock.autoAdvance = false
composeTestRule.setContent {
CarritoPantalla(estado = CarritoState(cargando = true), alEnviar = {})
}
composeTestRule.mainClock.advanceTimeBy(milliseconds = 250)
composeTestRule.onNodeWithContentDescription("Cargando").assertExists()
}
Tomar el control del reloj cambia la naturaleza del test: pasa de esperar a que el sistema se calme a decidir en qué instante exacto se observa. Esa es también la única forma honesta de comprobar estados intermedios de una animación, y conviene reservarla para los casos donde de verdad importa, porque un test que fija el tiempo a mano es más preciso y también más acoplado a los detalles de la transición.
Cuando un test de interfaz pasa nueve de cada diez veces, la tentación es añadir un reintento. Casi siempre la causa real es una fuente de trabajo que el sistema no puede observar: un ejecutor propio, un temporizador externo o una carga de imagen que no se declara como recurso pendiente. Lo que hay que arreglar es la visibilidad de ese trabajo para el mecanismo de sincronización, no la paciencia del test.
Vale la pena sostener la mirada sobre esta elección de diseño, porque tiene consecuencias que exceden con mucho la comodidad de escribir aserciones. Las generaciones anteriores de herramientas probaban la interfaz por identificador de vista o por coordenadas, es decir, contra la implementación del dibujo. Compose decidió que las pruebas hablaran contra la misma estructura que consumen las tecnologías de asistencia, y esa decisión convierte la batería de tests en una verificación indirecta de la accesibilidad: si tu test encuentra el botón por su rol y su descripción, es porque un lector de pantalla también lo encontraría; si tu test necesita una etiqueta artificial para localizarlo, acabas de documentar que ese control es invisible para quien no ve la pantalla. La deriva hacia las etiquetas de test es cómoda y silenciosa, y su coste no aparece en ninguna métrica: la suite sigue verde mientras la aplicación se vuelve inutilizable para una parte de sus usuarios. Hay una segunda consecuencia, más filosófica y igual de práctica. Al probar contra la semántica se está afirmando qué significa la interfaz, no qué aspecto tiene, y esas son dos propiedades separables que conviene no confundir: un test puede certificar que existe un botón habilitado llamado reintentar mientras ese botón está pintado de blanco sobre blanco o desplazado fuera de la pantalla. Reconocer ese hueco no es una debilidad del modelo sino su delimitación honesta, y es exactamente el hueco que la comprobación por captura de imagen viene a cubrir en la lección siguiente. Quien entiende que la semántica prueba el significado y la captura prueba la apariencia deja de esperar de cada herramienta lo que la otra hace y empieza a construir una batería donde ambas se complementan en vez de solaparse.
Conviene cerrar con la nota económica: estos tests siguen siendo los más caros de la pirámide, incluso cuando corren sobre la máquina de desarrollo en lugar de sobre un dispositivo. Por eso el criterio no es cubrir todo lo que se ve, sino cubrir cada forma distinta del estado una vez y cada gesto que dispara un intent una vez. Todo lo demás pertenece a los estratos de abajo, donde la misma propiedad se demuestra por una fracción del precio.
- Separa una de tus pantallas en dos composables: uno sin estado que recibe estado y devolución de llamada, y otro que solo conecta el ViewModel.
- Escribe un test por cada forma del estado: vacío, cargando, error y contenido, comprobando en cada uno un elemento característico.
- Sustituye todas las búsquedas por texto literal por búsquedas por rol o descripción y anota qué elementos carecían de semántica.
- Captura los intents emitidos en una lista y comprueba que cada gesto produce exactamente uno, ni cero ni dos.
- Imprime el árbol de semántica de la pantalla y localiza al menos un elemento interactivo que un lector de pantalla no podría anunciar.