Tests de UI en Compose: semántica, sincronización y espera explícita
Las pruebas de interfaz en Compose no interrogan píxeles ni jerarquías de vistas: interrogan el árbol de semántica, la misma estructura que consume el lector de pantalla. Esta lección explica qué regla elegir y qué implica cada una, por qué buscar por significado es superior a buscar por etiqueta y cómo esa preferencia convierte cada prueba en una verificación parcial de accesibilidad, cómo funciona la sincronización automática entre el reloj de la prueba y el de la composición, cuáles son sus tres agujeros conocidos, y cómo cerrar el último de ellos con espera explícita sin reintroducir la fragilidad que las esperas por tiempo siempre traen consigo.
La herramienta de pruebas de Compose parte de una decisión de diseño que conviene entender antes de escribir la primera línea, porque explica casi todo lo demás: la prueba no ve la interfaz. No hay jerarquía de vistas que recorrer ni identificadores numéricos que resolver, porque los composables no producen objetos consultables. Lo que la prueba ve es el árbol de semántica, una estructura paralela que la propia interfaz publica para describirse a sí misma, y que es exactamente la misma que consume el lector de pantalla del sistema. De esa única decisión se derivan las tres propiedades que definen el trabajo con composeTestRule: que se busca por significado y no por posición, que las afirmaciones de la prueba y las garantías de accesibilidad son el mismo trabajo hecho una sola vez, y que existe un mecanismo de sincronización que acopla el reloj de la prueba al de la composición y elimina la mayor parte de las esperas manuales. Esta lección recorre las tres y después se ocupa del margen donde el mecanismo no llega.
- Elegir entre regla de composición pura y regla con actividad según lo que la prueba necesite del entorno.
- Localizar nodos por semántica y reservar las etiquetas de prueba para los casos que el significado no cubre.
- Explicar el acoplamiento entre el reloj de la prueba y el de la composición, y sus tres puntos ciegos.
- Aplicar espera explícita con condición en lugar de esperas por tiempo cuando la sincronización no basta.
La regla, el árbol y lo que la prueba puede ver
Hay dos reglas y la elección no es cosmética. La regla de composición pura no arranca ninguna actividad propia: monta el contenido en un contenedor mínimo, arranca en microsegundos y basta para cualquier composable que no dependa del entorno. La regla que recibe una actividad concreta arranca esa actividad de verdad, lo que da acceso a su instancia, a sus recursos, a su tema aplicado y al ciclo de vida real, y es imprescindible en cuanto la prueba necesite provocar un cambio de configuración, leer un Intent de entrada o convivir con vistas clásicas.
Sea cual sea la regla, lo que la prueba interroga es el árbol de semántica. Cada nodo de ese árbol tiene propiedades: un texto, una descripción de contenido, un rol, un estado de selección, un conjunto de acciones disponibles. Los composables de la biblioteca estándar publican esas propiedades por su cuenta, y el código propio puede añadirlas o modificarlas mediante el modificador de semántica. Cuando una prueba no encuentra lo que busca, la causa casi siempre es que ese nodo no publica nada, y el primer diagnóstico debe ser siempre imprimir el árbol.
@get:Rule val composeTestRule = createComposeRule()
@Test fun elBotonDeGuardarSeHabilitaConTextoValido() {
composeTestRule.setContent { MiTema { FormularioNota(estado = EstadoNota.vacio) } }
composeTestRule.onRoot().printToLog("ARBOL") // diagnostico, no afirmacion
composeTestRule.onNodeWithText("Guardar").assertIsNotEnabled()
composeTestRule.onNodeWithContentDescription("Titulo de la nota").performTextInput("Compra")
composeTestRule.onNodeWithText("Guardar").assertIsEnabled()
}
Existe un matiz sobre la fusión que causa una parte desproporcionada de las búsquedas fallidas. Por defecto el árbol que ve la prueba está fusionado: un botón con icono y texto se presenta como un solo nodo cuya descripción combina la de sus hijos, porque así es como lo anuncia el lector de pantalla. Si la prueba necesita afirmar sobre un descendiente concreto, debe pedir el árbol sin fusionar. Es un mecanismo correcto y bien pensado, pero la primera vez que se tropieza con él parece una avería.
// El icono no existe como nodo propio en el arbol fusionado
composeTestRule
.onNodeWithContentDescription("Nota favorita", useUnmergedTree = true)
.assertExists()
La regla general que se deriva de esa fusión es que la prueba debe interrogar al nivel en el que la interfaz se comunica, no al nivel en el que está construida. Si el usuario percibe un botón, el nodo correcto es el botón; bajar al icono solo tiene sentido cuando lo que se verifica es precisamente que ese icono cambió.
Buscar por significado en lugar de por identificador
La tentación inicial es marcar todo con etiquetas de prueba, porque reproduce el hábito de los identificadores de recurso y siempre funciona. Es una decisión defendible en casos concretos y una política desastrosa como norma general, por dos razones que conviene separar.
La primera es de acoplamiento. Una etiqueta de prueba es una cadena inventada que no significa nada para el usuario, por lo que una prueba escrita sobre etiquetas puede pasar íntegra mientras la pantalla muestra el texto equivocado, el botón deshabilitado o el estado invertido. Una prueba escrita sobre semántica falla cuando la interfaz deja de comunicar lo que decía, que es la definición operativa de una regresión de interfaz.
La segunda es de rendimiento del esfuerzo. Como el árbol de semántica es el mismo que consume el lector de pantalla, toda pantalla que sea cómoda de probar por significado es, por construcción, una pantalla navegable con lector de pantalla, y toda pantalla que obligue a llenarlo todo de etiquetas está diciendo que sus nodos no publican rol, ni estado, ni descripción. La dificultad para escribir la prueba no es un problema de la prueba: es el informe de accesibilidad llegando por otro canal.
Por texto
Lo primero que hay que intentar. Verifica de paso que el texto visible es el correcto, que es la mitad de la regresión.
Por descripción
Para iconos y elementos gráficos. Si no existe descripción, el defecto es de accesibilidad y no de la prueba.
Por rol y estado
Combinar rol de conmutador con estado marcado localiza sin ambigüedad y afirma comportamiento a la vez.
Por etiqueta de prueba
Último recurso legítimo para contenedores y listas sin texto propio. Nunca como sustituto del significado ausente.
Cuando un componente propio no es localizable por significado, la corrección correcta no es añadirle una etiqueta de prueba sino declararle las propiedades que le faltan con el modificador de semántica: rol, estado, descripción y acciones. Cuesta lo mismo, arregla la prueba, arregla el lector de pantalla y deja el componente mejor descrito para quien lo use después.
La sincronización automática y sus tres puntos ciegos
El mecanismo que más trabajo ahorra en esta capa es también el peor comprendido. Durante una prueba, el reloj de la composición no avanza solo: está bajo control de la prueba, y antes de cada búsqueda y de cada afirmación la biblioteca lleva el sistema a reposo, lo que significa aplicar todas las recomposiciones pendientes, ejecutar las fases de medida y disposición y avanzar las animaciones hasta que terminen. El resultado es que una afirmación escrita inmediatamente después de una pulsación observa el estado ya estabilizado, sin ninguna espera escrita a mano.
composeTestRule.onNodeWithText("Añadir").performClick()
composeTestRule.onNodeWithText("Compra").assertIsDisplayed() // ya sincronizado
Ese acoplamiento explica también por qué las animaciones no producen intermitencia aquí, al contrario que en las pruebas de vistas clásicas: el reloj virtual las atraviesa de golpe. Y explica cómo se prueba un estado intermedio de animación, que de otro modo sería inabordable: desactivando el avance automático del reloj y adelantándolo a mano la cantidad exacta que se quiera observar.
@Test fun aMitadDeLaTransicionSiguenLosDosPaneles() {
composeTestRule.mainClock.autoAdvance = false
composeTestRule.onNodeWithText("Abrir detalle").performClick()
composeTestRule.mainClock.advanceTimeBy(150)
composeTestRule.onNodeWithTag("panelLista").assertIsDisplayed()
composeTestRule.onNodeWithTag("panelDetalle").assertIsDisplayed()
}
El mecanismo tiene tres agujeros conocidos y conviene tenerlos presentes porque son el origen de casi todas las pruebas intermitentes de esta capa. El primero es la animación infinita: como no termina nunca, el sistema jamás alcanza reposo y la prueba se cuelga hasta agotar su tiempo de espera. El segundo es el trabajo asíncrono que no pasa por el reloj de la composición: una corrutina en un despachador de entrada y salida, una carga de imagen desde la red, una consulta a la base de datos en su propio grupo de hilos. Nada de eso es visible para la sincronización, que declara reposo mientras el dato todavía viaja. El tercero es todo lo que ocurre fuera de la composición, empezando por cualquier diálogo del sistema.
flowchart TD
A[La prueba ejecuta una accion] --> B[Se espera a reposo de forma automatica]
B --> C{Queda recomposicion o animacion finita}
C -->|si| B
C -->|no| D{El resultado depende de trabajo fuera del reloj}
D -->|no| E[La afirmacion es segura]
D -->|si| F[Usar espera explicita con condicion]
F --> G{Se cumple la condicion antes del limite}
G -->|si| E
G -->|no| H[Fallo con mensaje util en lugar de intermitencia]
style E fill:#a6e3a1,color:#11111b
style H fill:#f38ba8,color:#11111bEsperar con condición, nunca con tiempo
Para el segundo agujero existe la espera explícita, y su forma correcta es siempre la misma: se declara la condición que debe cumplirse y un límite de paciencia, y la biblioteca reevalúa periódicamente llevando el sistema a reposo entre intentos. La diferencia con dormir un número fijo de milisegundos es de naturaleza, no de grado. Una espera por tiempo escoge un número que es simultáneamente demasiado grande en la máquina de desarrollo y demasiado pequeño en el agente de integración continua cargado; una espera por condición termina en cuanto la condición se cumple y falla con un mensaje que dice qué se esperaba cuando no se cumple.
@Test fun laListaAparecCuandoLlegaLaRespuesta() {
composeTestRule.setContent { MiTema { PantallaNotas(viewModel = vmConRedLenta) } }
composeTestRule.waitUntilExactlyOneExists(
hasText("Compra"),
timeoutMillis = 5_000,
)
composeTestRule.onNodeWithText("Compra").assertIsDisplayed()
}
Las variantes con matcher cubren la mayoría de los casos de forma legible, y para condiciones que no se expresan como presencia de un nodo queda la forma general de waitUntil con una expresión booleana, útil para esperar a que una lista alcance cierto tamaño o a que desaparezca un indicador de progreso.
Conviene añadir un principio que evita casi todo este trabajo: la mayoría de las esperas explícitas en una suite son el síntoma de que la prueba está usando la infraestructura real cuando podría estar controlándola. Si el modelo de vista recibe su despachador por inyección y la prueba le pasa el del entorno de corrutinas, el trabajo asíncrono vuelve a estar bajo el control del reloj de la prueba y la espera desaparece. La espera explícita es la herramienta correcta para la frontera con lo que de verdad no controlas, y una mala solución para lo que sí podrías controlar y no quisiste.
Una espera de dos segundos escrita para arreglar una prueba intermitente no arregla nada: convierte un fallo reproducible en un fallo probabilístico y añade dos segundos a cada ejecución futura. Si aparece la tentación, la pregunta correcta es qué trabajo está ocurriendo fuera del reloj de la composición, y la respuesta a esa pregunta suele ser también el defecto de diseño que hacía difícil la prueba.
Merece la pena detenerse en lo que este diseño implica, porque va bastante más allá de una elección de API y toca la pregunta de qué es exactamente una interfaz. Durante dos décadas, probar una pantalla significó recorrer la estructura que la dibujaba: encontrar el widget por su identificador, comprobar su posición, verificar sus atributos gráficos. Ese enfoque acopla la prueba a la implementación visual de la manera más rígida posible, y produce el resultado familiar de que cualquier rediseño invalida cientos de pruebas que nunca dijeron nada sobre si la pantalla funcionaba. Compose rompe esa relación por una vía que es fácil pasar por alto: la prueba no puede acceder a la estructura de dibujo, ni siquiera queriendo, porque tal estructura consultable no existe. Lo único que hay es la descripción que la interfaz hace de sí misma en términos de qué significa cada parte y qué se puede hacer con ella. La consecuencia inmediata es que ahora es imposible escribir la clase de prueba frágil que antes era la norma, y la mediata es más profunda: la prueba y el lector de pantalla dejan de ser dos consumidores distintos y pasan a ser el mismo consumidor, porque ambos preguntan lo mismo. Esto disuelve una tensión organizativa que había resultado insoluble durante años. La accesibilidad fracasa en la práctica porque es un trabajo cuyo beneficiario no está en la sala, que no bloquea ninguna entrega y que nadie nota si se omite; puesta a competir con el resto del trabajo, pierde siempre y no por maldad sino por ausencia de realimentación. Al hacer que el árbol de semántica sea la única superficie por la que se puede verificar la interfaz, ese trabajo deja de ser opcional por una vía indirecta y mucho más eficaz que cualquier norma: una pantalla mal descrita ya no es una pantalla con un defecto invisible, es una pantalla que no se puede probar. La dificultad para escribir la prueba se convierte en la señal de realimentación que la accesibilidad nunca tuvo, y llega en el momento correcto, al desarrollador correcto, en el lenguaje que ese desarrollador ya atiende. Vale la pena reconocer la elegancia de la jugada: no se resolvió el problema convenciendo a nadie, se resolvió colocando el incentivo donde el trabajo ocurre.
- Elige tu pantalla más compleja, imprime su árbol de semántica y anota cuántos nodos interactivos carecen de rol, estado o descripción.
- Reescribe sus pruebas eliminando toda etiqueta de prueba que sustituya a un significado ausente, y añade la semántica que falte en los componentes propios.
- Añade una prueba de estado intermedio de animación desactivando el avance automático del reloj y adelantándolo a mano.
- Localiza todas las esperas por tiempo de tu suite, sustitúyelas por espera con condición y anota en cada caso qué trabajo ocurría fuera del reloj de la composición.
- Elimina la mitad de esas esperas inyectando el despachador de prueba en los modelos de vista implicados y compara el tiempo total de ejecución.