wandres.dev
TESTING NO EXHAUSTIVO · y de integración

Testear la vista: snapshots, tests de UI y lo que sobra

Cuando la lógica ya vive en el reducer, la vista queda como una función de estado a píxeles y el riesgo residual cambia de naturaleza: deja de ser lógico y pasa a ser presentacional —texto largo, tipografía dinámica, modo oscuro, estado vacío, traducción que desborda—. Esta lección reparte ese resto entre las tres herramientas disponibles: previsualizaciones para el bucle rápido, snapshots para fijar estados concretos con su coste de fragilidad bien entendido, y un puñado mínimo de tests de UI de humo con dependencias preparadas al arrancar. Y cierra el nivel con la lista de lo que, con la lógica cubierta, ya no merece la pena testear.

⏱ 19 min

Una vez que la lógica vive entera en el reducer, la vista se queda sin nada que un test de comportamiento pueda examinar: no decide, no espera, no guarda; solo dibuja lo que el estado dice y manda acciones cuando la tocan. Esa pobreza deliberada es un logro arquitectónico y tiene una consecuencia directa en la estrategia de pruebas: el riesgo que queda en la capa de interfaz ya no es lógico sino presentacional, y los defectos que sobreviven —un texto que desborda al traducirlo, una fila que se rompe con tipografía accesible, un estado vacío que nadie diseñó— no se detectan afirmando valores, porque no son valores. Esta lección reparte ese resto entre las tres herramientas que existen, mide lo que cada una cuesta y cierra el nivel con la parte más liberadora: la lista de lo que ya no hace falta probar.

🎯 Al terminar esta lección sabrás
  • Reconocer qué clase de defecto queda en la vista cuando la lógica está cubierta, y por qué exige un instrumento distinto.
  • Escribir snapshots sobre estados concretos con un store sin reducer, y administrar su fragilidad en integración continua.
  • Reservar los tests de UI a un puñado de comprobaciones de humo, con dependencias preparadas al arrancar la aplicación.
  • Enumerar y defender lo que no merece la pena testear una vez el reducer está cubierto.

Qué queda en la vista cuando le quitas la lógica

Un defecto de interfaz en una aplicación TCA pertenece casi siempre a una de estas cinco familias, y ninguna se detecta comparando estados: texto que desborda su contenedor al traducirse o al llegar largo del servidor; jerarquía que se descompone con tipografía dinámica en tamaños accesibles; contraste o color que falla en modo oscuro; estados de excepción —vacío, cargando, error, sin permiso— que existen en el State pero que nadie dibujó nunca; y elementos de accesibilidad ausentes o mal etiquetados. Todas comparten una propiedad: se ven, no se afirman.

Frente a ellas hay tres instrumentos con costes muy distintos por unidad de información. La previsualización de Xcode es instantánea y no deja rastro: no falla en integración continua, no protege de regresiones, pero atrapa la inmensa mayoría de estos defectos en el momento en que se escriben, sobre todo si aprovechas los valores de previsualización de tus dependencias para dibujar sin backend. El snapshot es una previsualización congelada que sí falla y sí protege, a cambio de una fragilidad que hay que administrar. Y el test de UI es el único que ejercita la aplicación real de principio a fin, con un coste por prueba tres órdenes de magnitud mayor.

#Preview("Estado vacío") {
  PedidosView(
    store: Store(initialState: Pedidos.State(pedidos: [])) {
      Pedidos()
    } withDependencies: {
      $0.pedidosCliente = .previewValue
    }
  )
}

Una previsualización con dependencias de previsualización dibuja la pantalla real, con su reducer real, sin tocar la red y sin depender de que exista una cuenta con datos. Es el instrumento con mejor relación entre coste e información de todo este nivel, y su única carencia —que nadie la ejecuta en integración continua— es precisamente la que el snapshot viene a cubrir.

flowchart TD
R[Riesgo residual en la capa de interfaz] --> V[Defectos visuales y de accesibilidad]
V --> PV[Previsualizaciones: bucle rapido, sin red de seguridad]
V --> SN[Snapshots: fijan estados concretos y detectan regresiones]
V --> UI[Tests de UI: verifican que la aplicacion real arranca y navega]
PV --> C1[Coste casi nulo, cobertura no verificable]
SN --> C2[Coste medio, fragilidad de entorno]
UI --> C3[Coste alto, inestabilidad alta, valor unico]
style PV fill:#a6e3a1,color:#11111b
style SN fill:#89b4fa,color:#11111b
style UI fill:#f9e2af,color:#11111b

Snapshots: fijar estados, no recorridos

La clave para que el snapshot sea barato en TCA es que no necesitas recorrer nada para llegar a un estado interesante: lo construyes. Un State es un valor, así que el estado de error tras tres reintentos fallidos se escribe en una línea en vez de alcanzarse simulando tres fallos. Y como para dibujar no hace falta que corra ninguna lógica, el store puede montarse sin reducer, con lo que ningún efecto se dispara durante la prueba.

@Test
func estadosDeLaPantallaDePedidos() {
  let vacia = PedidosView(
    store: Store(initialState: Pedidos.State(pedidos: [])) { }
  )
  assertSnapshot(of: UIHostingController(rootView: vacia), as: .image(on: .iPhone13))

  let conError = PedidosView(
    store: Store(initialState: Pedidos.State(error: .sinConexion)) { }
  )
  assertSnapshot(of: UIHostingController(rootView: conError), as: .image(on: .iPhone13))

  let largo = PedidosView(
    store: Store(initialState: .conNombreMuyLargo) { }
  )
  assertSnapshot(
    of: UIHostingController(rootView: largo),
    as: .image(on: .iPhone13, traits: .init(preferredContentSizeCategory: .accessibilityExtraLarge))
  )
}

El criterio de selección es combinatorio y hay que aplicarlo con mano dura, porque el producto de estados por dispositivos por apariencias por tamaños de letra crece hasta volverse ingobernable. La regla que funciona es fijar un solo dispositivo y una sola versión del sistema como referencia, cubrir todos los estados cualitativamente distintos de la pantalla —vacío, cargando, error, contenido normal, contenido extremo— y añadir variantes de apariencia o tipografía únicamente en las pantallas donde ya te ha mordido antes.

La fragilidad de los snapshots es real y conviene nombrarla sin adornos: la imagen depende del simulador, de la versión del sistema, de las fuentes instaladas y de cualquier cambio en el motor de dibujo, de modo que una actualización de Xcode puede teñir de rojo la suite entera sin que tu código haya cambiado. Se administra con tres decisiones. La primera es fijar en integración continua un dispositivo y una versión concretos, y aceptar que regenerar todas las imágenes es una tarea planificada cuando ese par cambia. La segunda es publicar los diffs como artefactos del trabajo de integración, porque un snapshot cuyo fallo no puedes mirar es peor que no tenerlo. La tercera es desconfiar de la tolerancia de precisión: bajarla apaga el ruido, sí, pero también apaga exactamente las regresiones sutiles —un desplazamiento de dos puntos, un tono ligeramente distinto— que el snapshot existía para cazar.

💡
El snapshot más estable no es una imagen

Antes de recurrir a píxeles, considera fijar en texto lo que de verdad quieres proteger: una descripción textual de la jerarquía de vistas, o el volcado del estado de vista que alimenta la pantalla. Un snapshot textual se revisa en el propio diff del cambio, no engorda el repositorio, no depende de la versión del simulador y detecta el cambio de estructura, que suele ser el que importa. Reserva la imagen para lo que solo la imagen puede ver: composición espacial, contraste y desbordamiento.

Queda un coste que casi nunca se contabiliza al adoptar snapshots y que conviene decidir a conciencia: las imágenes viven en el repositorio y engordan su historia para siempre, porque un binario modificado no se comprime contra su versión anterior como lo hace el texto. Doscientas pantallas con cuatro variantes cada una son ochocientas imágenes que cambian con cada rediseño, y ese peso lo pagan todos los clones y todos los trabajos de integración durante el resto de la vida del proyecto. Es una razón más para que la selección de estados sea deliberada y no automática.

Tests de UI: pocos, de humo y deterministas

Un test de UI cuesta decenas de segundos, falla a veces por motivos ajenos a tu código y es difícil de depurar; a cambio verifica algo que ninguna otra prueba de este nivel toca: que la aplicación real arranca, que las dependencias vivas están correctamente registradas, que la navegación funciona con el motor de SwiftUI de verdad y que los permisos y las capacidades del proyecto están en orden. Esa combinación de coste alto y valor irreemplazable dicta la política: tres o cinco pruebas de humo que recorran los caminos que sostienen el negocio, y ni una más.

Para que no parpadeen hay que quitarles el mundo, igual que a todo lo demás. La aplicación puede preparar sus dependencias al arrancar cuando detecta que está bajo prueba, sustituyendo la capa de red y los relojes por implementaciones fijas.

@main
struct MiApp: App {
  init() {
    if ProcessInfo.processInfo.environment["PRUEBA_DE_UI"] != nil {
      prepareDependencies {
        $0.api = .fixture
        $0.continuousClock = ImmediateClock()
      }
    }
  }
  var body: some Scene { WindowGroup { AppView(store: .principal) } }
}
final class HumoTests: XCTestCase {
  func testArranqueYCompraMinima() {
    let app = XCUIApplication()
    app.launchEnvironment["PRUEBA_DE_UI"] = "1"
    app.launch()

    app.buttons["Catalogo"].tap()
    app.cells.firstMatch.tap()
    app.buttons["Comprar"].tap()
    XCTAssertTrue(app.staticTexts["Pedido confirmado"].waitForExistence(timeout: 5))
  }
}

Fíjate en lo que este test verifica y en lo que no: no comprueba ninguna regla de negocio —el cobro, los impuestos, el inventario están todos probados en sus reducers—, comprueba que el ensamblaje real existe. Que la aplicación arranca sin abortar, que las dependencias vivas están registradas, que los identificadores de accesibilidad por los que navega existen de verdad y que la pantalla de confirmación se alcanza con el motor real de SwiftUI. Eso no lo sabe ningún TestStore, porque un TestStore construye el árbol a mano y nunca ejecuta el punto de entrada de la aplicación.

Con esa preparación, el test de UI deja de medir la red y pasa a medir lo que querías: que el ensamblaje real funciona. Y conviene decir la parte incómoda: si tus tests de UI son numerosos y lentos, casi siempre es porque están verificando lógica de negocio a través del instrumento más caro disponible. Cada uno de ellos tiene un equivalente en un test de reducer que corre en un milisegundo y que además te dice, cuando falla, exactamente qué campo se movió.

Lo que ya no merece la pena testear

Tentación Por qué sobra
Que un botón envía la acción esperada El compilador lo garantiza | y el test de integración lo cubre de rebote
Que un texto muestra un campo del estado Es la definición de la vista | no una afirmación sobre el dominio
Que una hoja aparece cuando el destino no es nulo Es responsabilidad de TCA | ya está probado aguas arriba
Que un binding escribe en el estado Lo prueba el reducer de bindings | no tu pantalla
Posiciones y medidas exactas de la maquetación Cambia con cada rediseño | rompe sin señalar ningún error
Cualquier regla de negocio a través de la interfaz Ya está cubierta donde vive | aquí solo la reverificas más despacio

Las seis filas comparten una raíz: son intentos de probar el marco de trabajo o de reprobar lo ya probado, en vez de probar tu dominio. Y hay una lectura de diseño que sacar de la primera y de la última: si te resulta difícil resistir la tentación de testear una regla desde la interfaz, sospecha que esa regla se quedó en la vista. La respuesta no es escribir el test de UI, es mover la regla al reducer, que es donde tenías previsto que estuviera desde el nivel uno.

La vista es el único sitio donde la apuesta de TCA no te ayuda, y por eso es el único que se prueba mirando

Merece la pena cerrar este nivel entendiendo por qué el testing de la vista queda tan raquítico en una aplicación TCA bien construida, porque la explicación no es que la interfaz importe poco sino que la arquitectura ha ido vaciándola sistemáticamente durante veintitrés niveles. Cada decisión que tomaste tuvo el mismo efecto colateral: al meter el estado en un valor, la vista dejó de guardar; al convertir los eventos en acciones, dejó de decidir; al devolver los efectos como datos, dejó de esperar; al modelar la navegación como estado, dejó de mandar sobre sí misma; al inyectar las dependencias, dejó de hablar con el mundo. Lo que queda después de esa extracción metódica es una función total del estado a una descripción declarativa de píxeles, y una función así tiene una propiedad singular dentro del sistema: su corrección no es una proposición que se pueda afirmar, es un juicio que se emite mirando. Nadie puede escribir la aserción de que una pantalla se ve bien, porque bien no es un predicado sobre valores sino una relación entre lo dibujado y un ojo humano con expectativas culturales, tipográficas y de accesibilidad. De ahí que la única herramienta sensata en esta capa sea congelar imágenes y compararlas —una forma de comprobar que nadie cambió el juicio ya emitido, no de comprobar que el juicio era correcto—, y de ahí también que los tests de UI, que en otras arquitecturas cargan con toda la verificación del comportamiento, aquí queden reducidos a confirmar que el ensamblaje arranca. Esa es la simetría que cierra el nivel y con él la historia del testing en TCA: la lógica se verifica exhaustivamente porque es determinista y predecible; los flujos se verifican laxamente porque su interés está en el desenlace y no en la coreografía; y la interfaz se mira, porque el resto de la arquitectura hizo el trabajo de dejar en ella solamente lo que hay que mirar.

⚔️ Reduce la capa de interfaz a lo mínimo verificable
  1. Elige tu pantalla más compleja y enumera sus estados cualitativamente distintos leyendo el State, no la vista. Escribe un snapshot por estado con un store sin reducer.
  2. Añade una variante con tipografía accesible extra grande y otra con un texto de longitud extrema. Anota cuál de las dos rompe primero la maquetación.
  3. Configura la integración continua con un dispositivo y una versión de sistema fijos, y publica los diffs de imagen como artefacto. Comprueba que un fallo se puede diagnosticar sin ejecutar nada en local.
  4. Revisa tus tests de UI e identifica los que verifican reglas de negocio. Reescribe uno como test de reducer, mide los dos tiempos de ejecución y decide si el original sigue justificando su sitio.
  5. Añade prepareDependencies en el arranque bajo una variable de entorno de prueba y comprueba que tus pruebas de humo dejan de depender de la red. Si alguna sigue parpadeando, busca qué dependencia se te escapó.