¿En qué se diferencia testear UI de Compose de testear el viejo sistema de Views con XML?
Espresso consulta una jerarquía de vistas por resource id y espera al hilo principal; Compose consulta un árbol de semántica por rol y texto, y en general se sincroniza solo.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿En qué se diferencia testear UI de Compose de testear el viejo sistema de Views con XML?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.La respuesta honesta es que el testing de UI de Compose no es una API nueva pegada sobre el mismo modelo que Espresso — está construido sobre un árbol completamente distinto, y la mayor parte de lo que se siente raro la primera vez que escribís un test de Compose se explica por ese único hecho.
#Espresso opera sobre la jerarquía de vistas
Espresso, el framework estándar de tests instrumentados para el sistema de Views, encuentra e interactúa con objetos android.view.View reales que viven dentro de una Activity o Fragment realmente adjunta a una ventana. Ubicás un objetivo con un ViewMatcher — lo más común, onView(withId(R.id.submit_button)) —, ejecutás una ViewAction sobre él (click(), typeText(), scrollTo()), y afirmás con una ViewAssertion (matches(isDisplayed())). Por debajo, la mayor hazaña de ingeniería de Espresso es la sincronización: se engancha a la cola de mensajes del hilo principal (vía Looper y un IdlingRegistry) y bloquea cada ViewAction hasta que el hilo de UI y cualquier IdlingResource registrado queden en reposo, así click() no retorna hasta que lo que ese click disparó realmente se asentó. Por eso los tests clásicos de Espresso necesitan registrar IdlingResource para cualquier cosa asíncrona — una llamada de red, un Handler.postDelayed, una corrutina lanzada fuera del dispatcher consciente de idling — o se vuelven flaky compitiendo la aserción contra un trabajo que todavía no terminó.
Esto también significa que un test de Espresso generalmente necesita una Activity real que lo hospede (vía ActivityScenario / ActivityScenarioRule), porque una View aislada, sin ventana y sin Activity adjunta, muchas veces se comporta distinto a una realmente dispuesta en una pantalla real (o con shadows de Robolectric).
#Los tests de Compose operan sobre el árbol de semántica, no el árbol de vistas
Jetpack Compose no tiene un equivalente a findViewById, porque los composables no son objetos a los que guardás referencias — son funciones que emiten UI. Por eso androidx.compose.ui.test no busca en absoluto en una jerarquía de vistas; busca en el árbol de semántica, una estructura separada a la que cada composable contribuye automáticamente (Text emite un nodo de semántica de texto, Button emite un nodo clickeable con un rol anotado) o explícitamente vía Modifier.semantics { }. Empezás un test con createComposeRule() — que ni siquiera necesita una Activity real que lo hospede, porque puede alojar un composable aislado en una activity de test mínima — o con createAndroidComposeRule<MyActivity>() cuando específicamente necesitás la activity real alrededor. Encontrás nodos con un SemanticsMatcher (onNodeWithText("Submit"), onNodeWithTag("submit_button")), actuás con performClick() / performTextInput(), y afirmás con assertIsDisplayed() / assertTextEquals().
La búsqueda por resource-id de la que depende Espresso no existe por defecto en Compose — un nodo se vuelve encontrable por tag agregando Modifier.testTag("submit_button"), que es el análogo directo de android:id pero solo para fines de test; no carga ningún otro significado en runtime como sí lo hace un id de vista.
#Sincronización: casi automática, hasta que aparece una animación
El framework de test de Compose sincroniza con la recomposición y el frame clock automáticamente en la mayoría de los casos — no registrás un IdlingResource para un LaunchedEffect que termina rápido, porque la regla de test ya espera a que el runtime de Compose quede en reposo entre acciones. Donde esto se rompe es con animaciones: una animación infinita o de larga duración nunca deja que el frame clock quede en reposo, así que un test ingenuo se colgaría. La solución es composeTestRule.mainClock.autoAdvance = false, seguido de avanzar el tiempo a mano con mainClock.advanceTimeBy(...), lo que hace que la UI animada sea determinista para testear en vez de competir contra el reloj de pared real.
#La trampa del árbol fusionado vs. sin fusionar
Algo que confunde a ingenieros que vienen de Espresso es que el árbol de semántica se fusiona (merge) por defecto: un Row que contiene un ícono y una etiqueta de texto colapsa, por motivos de accesibilidad, en un solo nodo de semántica con una descripción de contenido combinada, porque eso es lo que un lector de pantalla debería anunciar como una sola unidad. Si consultás directamente por el nodo de texto interno y este ya fue fusionado en su padre, onNodeWithText(...) puede fallar en encontrar un texto que claramente está presente. onRoot(useUnmergedTree = true) (o pasar useUnmergedTree = true al finder) evita esto consultando el árbol antes de la fusión, y suele ser la solución.
#Aislamiento y velocidad como el beneficio práctico
La diferencia cotidiana que notan los ingenieros senior no es filosófica, es de velocidad: Compose te deja hacer composeTestRule.setContent { MyScreen(state = fakeState) } y testear un composable en aislamiento completo, con estado falso y sin grafo de navegación, sin más ciclo de vida de Activity que un host mínimo, y sin más grafo de inyección de dependencias que el que ese composable puntual necesita. Un test equivalente en Espresso casi siempre necesita levantar la Activity/Fragment real que lo hospeda, lo que arrastra el grafo de DI, el componente de navegación y los dueños de ciclo de vida que esa pantalla necesita en producción. Ese aislamiento también es la razón por la que Compose combina tan naturalmente con el screenshot testing tipo @Preview (Paparazzi, Roborazzi): la misma llamada "renderizá este composable con estas props falsas" que impulsa un unit test rápido es la misma llamada que usa una herramienta de screenshot para renderizar un bitmap, mientras que un test de screenshot equivalente basado en Views normalmente todavía necesita una vista real, medida y dispuesta, dentro de un host.
// Espresso — sistema de Views, necesita una Activity real que lo hospede@RunWith(AndroidJUnit4::class)class LoginActivityEspressoTest {@get:Ruleval activityRule = ActivityScenarioRule(LoginActivity::class.java)@Testfun submitButton_deshabilitadoHastaCompletarLosCampos() {onView(withId(R.id.submit_button)).check(matches(not(isEnabled())))onView(withId(R.id.email_field)).perform(typeText("dev@example.com"))onView(withId(R.id.password_field)).perform(typeText("hunter2"), closeSoftKeyboard())onView(withId(R.id.submit_button)).check(matches(isEnabled()))onView(withId(R.id.submit_button)).perform(click())onView(withId(R.id.loading_spinner)).check(matches(isDisplayed()))}}
// Compose — arbol de semantica, no requiere Activity para el composable en siclass LoginScreenComposeTest {@get:Ruleval composeTestRule = createComposeRule()@Testfun submitButton_deshabilitadoHastaCompletarLosCampos() {composeTestRule.setContent {AppTheme { LoginScreen(onSubmit = {}) }}composeTestRule.onNodeWithTag("submit_button").assertIsNotEnabled()composeTestRule.onNodeWithTag("email_field").performTextInput("dev@example.com")composeTestRule.onNodeWithTag("password_field").performTextInput("hunter2")composeTestRule.onNodeWithTag("submit_button").assertIsEnabled()composeTestRule.onNodeWithTag("submit_button").performClick()composeTestRule.onNodeWithTag("loading_spinner").assertIsDisplayed()}}