Espresso/Compose Testing vs XCUITest: ¿cómo abordas las pruebas de UI en cada plataforma?
Android se apoya en los idling resources de Espresso y el árbol de semántica de Compose; iOS se apoya en los identificadores de accesibilidad de XCTest — ambos combaten la misma flakiness con herramientas distintas.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Espresso/Compose Testing vs XCUITest: ¿cómo abordas las pruebas de UI en cada plataforma?
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.#El mismo objetivo, dos runtimes muy distintos
Ambas plataformas intentan responder la misma pregunta — ¿esta pantalla se comporta como la experimentaría un usuario real? — pero llegan ahí con arquitecturas opuestas. Espresso y la API de testing de Compose corren en el mismo proceso: tu código de prueba y la app bajo prueba comparten proceso, así que podés meter la mano en el grafo de dependencias de la app y reemplazar cosas por fakes antes de que arranque la prueba. XCUITest corre fuera de proceso: XCUIApplication() lanza la app como un proceso separado de caja negra y la maneja únicamente a través de la capa de accesibilidad, tal como lo haría VoiceOver. Esa única diferencia arquitectónica explica casi todo lo demás.
#Compose: un árbol de semántica, no una jerarquía de vistas
createComposeRule() — o createAndroidComposeRule<MainActivity>() cuando hace falta una Activity real — te da finders que consultan el árbol de semántica de Compose en vez de la jerarquía de Views clásica: onNodeWithTag, onNodeWithText, onNodeWithContentDescription. Las acciones se despachan por el mismo canal de entrada que usa el sistema operativo — performClick(), performTextInput(), performScrollTo(). Un detalle clave: la regla de prueba de Compose tiene su propio reloj virtual y espera automáticamente a que la recomposición y las animaciones se asienten antes de cada aserción, por eso una pantalla de Compose simple normalmente no necesita ninguna sincronización manual.
#Espresso: idling resources para la realidad de interop
La mayoría de las bases de código Android en producción todavía mezclan Fragments, RecyclerViews o Views clásicas, y Espresso es lo que maneja eso — onView(withId(...)), perform(click()), check(matches(...)). Espresso se sincroniza automáticamente con la cola de mensajes del hilo principal, pero no tiene visibilidad sobre una corrutina o una llamada de red corriendo en un dispatcher de fondo. Para eso registrás un IdlingResource — o un wrapper consciente de corrutinas registrado con IdlingRegistry — que le dice a Espresso "esperá, todavía hay trabajo en curso". Si te salteás esto, las pruebas hacen clic en un botón antes de que su resultado se haya renderizado de forma intermitente, lo cual se lee como flakiness cuando en realidad es una señal de sincronización que falta.
#iOS: caja negra por diseño
XCUIApplication() nunca toca los internos de tu app; no hay forma de inyectar un ViewModel fake dentro de un XCUITest en ejecución como sí podrías reemplazar un módulo de test en un grafo de DI. La solución práctica a la que recurren los equipos de iOS es un argumento de lanzamiento o variable de entorno leído al arrancar la app — algo como -UITestMode — que reemplaza datos de prueba o una capa de red fake dentro de la propia app, antes de que la prueba consulte un solo elemento. Los elementos se encuentran de la misma forma en que los encontraría un servicio de accesibilidad: app.buttons["login_button"], buscados por un accessibilityIdentifier fijado explícitamente en SwiftUI o UIKit — nunca por el texto visible localizado, que se rompe apenas cambian las traducciones.
#Esperar: Compose lo hace por vos, XCUITest te obliga a pedirlo
Como XCUITest maneja un proceso real y separado, no tiene forma incorporada de saber cuándo terminó realmente el trabajo asíncrono de tu app — una llamada de red, un publisher de Combine, un Task —; consultar demasiado temprano simplemente devuelve "elemento no encontrado". element.waitForExistence(timeout:) es la solución idiomática, y aparece todo el tiempo. La regla de prueba de Compose, en cambio, avanza su propio reloj automáticamente y bloquea hasta que la composición queda inactiva, por eso la mayoría de las pruebas de UI de Compose se leen como siempre quisieron leerse las de Espresso: clic, aserción, sin espera explícita.
#Mantené ambas legibles con un objeto Screen
Sin importar la plataforma, poner onNodeWithTag("login_button") o app.buttons["login_button"] a mano en cada método de prueba convierte un rediseño de UI en un buscar-y-reemplazar por docenas de archivos. Envolver los selectores y acciones de cada pantalla detrás de un pequeño objeto — una clase Screen en Android, un tipo equivalente en iOS — hace que el método de prueba en sí se lea como una historia de usuario, mientras que el único lugar que conoce tags e identificadores es el objeto Screen.
class LoginScreen(private val compose: ComposeTestRule) {fun typeEmail(value: String) = apply {compose.onNodeWithTag("email_field").performTextInput(value)}fun typePassword(value: String) = apply {compose.onNodeWithTag("password_field").performTextInput(value)}fun submit() = apply {compose.onNodeWithTag("login_button").performClick()}}class LoginFlowTest {@get:Ruleval composeRule = createAndroidComposeRule<MainActivity>()@Testfun loginButton_isDisabledUntilFieldsAreFilled() {composeRule.onNodeWithTag("login_button").assertIsNotEnabled()LoginScreen(composeRule).typeEmail("ada@example.com").typePassword("s3cret123").submit()composeRule.onNodeWithText("Welcome back, Ada").assertIsDisplayed()}}
final class LoginFlowUITests: XCTestCase {private var app: XCUIApplication!override func setUpWithError() throws {continueAfterFailure = falseapp = XCUIApplication()app.launchArguments = ["-UITestMode", "1"]app.launch()}func testLoginButtonEnablesOnlyAfterBothFieldsAreFilled() {let loginButton = app.buttons["login_button"]XCTAssertFalse(loginButton.isEnabled)let email = app.textFields["email_field"]email.tap()email.typeText("ada@example.com")let password = app.secureTextFields["password_field"]password.tap()password.typeText("s3cret123")XCTAssertTrue(loginButton.isEnabled)loginButton.tap()let welcome = app.staticTexts["Welcome back, Ada"]XCTAssertTrue(welcome.waitForExistence(timeout: 5))}}