90 capturas: seis idiomas (en, ru, es, ja, zh-Hans, ar) en dos conjuntos — sitio web y App Store — con dos temas cada uno. Sacar a mano la lista de tomas de MeteoHealth son días de trabajo. Y no solo por el tiempo: una persona se olvida de entrar en la hoja de «Sueño» justo después de que «Analítica» haya dibujado las correlaciones, y no antes.
Monté un harness sobre XCUITest: cuatro clases de test que juntas cubren 86 capturas de 90 sin un solo toque humano. Las cuatro restantes son las tomas de pulso con la cámara: en el simulador la cámara físicamente no existe. A continuación, cómo funciona la fábrica y qué encontró más allá de las propias capturas.
Un sembrador detrás de un launch argument#
Usar el perfil real del usuario es inviable: no tiene historial de 30 días hacia atrás, no tiene correlaciones entre indicadores y, sobre todo, cada usuario tiene el suyo.
El perfil de demostración ScreenshotSeederSite vive por completo detrás del launch argument MH_SCREENSHOT_SEED y en una build normal no hace absolutamente nada: es un no-op, código que físicamente no se ejecuta sin un argumento de lanzamiento explícito. El sembrador escribe el ciclo, el tabaquismo, los medicamentos con historial de tomas, el agua, el historial de energía y estrés — y por separado, detrás de un segundo argumento MH_SCREENSHOT_SEED_HEALTH, 30 días de HealthKit: sueño con fases, VFC, pulso en reposo, pasos.
La separación no es casual: las pantallas «Sueño» y «Estrés» leen HealthKit directamente y no tienen copia propia en Core Data. Sin él salen vacías en la captura — algo que no se aprende de la documentación, sino de la primera pasada con tarjetas en negro.
func test_0_prepareDemoProfile() {
let app = XCUIApplication()
app.launchArguments += [
"SKIP_ONBOARDING",
"MH_SCREENSHOT_SEED",
"MH_SCREENSHOT_SEED_HEALTH", // solo aquí: véase ScreenshotSeederSite
"-AppleLanguages", "(en)",
"-AppleLocale", "en_US",
"-\(themeDefaultsKey)", Theme.light.rawValue
]
app.launch()
acceptAllHealthSheets(app)
sleep(25) // escritura de la semilla en Core Data y HealthKit
...
}El único test de toda la suite que llega a ver el diálogo del sistema de acceso a «Salud» es este, y va primero por nombre (test_0_). Después el permiso ya está concedido, una nueva siembra se omite gracias a la comprobación de base no vacía, y las restantes 340 líneas de la clase no vuelven a encontrarse con ese diálogo.
Rutas en lugar de buscar por etiquetas#
Las capturas 6–13 son las hojas de detalle de «Hoy» (sueño, estrés, ciclo, alimentación, medicamentos, tabaquismo) más las secciones de Analítica. Abrir «Hoy» y encontrar la tarjeta tocando su etiqueta no funciona: las tarjetas se ordenan dinámicamente y el orden cambia de un idioma a otro. Un test que localiza «Sueño» por su posición en inglés abrirá otra cosa en la captura japonesa.
La solución es evitar por completo la búsqueda en la UI. La pantalla se abre directamente con un launch argument:
private func captureTodaySheet(_ route: String, name: String, language: String, locale: String, theme: Theme) {
let app = launch(
language: language, locale: locale, theme: theme,
extraArguments: ["MH_SCREENSHOT_ROUTE", route]
)
sleep(4) // la hoja se abre desde onAppear, el contenido termina de cargarse
shoot(name)
app.terminate()
}La sección de Analítica funciona igual, pero con su propio argumento MH_SCREENSHOT_ANALYTICS. La captura de «Pronóstico» tiene una sutileza: antes de disparar, el test primero pasa 14 segundos dentro de «Analítica» y solo después cambia a «Pronóstico».
La razón no es superstición: las correlaciones se calculan una sola vez, al abrir Analítica (CorrelationEngine.correlations vive en la memoria del motor), y la tarjeta de riesgo de «Pronóstico» lee el resultado ya terminado. Sin esa visita previa, mostrará honestamente «aprendiendo tu ritmo», por mucho historial que haya en la base de datos.
SpringBoard: territorio hostil#
La captura 5 — los widgets en la pantalla de inicio — es la única parte del conjunto que no vive en la app sino en SpringBoard, y eso lo cambia todo. SiteWidgetsUITests, con 462 líneas, es el archivo más largo de la fábrica, y la mayor parte de ese volumen es defensa contra un SpringBoard que responde de forma distinta a la esperada.
Tres trampas, cada una descubierta no en la documentación sino a base de un conjunto de capturas arruinado.
La primera: springboard.icons devuelve una lista vacía justo después de reiniciar el simulador con un idioma nuevo, aunque los widgets estén físicamente en pantalla: SpringBoard aún está reconstruyendo la pantalla de inicio mientras la consulta ya devuelve respuesta. No se cura con un timeout, sino con reintentos:
private func existingWidgetCount() -> Int {
var count = 0
for attempt in 0 ..< 6 {
count = springboard.icons
.matching(NSPredicate(format: "identifier == %@", "MeteoHealth"))
.allElementsBoundByIndex
.filter { $0.frame.width > 200 }
.count
if count >= 2 { return count }
if attempt < 5 { sleep(4) }
}
return count
}Un comentario en el código nombra con honestidad el precio de la primera versión: el test se creyó la respuesta vacía, se puso a eliminar y volver a colocar widgets que ya estaban puestos, y se quedó atascado en la confirmación de borrado — que es justo lo que retiene la segunda trampa.
La segunda: los botones de las alertas del sistema devuelven con regularidad isHittable == false, y un tap() normal sobre ellos no hace nada en silencio: no falla, no informa de error, el bucle simplemente gira hasta el timeout. Es el mismo mecanismo que arruinó el conjunto widgets-es: la alerta de borrado se quedó colgada y toda la pasada chocaba contra ella. La solución es la misma en todas partes: tocar la coordenada del centro del elemento:
func tapCenter(_ element: XCUIElement) {
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}La tercera: direccionar sin etiquetas en absoluto. El botón «+» que abre el menú de edición de la pantalla de inicio se llama «+» en ruso y «Editar» en español — en el idioma del sistema, que cambia en cada captura. El único camino que funciona es el de coordenadas, anclado a la geometría de la pantalla y no al texto.
RTL e iPad: donde hasta el orden de las páginas se refleja#
El árabe añade el reflejo especular. El carrusel de variantes del widget en el configurador de SpringBoard solo se deslizaba de derecha a izquierda, y en una interfaz RTL esa dirección significa «atrás». Ocho intentos seguidos devolvían la misma variante ya añadida, la función respondía honestamente «no encontrado», y el conjunto widgets-ar salió con un widget en vez de dos. La corrección: recorrer ambas direcciones:
let directions: [(from: CGFloat, to: CGFloat)] = [(0.85, 0.15), (0.15, 0.85)]
for direction in directions {
for _ in 0 ..< 8 {
// ...
}
}Lo mismo vale para las páginas de la pantalla de inicio: tras cambiar de idioma no se sabe qué dirección de swipe lleva a los widgets, así que la toma prueba ambas direcciones en lugar de adivinar una.
El iPad añade una dimensión no de idioma, sino de disposición. Allí la barra de pestañas está arriba, no abajo, y el fallback por coordenadas pensado para la barra inferior del iPhone abría la pestaña equivocada — por ejemplo, la tarjeta de energía en lugar de la pantalla deseada. La solución es más elegante que las coordenadas: el TabView nativo expone los botones de las pestañas con un identificador igual al nombre del SF Symbol de tabItem — un hecho poco conocido pero fiable, que funciona en ambas disposiciones:
let order: [(symbol: String, id: String, slot: Int, name: String)] = [
("cloud.sun.fill", "tab_forecast", 1, "5_forecast"),
("chart.xyaxis.line", "tab_analytics", 3, "3_analytics"),
("book.fill", "tab_journal", 2, "4_journal")
]Las coordenadas quedaron solo como fallback para iPhone. El desplazamiento siguió el mismo camino: swipeUp() agotaba el timeout en el pesado dashboard de Analítica en iPad — exige un snapshot de toda la jerarquía; un arrastre por coordenadas no construye snapshot. Los archivos de los dos dispositivos se separan por el prefijo iPhone_/iPad_ según UIDevice.current.userInterfaceIdiom.
Aquí terminan las trampas de la propia toma. Lo más difícil resultó no ser «cómo sacar la captura», sino «qué debe verse en ella».
Los datos de demostración deben pasar la estadística#
El hallazgo menos trivial de la fábrica: los datos de demostración también son código, y código con obligaciones. La pantalla «Pronóstico» debe mostrar una conclusión sustancial sobre la relación entre presión y bienestar, y no «aprendiendo tu ritmo». La primera versión del sembrador escribía la relación como un escalón: «cayó → 2…4, si no 6…9».
En 30 días eso da r ≈ 0.45, p bruto ≈ 0.012 — en apariencia significativo. Pero el motor aplica la corrección de Benjamini-Hochberg sobre todos los pares de factores comprobados, que son alrededor de ~100, y tras la corrección el p-value se hunde. La pantalla marcaba honestamente su propia conclusión como preliminar — justo en la captura para el App Store.
La corrección sustituyó el escalón por una dependencia lineal: r ≈ 0.72, t = 5.5, y la dispersión de las valoraciones individuales sigue siendo completa — de 1 a 10. Una relación así supera la corrección y da un veredicto seguro.
El segundo caso es de la misma naturaleza, pero inverso: apareció una correlación donde nadie la había pedido. El historial sembrado de energía coincidió en fase con la onda de temperatura, y «Correlaciones» se abría con la línea «Temperatura → Energía +0.96» — un coeficiente que en datos reales no existe. El historial de energía se desacopló de la temperatura con una onda propia, para que la primera fuera la correlación esperada: «Cambio de presión → Bienestar +0.72».
La moraleja para cualquier fábrica de datos de demostración: si la app muestra una conclusión estadística, el perfil de demostración está obligado a generar datos que pasen honestamente la misma estadística que los datos de un usuario real. Pero la estadística no es lo único que una captura honesta deja al descubierto.
Las capturas encuentran lo que los tests no encuentran#
El proyecto tiene más de 900 tests en verde, y ninguno atrapó los ocho defectos reales que aparecieron con una simple revisión de las capturas terminadas. Los tests comprueban que el código hace lo previsto; las capturas muestran lo que ve el usuario — y no siempre es lo mismo.
Entre los hallazgos: un síntoma en la lista del Diario se renderizaba con la clave cruda symptom.jointPain en lugar del localizado «Joint Pain» — la pantalla de detalle localizaba desde hacía tiempo, pero la fila de la lista pegaba el array tal cual. Un tratamiento de medicación iniciado hoy mostraba un 0% de adherencia — la diferencia de fechas «tal cual» daba cero días completos, y ese cero anulaba el porcentaje en el denominador. En el widget de resumen en japonés y chino colgaba la cadena «Good Sleep» en lugar del nombre traducido del factor — al snapshot se le pasaba el factor.name en inglés, mientras que las pantallas lo renderizan a través de localizedName.
Ninguno de estos bugs pertenece a la lógica de captura — todos están en el producto. Solo pudieron detectarse porque las capturas existen en los seis idiomas a la vez, y no porque alguien escribiera un assert sobre una cadena concreta.
Para qué todo esto, más allá de ahorrar tiempo#
La captura automática no se amortiza solo en tiempo, aunque reducir días de trabajo manual a la ejecución de un target de test ya es un argumento de peso por sí mismo. Obliga a mirar la app con los ojos del usuario en todos los idiomas a la vez, en ambos temas, en ambas disposiciones de dispositivo — un ángulo que los tests de lógica estructuralmente no cubren. Los launch arguments en lugar de la búsqueda en la UI eliminan la dependencia del orden de los elementos en pantalla. Los toques por coordenadas y las dos direcciones de swipe son el mínimo obligatorio para SpringBoard y RTL, donde las etiquetas no se leen y los elementos mienten sobre isHittable. Y una lección aparte para una app con estadística dentro: los datos de demostración son código del producto, y deben pasar la misma prueba de significación que los datos de una persona real.



