Tests de UI con XCUITest: cuándo compensan y por qué son frágiles
Una prueba de interfaz maneja la app desde otro proceso a través del árbol de accesibilidad, y esa arquitectura explica a la vez todo lo que compra y todo lo que cuesta: es la única capa que verifica el arranque real y el recorrido completo, y la única cuya fragilidad es estructural porque toda consulta es una carrera contra la animación. Esta lección delimita los pocos casos donde compensa, convierte los identificadores de accesibilidad en un contrato explícito y reúne las técnicas que reducen la inestabilidad a un mínimo administrable.
XCUITest no es una prueba unitaria grande: es una arquitectura distinta. El proceso de pruebas no habla con tus objetos sino que le pide al sistema que manipule otra aplicación, y cada consulta viaja hasta el árbol de accesibilidad de un proceso que mientras tanto sigue animando, cargando y recomponiendo vistas. De ese hecho se derivan sus dos propiedades esenciales, que conviene aceptar juntas: es la única capa capaz de responder a la pregunta de si la app arranca y se puede usar, y es una capa donde toda afirmación es una carrera contra el tiempo. Quien intenta escribir con ella la suite entera acaba con cuarenta minutos de ejecución y un equipo que reejecuta ante cualquier rojo.
- Delimitar los pocos recorridos donde una prueba de extremo a extremo compra algo que ninguna otra capa puede comprar.
- Tratar el identificador de accesibilidad como un contrato estable y separarlo del texto visible y de la localización.
- Sembrar el estado inicial de la app desde la propia prueba para que cada caso arranque en una situación conocida.
- Reconocer las tres fuentes de inestabilidad de esta capa y aplicar la técnica correspondiente a cada una.
Lo que solo esta capa puede verificar
La pregunta útil no es si las pruebas de interfaz son buenas o malas, sino qué defectos existen que ninguna otra prueba puede detectar. La lista es corta y por eso mismo valiosa: que la app arranca sin fallar con el paquete real y sus recursos; que la navegación completa lleva del inicio al objetivo sin callejones sin salida; que el flujo de compra, alta o pago funciona de punta a punta; que la integración entre módulos que nadie prueba junta se sostiene; y que un cambio en el sistema no ha roto un permiso o una hoja del sistema. Todo lo demás —reglas, formatos, cálculos, estados de error— se comprueba más rápido y con más fiabilidad una capa por debajo.
De ahí sale la regla de presupuesto que sostienen los equipos que conviven bien con esta herramienta: un puñado de recorridos críticos, no una matriz de casos. Cinco pruebas que cubren los caminos por los que pasa el noventa por ciento de los usuarios valen más que ochenta que exploran variantes de un formulario, porque las ochenta tardan media hora, fallan por su cuenta cada semana y verifican cosas que un test de lógica clava en un milisegundo.
Si para comprobar que un descuento se aplica bien alguien navega tres pantallas con XCUITest, el problema no es la prueba: es que el descuento no es accesible desde ninguna otra parte. La prueba de interfaz es entonces el síntoma de una arquitectura donde la regla vive dentro de la vista. Extraer la regla arregla las dos cosas a la vez, y suele borrar diez pruebas lentas de golpe.
El identificador de accesibilidad es el contrato
Buscar un elemento por su texto visible ata la prueba a dos cosas que cambian solas: la redacción y el idioma. El identificador de accesibilidad existe justamente para dar a cada elemento un nombre estable que no se muestra al usuario ni se traduce, y usarlo convierte la interfaz en una superficie con contrato.
// En la vista: el nombre estable va aparte del texto visible.
Button("Guardar cambios") { modelo.guardar() }
.accessibilityIdentifier("perfil.boton.guardar")
List(modelo.tareas) { tarea in
TareaFila(tarea: tarea)
.accessibilityIdentifier("tareas.fila.\(tarea.id)")
}
// En la prueba: un objeto de pantalla concentra los selectores.
struct PantallaPerfil {
let app: XCUIApplication
var guardar: XCUIElement { app.buttons["perfil.boton.guardar"] }
var aviso: XCUIElement { app.staticTexts["perfil.texto.aviso"] }
@discardableResult
func pulsarGuardar() -> Self {
XCTAssertTrue(guardar.waitForExistence(timeout: 5))
guardar.tap()
return self
}
}
El objeto de pantalla no es ceremonia: es lo que impide que renombrar un identificador obligue a editar treinta ficheros. Toda consulta vive en un solo sitio, las pruebas quedan escritas en el vocabulario del producto y el mantenimiento se concentra donde debe. La convención de nombres —pantalla, tipo de elemento, propósito— importa menos que el hecho de tener una, porque un espacio de nombres plano acaba con dos botones llamados guardar en pantallas distintas y con una prueba que pulsa el equivocado.
Identificador, nunca texto
El texto se traduce, se acorta y se reescribe por decisión de marketing. El identificador solo cambia cuando alguien decide cambiarlo.
Objeto de pantalla
Una estructura por pantalla con sus elementos y sus acciones. La prueba lee como una descripción del recorrido, no como una lista de consultas.
Estado sembrado
Cada caso arranca con datos conocidos inyectados al lanzar, nunca con lo que dejó la prueba anterior.
Espera explícita
Nada existe cuando la prueba lo pide. Toda consulta se acompaña de una espera con tiempo límite y con mensaje.
La fragilidad es asíncrona y se administra
La inestabilidad de esta capa tiene tres orígenes, y cada uno admite un remedio distinto. El primero es el estado previo: si la app conserva sesión, base de datos o preferencias entre casos, el orden de ejecución decide el resultado. El remedio es sembrar la app desde el lanzamiento y hacer que la propia app entienda esos argumentos.
final class CompraTests: XCTestCase {
private var app: XCUIApplication!
override func setUp() {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["-modoPruebas", "-almacenLimpio", "-sinAnimaciones"]
app.launchEnvironment = ["SESION": "usuario.demo", "CATALOGO": "fijo"]
app.launch()
}
}
// En la app, una unica puerta para el modo de pruebas.
@main struct MiApp: App {
init() {
if ProcessInfo.processInfo.arguments.contains("-sinAnimaciones") {
UIView.setAnimationsEnabled(false)
}
}
}
El segundo origen es el tiempo: la prueba consulta un elemento que aún no ha aparecido porque hay una animación en curso o una carga pendiente. La respuesta correcta nunca es una espera fija, sino una espera sobre la condición concreta, con waitForExistence o con una expectativa sobre un predicado, y con un mensaje que diga qué se estaba esperando. Desactivar las animaciones en modo de pruebas elimina de golpe una fracción grande de estas carreras, y sustituir el servidor real por respuestas locales elimina otra.
El tercero es el sistema: diálogos de permisos, teclados que aparecen tapando el botón, hojas de compartir y alertas del sistema operativo que interrumpen el flujo. Aquí la herramienta es el monitor de interrupciones, que registra un manejador para cuando aparezca una alerta ajena, y la disciplina de conceder permisos por adelantado en la configuración del simulador siempre que sea posible.
addUIInterruptionMonitor(withDescription: "Permiso de notificaciones") { alerta in
let permitir = alerta.buttons["Permitir"]
guard permitir.exists else { return false }
permitir.tap()
return true
}
app.tap() // el monitor solo se dispara al interactuar con la app
Ese último toque desconcierta a todo el mundo la primera vez: el manejador no se ejecuta cuando aparece la alerta sino cuando la prueba intenta interactuar con la aplicación y encuentra la interrupción por delante. Y como el texto del botón pertenece al sistema operativo y está traducido, esta es la única situación donde buscar por texto es inevitable; la salida limpia consiste en fijar el idioma del simulador desde los argumentos de lanzamiento para que ese texto sea predecible.
flowchart TB f[Prueba de interfaz inestable] --> a[La causa es estado previo] a --> a1[Sembrar datos al lanzar y limpiar el almacen] f --> b[La causa es sincronizacion] b --> b1[Esperas por condicion con tiempo limite] b --> b2[Desactivar animaciones en modo pruebas] b --> b3[Respuestas de red locales y deterministas] f --> c[La causa es el sistema operativo] c --> c1[Monitor de interrupciones para alertas] c --> c2[Permisos concedidos antes de lanzar] a1 --> v[Prueba repetible] b1 --> v b2 --> v b3 --> v c1 --> v c2 --> v
Cuando una prueba falla solo en la máquina de integración, el instinto de mirar el código es casi siempre estéril. Los adjuntos automáticos del informe —captura de pantalla en el instante del fallo, jerarquía de elementos y grabación del recorrido— contienen la respuesta en la mayoría de los casos, y activar la captura de vídeo solo ante fallo cuesta muy poco espacio. Añadir pasos con nombre mediante actividades hace además que el informe indique en qué tramo del recorrido se rompió, en lugar de señalar una línea de consulta sin contexto.
Vale la pena nombrar con precisión por qué esta capa envejece peor que cualquier otra, porque la explicación habitual —son lentas y frágiles— describe los síntomas y no la causa. Una prueba de lógica afirma sobre un comportamiento: dado este estado y esta entrada, este resultado. Una prueba de interfaz afirma inevitablemente sobre una estructura: que existe un botón, que está en esta pantalla, que se llega a él después de estos tres toques, que el resultado se muestra como un texto y no como una insignia. Y la estructura de una app es precisamente lo que el equipo de producto cambia cada trimestre sin considerar que esté cambiando nada importante. De ahí que estas pruebas se rompan sin que exista ningún defecto, con una frecuencia que no depende de tu disciplina sino del ritmo de rediseño del producto; y de ahí también que su coste de mantenimiento crezca con el número de pruebas mientras su valor crece solo con el número de recorridos distintos que cubren, que es un número mucho menor. Esa divergencia entre coste y valor es la razón matemática de que la cima de la pirámide deba ser estrecha, y explica por qué el remedio correcto ante una suite de interfaz inestable casi nunca es mejorar las esperas: es borrar el ochenta por ciento de los casos y mover lo que verificaban una capa más abajo. La segunda consecuencia es más sutil y más útil: como estas pruebas dependen del árbol de accesibilidad, hacerlas posibles obliga a etiquetar la interfaz, y una interfaz bien etiquetada es exactamente una interfaz que un lector de pantalla puede recorrer. El esfuerzo que inviertes en poder automatizar la app es, sin metáfora, el mismo esfuerzo que la vuelve utilizable por quien no la ve.
- Enumera los recorridos de tu app y quédate con los que, si se rompen, impiden usarla. Comprueba que caben en los dedos de una mano.
- Añade identificadores de accesibilidad a los elementos de esos recorridos con una convención de tres tramos y verifica que ninguna prueba busca por texto visible.
- Escribe un objeto de pantalla por cada pantalla implicada y reescribe una prueba existente usándolo.
- Implementa el modo de pruebas con argumentos de lanzamiento que limpie el almacén, desactive animaciones y fije el catálogo.
- Ejecuta la suite de interfaz veinte veces seguidas, anota cuáles fallaron alguna vez y clasifica cada fallo en una de las tres causas de esta lección.