Snapshot testing: capturar cada estado y detectar regresiones visuales
Una instantánea no verifica que la vista sea correcta sino que sigue siendo igual que ayer, y entender esa diferencia decide si la técnica se convierte en la red de seguridad más barata del proyecto o en un generador industrial de rojos inútiles. Esta lección explica qué fija realmente una imagen de referencia, cómo enumerar la matriz de estados que hace rentable la inversión, qué hay que congelar para que dos ejecuciones produzcan píxeles idénticos y cómo se administra el mantenimiento sin caer en la regrabación automática.
La prueba de instantánea es la técnica más incomprendida del catálogo, porque la gente la evalúa como si fuera una prueba de corrección cuando en realidad es una prueba de estabilidad. Ninguna imagen de referencia sabe si tu pantalla está bien diseñada; lo único que afirma es que el resultado de renderizar este estado es idéntico al que alguien aprobó una vez. Esa modestia es justamente su fuerza: por el precio de tres líneas cubre una superficie enorme —espaciado, tipografía, truncamientos, colores, orden, estados vacíos— que ninguna aserción escrita a mano cubriría jamás, y por el mismo motivo es la técnica que más deprisa se convierte en ruido si se aplica sin disciplina.
- Distinguir lo que una imagen de referencia fija de lo que no puede fijar, y elegir en consecuencia qué vistas merecen instantánea.
- Enumerar la matriz de estados de una vista, incluidos los degenerados, y convertirla en pruebas parametrizadas.
- Congelar las variables de entorno que hacen variar los píxeles: dispositivo, escala, tema, tipo dinámico, idioma y datos.
- Administrar el mantenimiento con revisión de diferencias y una política explícita de regrabación.
Una instantánea fija la forma, no el comportamiento
El gesto básico es renderizar una vista con un estado concreto y comparar el resultado con una imagen guardada en el repositorio. La primera ejecución graba la referencia; las siguientes comparan y fallan si algo cambió.
import SnapshotTesting
import Testing
import SwiftUI
@MainActor
@Test func fichaDeTareaEnEstadoNormal() {
let vista = FichaTarea(tarea: .ejemploCorto)
.frame(width: 390)
assertSnapshot(of: vista, as: .image(layout: .sizeThatFits))
}
Lo que esa prueba defiende no es que la ficha esté bien maquetada, sino que nadie la ha cambiado sin querer. La diferencia se nota el día que alguien ajusta el espaciado de un componente compartido: no rompe nada visible en la pantalla donde trabajaba, y en cambio catorce instantáneas de otras pantallas se ponen rojas y revelan el alcance real del cambio. Ese es el defecto que la técnica captura mejor que ninguna otra, porque es un defecto no local: el autor no tenía forma de saber a quién estaba afectando.
De ahí se sigue qué vistas merecen la inversión. Los componentes reutilizables la merecen siempre, porque su radio de impacto es grande y su superficie pequeña. Las pantallas completas la merecen en sus estados frontera. Y las vistas que cambian de forma cada dos semanas por decisión de diseño no la merecen en absoluto: cada cambio intencionado producirá un rojo que hay que aprobar, y aprobar rojos por rutina es exactamente el hábito que destruye el valor de la suite entera.
Una instantánea de una pantalla completa falla por cualquier cambio en cualquiera de sus veinte subvistas, y su imagen de diferencia obliga a buscar con lupa qué se movió. Veinte instantáneas de los componentes que la forman fallan solo donde está el cambio y señalan la causa sin ambigüedad. La regla es la misma que en cualquier otra prueba: cuanto más estrecha la afirmación, más informativo el fallo.
La matriz de estados es la técnica
El valor de una instantánea no está en la imagen sino en la enumeración que la precede. Escribir la prueba obliga a preguntarse cuántos estados distintos puede exhibir esta vista, y esa pregunta es la que encuentra los defectos de verdad, casi siempre antes de mirar ninguna imagen.
Estados de datos
Vacío, un elemento, muchos, y el caso degenerado: título larguísimo, cero decimales, nombre con una sola letra, número negativo.
Estados de proceso
Inactivo, cargando, cargado, error, sin conexión. Son los que la interfaz olvida y los que el usuario ve en el peor momento.
Estados de entorno
Tipo dinámico en su tamaño mayor, tema oscuro, idioma con palabras largas, disposición de derecha a izquierda.
Estados de tamaño
El teléfono más pequeño en uso y la tableta. La mayoría de los truncamientos y desbordes solo aparecen en los extremos.
@MainActor
@Test(arguments: EstadoFicha.allCases)
func fichaEnTodosSusEstados(_ estado: EstadoFicha) {
let vista = FichaTarea(estado: estado).frame(width: 390)
assertSnapshot(
of: vista,
as: .image(layout: .sizeThatFits, traits: .init(userInterfaceStyle: .dark)),
named: "oscuro.\(estado)"
)
assertSnapshot(
of: vista,
as: .image(layout: .sizeThatFits,
traits: .init(preferredContentSizeCategory: .accessibilityExtraLarge)),
named: "texto.enorme.\(estado)"
)
}
La combinatoria hay que administrarla con criterio: el producto cartesiano completo de estados por temas por tamaños por idiomas produce miles de imágenes que nadie revisa y que tardan minutos en compararse. La estrategia que funciona es cubrir todos los estados de datos en la configuración base, y añadir solo dos o tres configuraciones extremas —tipo dinámico máximo y tema oscuro— sobre los estados donde el riesgo de romperse es real.
Si solo puedes permitirte una variante de entorno, elige el tipo dinámico en su categoría más grande. Es la configuración que reordena cajas, parte palabras, tapa botones y hace desaparecer contenido bajo el borde inferior, y es además la que usan de verdad muchas personas. Un rojo ahí suele ser un defecto real; un rojo en tema oscuro suele ser un color mal declarado, que también importa pero rompe menos.
Determinismo y mantenimiento
Comparar imágenes solo es viable si dos ejecuciones producen exactamente los mismos píxeles, y por defecto no lo hacen. Las fuentes del sistema cambian entre versiones, la escala del dispositivo altera el submuestreo, el reloj se cuela en cualquier fecha formateada y el suavizado de texto difiere entre generaciones de simulador. Fijar todo eso no es un detalle de configuración: es la condición de posibilidad de la técnica.
flowchart TB v[Variables que mueven los pixeles] --> d[Dispositivo y escala] v --> o[Version del sistema operativo] v --> f[Tipografia y tipo dinamico] v --> t[Tema claro u oscuro] v --> i[Idioma y direccion de lectura] v --> a[Datos con fecha hora o azar] d --> c[Fijar un simulador de referencia unico] o --> c f --> c t --> p[Declarar la variante como parametro de la prueba] i --> p a --> s[Sembrar datos fijos sin reloj ni aleatoriedad] c --> r[Comparacion reproducible] p --> r s --> r
El corolario operativo es que las instantáneas deben grabarse y compararse en un único simulador de referencia, declarado en el fichero de configuración del proyecto y usado también por la integración continua. Grabar en una máquina y comparar en otra con distinta versión del sistema produce diferencias de un píxel en el suavizado que no significan nada y que enseñan al equipo a ignorar los rojos. Cuando esa uniformidad no es alcanzable, la salida es bajar la exigencia de la comparación con un umbral de precisión perceptual, aceptando que a cambio se pierden las regresiones de un solo píxel.
// Tolerancia explicita: util entre versiones del simulador, peligrosa como norma.
assertSnapshot(
of: FichaTarea(tarea: .ejemploCorto),
as: .image(precision: 0.99, perceptualPrecision: 0.98, layout: .sizeThatFits)
)
Bajar la precisión es un préstamo, no una solución: cada punto que se afloja amplía el conjunto de cambios reales que la prueba dejará pasar en silencio, y ese conjunto incluye cosas que importan, como un desplazamiento de un punto en un separador o un color con la opacidad equivocada. Antes de tocar el umbral conviene agotar la vía correcta, que es igualar los entornos; y si aun así hay que aflojarlo, el valor pertenece a la configuración del proyecto y no a la prueba, para que sea una decisión visible y revisable.
El mantenimiento tiene dos reglas y ninguna es opcional. La primera: la regrabación es una decisión, no un atajo. Un modo que regraba todo lo que falla convierte la suite en un espejo que siempre está de acuerdo contigo; el modo correcto graba únicamente lo que falta y obliga a regrabar explícitamente lo que cambió. La segunda: las imágenes nuevas se revisan como se revisa el código, mirando la diferencia. Un cambio que toca ciento veinte referencias y llega sin explicación no es un cambio revisable, y suele esconder dos modificaciones distintas que deberían haber viajado por separado.
Queda una cuestión que solo aparece con el tiempo: el peso. Mil imágenes de referencia ocupan espacio, y como el historial guarda cada versión de cada una, el repositorio crece de forma que a los dos años ralentiza cada clonación. Las medidas que lo contienen son mantener las referencias en tamaño de punto y no en la escala máxima, preferir instantáneas de componentes pequeños frente a pantallas completas, borrar sin sentimentalismo las de vistas que ya no existen y, si el volumen lo justifica, mover el directorio a almacenamiento de ficheros grandes. Y una última disciplina: nunca guardar en el repositorio las imágenes fallidas que la herramienta genera al comparar, porque son artefactos de diagnóstico y no referencias.
// Politica por defecto: graba solo lo que no existe, nunca lo que difiere.
withSnapshotTesting(record: .missing) {
assertSnapshot(of: FichaTarea(tarea: .ejemploLargo), as: .image)
}
Conviene entender qué clase de objeto epistemológico es una prueba de instantánea, porque no se parece a ninguna otra de la suite. Todas las demás pruebas contienen una afirmación escrita por una persona: este total vale doscientos setenta, este estado es de fallo, esta referencia debe ser nula. La instantánea no contiene ninguna afirmación; contiene un registro y una promesa de que el registro no cambiará. Eso tiene una consecuencia que decide el éxito o el fracaso de la técnica: el trabajo intelectual no ocurre cuando se escribe la prueba, sino cuando alguien mira una diferencia y decide si aprobarla. Si esa mirada existe, la instantánea es la prueba más eficiente que puedes escribir, porque cubre con tres líneas una superficie que llevaría cien aserciones describir y captura precisamente los defectos que nadie sabe anticipar, los no locales, los que emergen de la interacción entre un cambio pequeño y una pantalla lejana. Si esa mirada no existe —si el equipo regraba en bloque cuando algo se pone rojo—, la técnica no queda neutralizada sino que se vuelve activamente dañina: consume tiempo de ejecución, ocupa el repositorio, produce ruido en cada revisión y, lo peor, transmite una sensación de cobertura que impide escribir las pruebas que sí afirmarían algo. De ahí la conclusión práctica, que es más de proceso que de código: antes de añadir la primera instantánea al proyecto hay que pactar quién revisa las diferencias y bajo qué criterio se aprueban. Sin ese pacto, no estás añadiendo una red de seguridad; estás añadiendo un archivo de imágenes que envejece.
- Elige un componente reutilizable tuyo y enumera por escrito todos sus estados de datos, incluidos tres degenerados que hoy nadie prueba.
- Escribe una prueba parametrizada que genere una instantánea por estado y revisa las imágenes una a una antes de aceptarlas.
- Añade la variante de tipo dinámico en su categoría mayor y arregla el primer defecto que aparezca.
- Fija un simulador de referencia en la configuración del proyecto y comprueba que la integración continua usa exactamente ese.
- Introduce a propósito un cambio de dos puntos en el espaciado de un componente compartido y comprueba cuántas instantáneas de otras pantallas se ponen rojas.