90 кадров: шесть языков (en, ru, es, ja, zh-Hans, ar) на два набора — сайт и App Store — в двух темах каждый. Съёмочный лист MeteoHealth руками — это дни. И не только по времени: человек забывает провалиться в шторку «Сон» именно после того, как «Аналитика» отрисует связи, а не до.
Я собрал харнесс на XCUITest — четыре тестовых класса, которые вместе закрывают 86 кадров из 90 без единого нажатия человека. Оставшиеся четыре — кадры пульса с камеры: в симуляторе камеры нет физически. Ниже — как устроена фабрика и что она нашла, помимо самих скриншотов.
Сеялка за launch-аргументом#
Гонять реальный профиль пользователя нельзя: у него нет истории на 30 дней вглубь, нет связей между показателями, а главное — он у каждого свой.
Демо-профиль ScreenshotSeederSite живёт целиком за launch-аргументом MH_SCREENSHOT_SEED и в обычной сборке не делает ровным счётом ничего — это no-op, код, который физически не выполняется без явного аргумента запуска. Сеялка пишет цикл, курение, лекарства с историей приёмов, воду, историю энергии и стресса — и отдельно, за вторым аргументом MH_SCREENSHOT_SEED_HEALTH, 30 дней HealthKit: сон с фазами, ВСР, пульс покоя, шаги.
Разделение неслучайное: экраны «Сон» и «Стресс» читают HealthKit напрямую, своей копии в Core Data у них нет. Без него они на кадре пустые — это узнаёшь не из документации, а из первого прогона с чёрными карточками.
func test_0_prepareDemoProfile() {
let app = XCUIApplication()
app.launchArguments += [
"SKIP_ONBOARDING",
"MH_SCREENSHOT_SEED",
"MH_SCREENSHOT_SEED_HEALTH", // только здесь: см. ScreenshotSeederSite
"-AppleLanguages", "(en)",
"-AppleLocale", "en_US",
"-\(themeDefaultsKey)", Theme.light.rawValue
]
app.launch()
acceptAllHealthSheets(app)
sleep(25) // запись сида в Core Data и HealthKit
...
}Единственный тест во всём наборе, который видит системный диалог доступа к «Здоровью», — этот, и он идёт первым по имени (test_0_). Дальше разрешение уже выдано, повторный сид пропускается по проверке непустой базы, и остальные 340 строк класса этот диалог больше не встречают.
Маршруты вместо поиска по подписям#
Кадры 6–13 — шторки деталей на «Сегодня» (сон, стресс, цикл, питание, лекарства, курение) плюс разделы Аналитики. Открыть «Сегодня» и найти карточку тапом по подписи не выйдет: карточки сортируются динамически, и порядок отличается от языка к языку. Тест, ищущий «Сон» по позиции на английском, на японском кадре откроет что-то другое.
Решение — обойти поиск по UI вообще. Экран открывается сразу launch-аргументом:
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) // шторка открывается из onAppear, содержимое догружается
shoot(name)
app.terminate()
}Раздел Аналитики устроен так же, но со своим аргументом MH_SCREENSHOT_ANALYTICS. У кадра «Прогноз» есть тонкость: перед съёмкой тест сначала на 14 секунд заходит в «Аналитику» и только потом переключается на «Прогноз».
Причина не в суеверии — связи считаются один раз, при открытии Аналитики (CorrelationEngine.correlations живёт в памяти движка), а карточка риска на «Прогнозе» читает уже готовый результат. Без этого захода честно отрисуется «учусь твоему ритму», сколько бы истории ни лежало в базе.
SpringBoard — враждебная территория#
Кадр 5 — виджеты на домашнем экране — единственная часть набора, которая живёт не в приложении, а в SpringBoard, и это меняет всё. SiteWidgetsUITests на 462 строки — самый длинный файл фабрики, и большая часть объёма — защита от того, что SpringBoard отвечает не так, как ожидаешь.
Три ловушки, каждая нашлась не в документации, а по факту сорванного набора кадров.
Первая — springboard.icons возвращает пустой список сразу после перезагрузки симулятора с новым языком, хотя виджеты физически на экране есть: SpringBoard ещё пересобирает домашний экран, а запрос уже отдаёт ответ. Лечится не таймаутом, а повтором:
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
}Комментарий в коде честно называет цену первой версии: тест поверил пустому ответу, пошёл удалять и заново ставить уже стоявшие виджеты и застрял на подтверждении удаления — а его как раз держит вторая ловушка.
Вторая — кнопки системных алертов регулярно отдают isHittable == false, и обычный tap() по ним молча ничего не делает: не падает, не сообщает об ошибке, просто цикл крутится до таймаута. Это тот же механизм, из-за которого сорвался набор widgets-es: алерт удаления остался висеть, а весь прогон бился об него. Обход везде одинаковый — тап координатой центра элемента:
func tapCenter(_ element: XCUIElement) {
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}Третья — адресация вообще без подписей. Кнопка «+», открывающая меню редактирования домашнего экрана, называется «+» по-русски и «Editar» по-испански — на системном языке, который меняется на каждом кадре. Рабочий путь только координатный, привязанный к геометрии экрана, а не к тексту.
RTL и iPad: где зеркалится даже порядок страниц#
Арабский добавляет зеркалирование. Карусель вариантов виджета в конструкторе SpringBoard свайпалась только справа налево, а в RTL-интерфейсе это направление означает «назад». Восемь попыток подряд возвращали один и тот же уже добавленный вариант, функция честно отвечала «не нашлось», и набор widgets-ar вышел с одним виджетом вместо двух. Правка — перебирать оба направления:
let directions: [(from: CGFloat, to: CGFloat)] = [(0.85, 0.15), (0.15, 0.85)]
for direction in directions {
for _ in 0 ..< 8 {
// ...
}
}То же касается страниц домашнего экрана: после смены языка неизвестно, свайп в какую сторону приведёт к виджетам, поэтому съёмка перебирает оба направления, а не угадывает одно.
iPad добавляет измерение не языка, а раскладки. Там таб-бар стоит сверху, а не снизу, и координатный фолбэк для нижней панели iPhone открывал не тот таб — например, карточку энергии вместо нужного экрана. Решение изящнее координат: нативный TabView экспонирует кнопки табов с идентификатором, равным имени SF Symbol из tabItem — малоизвестный, но надёжный факт, работающий на обеих раскладках:
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")
]Координаты остались только фолбэком для iPhone. Прокрутка туда же: swipeUp() таймаутил на тяжёлом дашборде Аналитики на iPad — требует снапшота всей иерархии; координатный драг снапшот не строит. Файлы двух устройств расходятся по префиксу iPhone_/iPad_ по UIDevice.current.userInterfaceIdiom.
На этом ловушки самой съёмки заканчиваются. Труднее оказался вопрос не «как снять кадр», а «что на нём должно быть показано».
Демо-данные должны пройти статистику#
Самая нетривиальная находка фабрики — демо-данные тоже код, и код с обязательствами. Экран «Прогноз» должен показывать содержательный вывод о связи давления и самочувствия, а не «учусь твоему ритму». Первая версия сеялки писала связь как ступеньку: «упало → 2…4, иначе 6…9».
На 30 днях это даёт r ≈ 0.45, сырое p ≈ 0.012 — вроде бы значимо. Но движок прогоняет поправку Бенджамини-Хохберга по всем проверенным парам факторов, а их около сотни, и после поправки p-value тонет. Экран честно помечал собственный вывод как предварительный — прямо в кадре для App Store.
Правка заменила ступеньку линейной зависимостью: r ≈ 0.72, t = 5.5, разброс индивидуальных оценок остаётся полным — от 1 до 10. Такая связь проходит поправку и даёт уверенный вердикт.
Второй случай той же природы — обратный: связь появилась там, где её не заказывали. Сеяная история энергии совпала по фазе с температурной волной, и «Корреляции» открывались строкой «Температура → Энергия +0.96» — коэффициентом, которого на живых данных не бывает. Историю энергии развязали с температурой отдельной волной, чтобы первой шла ожидаемая связь — «Перепад давления → Самочувствие +0.72».
Мораль для любой фабрики демо-данных: если приложение показывает статистический вывод, демо-профиль обязан порождать данные, честно проходящие ту же статистику, что и данные живого пользователя. Но статистика — не единственное, что честный кадр выдаёт с потрохами.
Кадры находят то, что не находят тесты#
В проекте больше 900 зелёных тестов, и ни один не поймал восемь реальных дефектов, которые нашлись при обычном просмотре готовых кадров. Тесты проверяют, что код делает то, что задумано; кадры показывают, что видит пользователь — и это не всегда одно и то же.
Среди находок — симптом в списке Дневника рендерился сырым ключом symptom.jointPain вместо локализованного «Joint Pain»: детальный экран локализацию делал давно, а строка списка склеивала массив как есть. Курс лекарств, начатый сегодня, показывал 0% приверженности — разница дат «как есть» давала ноль полных суток, и этот ноль обнулял процент в знаменателе. На японском и китайском виджете сводки висела строка «Good Sleep» вместо переведённого названия фактора — в снимок клалось английское factor.name, тогда как экраны рендерят его через localizedName.
Ни один из этих багов не относится к логике съёмки — все они в продукте. Заметить их удалось только потому, что кадры существуют на всех шести языках сразу, а не потому, что кто-то написал assert на конкретную строку.
Зачем это всё, кроме экономии времени#
Автоматическая съёмка окупается не только временем, хотя дни ручной работы, сведённые к прогону тестового таргета, — весомый аргумент сам по себе. Она заставляет посмотреть на приложение глазами пользователя сразу на всех языках, в обеих темах, на обеих раскладках устройств — угол зрения, который тесты на логику структурно не покрывают. Launch-аргументы вместо поиска по UI убирают зависимость от порядка элементов на экране. Координатные тапы и оба направления свайпа — обязательный минимум для SpringBoard и RTL, где подписи не читаются, а элементы лгут про isHittable. И отдельный урок для приложения со статистикой внутри: демо-данные — код продукта, и они обязаны проходить ту же проверку значимости, что и данные настоящего человека.



