Перейти к содержимому

Golden-тесты во Flutter: как мы проверяем адаптивный дизайн ASO.dev

Как ASO.dev проверяет адаптивный Flutter-интерфейс с помощью ff_golden и публикует тысячи эталонных PNG через ff_golden_presenter.

Интерфейс приложения на телефоне, планшете и десктопе для golden-тестированияИнтерфейс приложения на телефоне, планшете и десктопе для golden-тестирования

ASO.dev — Flutter-приложение для iOS, Android, macOS, Windows и Linux. Общая кодовая база помогает выпускать продукт сразу на нескольких платформах, но сама по себе не делает интерфейс адаптивным. Экран, который хорошо выглядит на большом мониторе, может не поместиться на маленьком телефоне. Светлая тема может быть аккуратной, а в тёмной — пропасть граница. Перевод одной кнопки может превратить строку в две и сломать всю панель.

Проверять такие комбинации вручную перед каждым релизом нереалистично. Поэтому одна из основ нашего подхода к качеству интерфейса — golden-тесты.

Golden-тест запускает Flutter-виджет в контролируемом окружении, рендерит его и сравнивает получившееся изображение с эталонным PNG из репозитория. В Flutter для этого используется matchesGoldenFile: стандартный локальный comparator декодирует PNG и выполняет попиксельное сравнение.

У теста есть три результата:

  1. Изображение совпало с эталоном — визуальной регрессии нет.
  2. Изображение отличается — тест падает и сохраняет материалы для анализа.
  3. Изменение было намеренным — разработчик проверяет diff и только после этого обновляет эталон.

Последний пункт принципиален. Команда flutter test --update-goldens не исправляет тест. Она лишь объявляет текущий результат новым правильным состоянием. Если обновлять изображения вслепую, golden-тесты быстро превращаются в дорогую формальность.

От опыта с 2022 года к публичным пакетам

Section titled “От опыта с 2022 года к публичным пакетам”

Я использую golden-тесты во Flutter с 2022 года, а в ASO.dev они появились с самой первой версии приложения. За эти годы тестовая инфраструктура развивалась вместе с продуктом: мы учились стабильно проверять разные размеры экранов, темы и состояния данных, поддерживать тысячи эталонов и удобно просматривать изменения.

Весь этот практический опыт мы оформили в два публичных пакета, первые стабильные версии которых выпустили 29 августа 2026 года:

ПакетРоль
ff_golden 1.0.0Запуск сценариев, матрица вариантов, захват и сравнение изображений
ff_golden_presenter 1.0.0Сбор копий, оптимизация, HTML-отчёт и публикация

Оба инструмента подключаются как локальные dev_dependencies, поэтому их версии фиксируются вместе с приложением и одинаково разрешаются у разработчиков и в CI:

Terminal window
flutter pub add --dev 'ff_golden:^1.0.0'
flutter pub add --dev 'ff_golden_presenter:^1.0.0'

ff_golden моделирует не только размер окна. Вариант теста может включать устройство и его devicePixelRatio, safe area, платформу, тему, локаль, масштаб текста, направление письма, яркость и high contrast. Для больших матриц доступны стратегии full, smoke, pairwise и приоритетная выборка с жёстким лимитом комбинаций. Состояние можно менять внутри сценария и снимать несколько именованных моментов, а ограниченное виртуальное ожидание помогает не зависать на бесконечных анимациях.

Строгий режим остаётся основным: попиксельное сравнение, обнаружение RenderFlex overflow, конфликтов имён и устаревших эталонов. Осознанный локальный tolerance возможен, но он не должен маскировать необъяснённую разницу рендера. Для CI пакет также умеет сохранять JSON-описание запланированных вариантов, результатов и failure-артефактов.

Один эталон для одного большого окна дал бы ложное чувство безопасности. Мы сделали общий тестовый harness, который прогоняет один сценарий по матрице устройств, локалей и тем.

В основную матрицу входят:

  • iPhone 5S как один из самых узких поддерживаемых экранов;
  • iPhone 11 как более современный телефон;
  • планшет в портретной и альбомной ориентации;
  • десктопное окно Full HD;
  • macOS Retina;
  • светлая и тёмная темы.

Основная локаль большинства сценариев — английская. Там, где длина текста или направление интерфейса особенно важны, мы добавляем отдельные локализованные варианты, в том числе русский. Для error-state обычно достаточно сокращённой матрицы из телефона и десктопа, а наиболее важные загруженные экраны проходят полный набор.

Упрощённо регистрация теста выглядит так:

import 'package:ff_golden/ff_golden.dart';
testDeviceGoldens(
'loaded page',
(tester, device, locale, theme) => golden.builder(
tester,
device,
locale,
theme,
scenarioName: 'loaded',
scenario: (_) async => golden.waitUntilReady(),
),
devices: GoldenTestDevices.bundle,
locales: GoldenTestDevices.locales,
themes: GoldenTestDevices.themes,
);

ff_golden перебирает комбинации и формирует отдельные Flutter-тесты и PNG с понятными именами. Обвязка ASO.dev задаёт поддерживаемые bundles, корневой widget приложения и подготовку конкретного состояния. Поэтому добавление нового сценария автоматически даёт не одну проверку, а целую визуальную матрицу.

Мы тестируем состояния, а не только страницы

Section titled “Мы тестируем состояния, а не только страницы”

Красивый loaded-экран — только часть интерфейса. В реальном продукте пользователь видит намного больше состояний:

  • начальную загрузку;
  • пустые данные;
  • ошибку провайдера;
  • ограничение по подписке или правам доступа;
  • открытый диалог, dropdown или контекстное меню;
  • таблицу с минимальным и максимальным набором колонок;
  • выбранные фильтры, длинные значения и массовые действия.

Для каждого важного состояния мы готовим фиксированные данные и отдельный golden-сценарий. Это позволяет поймать не только очевидную перестройку экрана, но и, например, кнопку, которая исчезла под таблицей только в error-state.

Моки данных делают такую проверку удобной и воспроизводимой. Вместо ожидания реальных API тестовые провайдеры сразу возвращают нужный ответ. Базовый контракт каждого экрана включает состояние загрузки и несколько основных сценариев данных: например, пустой результат, обычный набор записей, граничные значения или ограничение доступа. Один и тот же fixture затем проверяется на нужных размерах экрана, темах и локалях.

Сама необходимость писать моки помогает улучшать production-код. Чтобы зависимость можно было заменить в тесте, работу с API, хранилищем и другими внешними системами приходится отделять от UI через явные контракты. Состояния экрана становятся предсказуемыми, побочные эффекты — контролируемыми, а крупные компоненты сложнее незаметно связать с сетью или глобальным состоянием. В результате код проще тестировать, переиспользовать и безопасно изменять.

Для общего экрана ошибок матрица ещё шире. Мы стараемся воспроизводить почти каждый тип ошибки, с которым сталкиваемся в реальной работе: ответы App Store Connect и Google API, ошибки AI-провайдеров, сети и SSL, platform services и backend ASO.dev. Часть сценариев основана на реальных инцидентах из Sentry и сохранена как фиксированные JSON-fixtures. Golden-тест проверяет не только то, что исключение обработано, но и то, что пользователь увидит понятные заголовок, описание и действие, а длинное сообщение не сломает layout.

Детерминированность важнее количества скриншотов

Section titled “Детерминированность важнее количества скриншотов”

Golden-тест полезен только тогда, когда одинаковый код стабильно создаёт одинаковое изображение. Иначе команда перестаёт доверять падениям.

Поэтому в тестовом окружении мы:

  • загружаем те же шрифты, которые использует приложение;
  • подменяем сеть, аналитику, push, авторизацию и другие внешние зависимости;
  • используем фиксированные даты и подготовленные ответы провайдеров;
  • дожидаемся конкретного состояния экрана, а не случайной паузы;
  • после готовности выполняем ограниченное число кадров для завершения анимаций;
  • запускаем проверки на зафиксированной версии Flutter и одинаковом окружении.

Особенно важен момент готовности. Бесконечный pumpAndSettle() может зависнуть из-за фоновой анимации, а фиксированная задержка — сделать тест медленным и нестабильным. В сложных экранах мы ждём наблюдаемый признак: данные загружены, таблица создана, нужный action появился. После этого даём интерфейсу несколько кадров на стабилизацию и делаем снимок.

Как выглядит рабочий процесс

Section titled “Как выглядит рабочий процесс”

При изменении интерфейса мы придерживаемся простой последовательности:

  1. Запускаем точный golden-сценарий, который относится к изменённому экрану.
  2. Если тест упал, смотрим эталон, новый рендер и изолированный diff.
  3. Определяем причину: ожидаемое изменение, реальная регрессия или нестабильное тестовое окружение.
  4. Исправляем код либо обновляем только те PNG, которые действительно должны измениться.
  5. Повторно прогоняем затронутую матрицу, а затем более широкий набор тестов.

В GitLab CI Flutter-тесты запускаются автоматически. При расхождении pipeline собирает failure-изображения, masterImage, testImage и isolatedDiff в отдельный артефакт. Благодаря этому разницу можно разобрать, даже если тест выполнялся не на компьютере разработчика.

Локальный review через ff_golden_presenter diff

Section titled “Локальный review через ff_golden_presenter diff”

До этого для сравнения изменившихся golden-скриншотов мы использовали Git-клиент. Во время подготовки этой статьи я понял, что этот процесс можно сделать гораздо удобнее и объединить разрозненные действия в одном инструменте. Так в ff_golden_presenter 1.1.0 появилась команда diff:

Terminal window
fvm dart run ff_golden_presenter diff

Она запускает доступный только через 127.0.0.1 локальный сервер с браузерным UI для review изменённых изображений. В нём можно:

  • просматривать все изменённые golden-файлы и переходить между ними;
  • сравнивать эталон и рабочую версию рядом, подсвечивать изменившиеся пиксели и регулировать интенсивность подсветки;
  • синхронно приближать и перемещать обе версии изображения, чтобы изучать одну и ту же область;
  • добавлять файлы в Git index и убирать их оттуда, подготавливая изменения к фиксации;
  • запускать golden-тесты, просматривать их логи и отдельно копировать полный лог или ошибки;
  • открывать связанные тестовые Dart-файлы прямо из интерфейса.
Локальный интерфейс ff_golden_presenter diff со списком изменённых golden-файлов и подсветкой различий между эталоном и рабочим изображением

Так review остаётся локальным и работает с реальным состоянием Git-репозитория, но для основных действий больше не приходится переключаться между Git-клиентом, терминалом, редактором и отдельным просмотрщиком изображений.

Почему при разработке с ИИ мы начинаем с golden-теста, а не с Computer Use

Section titled “Почему при разработке с ИИ мы начинаем с golden-теста, а не с Computer Use”

ИИ-агент, получив задачу про интерфейс, часто пытается пойти самым наглядным путём: собрать приложение, запустить его и управлять им через Computer Use — переходить между экранами, менять размер окна и делать скриншоты. Для полноценного end-to-end сценария или проверки нативного поведения это полезный инструмент. Но для точечной визуальной задачи такой путь обычно слишком широкий.

Чтобы воспроизвести один дефект через запущенное приложение, агенту может понадобиться:

  • дождаться сборки и запуска нужной платформы;
  • пройти авторизацию и навигацию;
  • подготовить данные или дождаться ответа внешнего сервиса;
  • вручную привести окно к нужному размеру;
  • открыть точное состояние экрана;
  • визуально сравнить новый скриншот с ожидаемым результатом.

Каждый шаг добавляет время и новую переменную. Результат зависит от состояния аккаунта, сети, размера окна, данных и момента создания скриншота. Повторить такую проверку в точности сложнее, а изображения и длинная последовательность действий увеличивают стоимость работы ИИ-агента.

Точечный golden-тест начинает сразу с нужного состояния:

Computer UseGolden-тест под конкретный кейс
Запускает всё приложениеРендерит только нужный экран или компонент
Зависит от навигации, аккаунта и данныхИспользует фиксированные fixtures и DI overrides
Размер окна и момент снимка нужно воспроизвести вручнуюУстройство, devicePixelRatio, тема и локаль заданы в тесте
Разницу оценивает агент или человекFlutter строит воспроизводимый попиксельный diff
Сценарий трудно повторить без тех же действийТот же тест запускается локально и в CI

Поэтому в ASO.dev мы направляем ИИ-агента сначала в реальный widget и state path, затем — в именованный golden-сценарий, который воспроизводит конкретную проблему. Например, если на узком экране таблица переполняется на один пиксель, нам не нужно запускать приложение и вручную собирать это состояние. Агент меняет минимальный участок layout, запускает один сценарий на нужном устройстве и проверяет PNG. После этого можно прогнать всю матрицу, чтобы убедиться, что исправление не сломало другие размеры и темы.

Такой цикл обычно быстрее, экономнее и, главное, повторяем. Он оставляет проверяемый артефакт в репозитории и может выполняться снова после любого будущего изменения. Computer Use остаётся следующим инструментом, когда вопрос нельзя выразить через widget-тест: например, для нативного диалога, platform channel, поведения окна, системного drag-and-drop или полного пользовательского пути.

Как мы показываем все golden-экраны

Section titled “Как мы показываем все golden-экраны”

Локальный diff закрывает ежедневный review изменённых эталонов: позволяет сравнить изображения до и после, понять суть изменений и решить, что нужно поправить в интерфейсе, а где — осознанно обновить эталон.

Другим участникам команды бывает удобнее посмотреть, что уже есть в приложении в целом: какие экраны и состояния реализованы, как они выглядят на разных устройствах и в разных темах. Для такого обзора мы собираем галерею с помощью ff_golden_presenter.

В ASO.dev это скорее публичная витрина интерфейса, чем повседневный инструмент работы разработчика. Посмотреть её можно на golden.aso.dev.

Публикация demo устроена так:

test/screens/**/*.png → ff_golden_presenter build → goldens/index.html + оптимизированные копии → nginx → golden.aso.dev

Раньше проектный shell-скрипт сам искал PNG, копировал каталоги, проверял наличие pngquant, сжимал файлы и вызывал глобально установленный presenter. Теперь весь локальный pipeline выражен одной проектной командой:

Terminal window
fvm dart run ff_golden_presenter build \
--input test/screens \
--output-directory goldens \
--report-file index.html \
--profile balanced \
--clean \
--title "ASO.dev Golden Tests"

В примере выше build копирует эталоны из test/screens в отдельный каталог goldens и оптимизирует только копии. Исходные PNG, с которыми сравниваются тесты, остаются нетронутыми.

Профиль balanced использует pngquant, а при его отсутствии может переключиться на ImageMagick. Перед сборкой галереи можно проверить, установлен ли подходящий инструмент:

Terminal window
fvm dart run ff_golden_presenter doctor --profile balanced

Если сжатие не нужно, выбираем профиль none. Галерея по-прежнему собирается, но копии изображений остаются побайтово идентичными исходникам, а внешние оптимизаторы не требуются:

Terminal window
fvm dart run ff_golden_presenter build \
--input test/screens \
--output-directory goldens \
--profile none \
--clean

После упавших тестов в каталогах failures остаются диагностические изображения сравнения. Для их очистки есть отдельная команда clean-failures. Сначала смотрим, какие файлы она удалит, ничего не меняя на диске:

Terminal window
fvm dart run ff_golden_presenter clean-failures \
--input test/screens \
--dry-run

Если список верный, повторяем команду без --dry-run:

Terminal window
fvm dart run ff_golden_presenter clean-failures --input test/screens

По умолчанию удаляются только PNG внутри каталогов с именем failures. Эталоны вне этих каталогов и прочие диагностические файлы сохраняются.

Получившийся HTML не требует runtime-зависимостей. В нём есть поиск, фильтры по вариантам, навигация по сценариям, светлая и тёмная темы, адаптивные карточки и lightbox с клавиатурным управлением. Если ff_golden сохранил JSON-манифесты, presenter добавляет точные данные о capture, устройстве, теме, локали, масштабе текста, статусе, длительности и ошибке. Затем GitLab CI упаковывает готовый каталог в nginx-образ, а отдельная deployment job публикует его на golden.aso.dev.

Публикация может быстро расходовать трафик

Section titled “Публикация может быстро расходовать трафик”

Golden-галерея состоит из статических файлов, но это не означает, что её раздача бесплатна. Даже после оптимизации большой набор экранов может занимать сотни мегабайт.

Для масштаба: на 31 августа 2026 года наш каталог test/screens, из которого собирается галерея ASO.dev, содержит 1 996 PNG общим объёмом около 357 МБ. Это исходные эталоны до оптимизации, а не размер одной загрузки страницы. Размер опубликованных копий зависит от выбранного профиля оптимизации, а трафик — от того, сколько изображений посетители действительно загрузят.

Например, Firebase Hosting предоставляет без оплаты до 10 ГБ хранения и 10 ГБ передачи данных в месяц на проект. В трафик входят как cache misses, так и ответы из CDN-кэша, а файлы сохранённых releases учитываются в Hosting storage. Поэтому несколько активных просмотров большого отчёта или частые публикации могут быстро приблизить проект к бесплатным лимитам. На тарифе Spark после короткого льготного периода превышение трафика приводит к отключению сайтов до начала следующего месяца.

Firebase подходит для небольшого примера или закрытого отчёта, который открывают редко, но за storage, transfer и количеством сохранённых releases нужно следить. Для большого каталога мы выбрали статическую раздачу из собственной инфраструктуры: CI собирает goldens/, упаковывает каталог в Docker-образ с nginx и разворачивает его как golden.aso.dev. Docker не устраняет сетевой трафик, зато он не расходует квоту Firebase и позволяет нам самим управлять хранением, кэшированием и доступом.

Здесь важно разделять роли: ff_golden запускает сценарии и выполняет сравнение эталонов. ff_golden_presenter предоставляет UI для локального review и собирает каталог для обзора и публикации интерфейса. Команда diff может запускать проектные тесты, но не заменяет сам механизм golden-сравнения.

Какие ошибки мы находим

Section titled “Какие ошибки мы находим”

Golden-тесты особенно хорошо ловят небольшие, но дорогие для продукта дефекты:

  • RenderFlex overflow на один или несколько пикселей;
  • обрезанный текст и неправильный перенос;
  • сломанную адаптивную точку перехода между mobile и desktop layout;
  • исчезнувшую кнопку или колонку;
  • неверные отступы после переиспользования общего компонента;
  • различия светлой и тёмной тем;
  • ошибки в portrait/landscape;
  • случайное изменение шрифта, иконки или плотности таблицы.

Человек легко пропускает сдвиг на один пиксель на знакомом экране. Попиксельное сравнение — нет. При этом тест показывает проблему сразу на том размере, где она возникла.

Чего golden-тесты не гарантируют

Section titled “Чего golden-тесты не гарантируют”

Golden-тест — это widget-тест в управляемой среде, а не фотография каждого физического устройства. Он проверяет общий Flutter render path при заданных размерах, теме и локали, но не заменяет:

  • unit-тесты бизнес-логики;
  • widget-тесты интеракций и доступности;
  • integration-тесты полных сценариев;
  • проверки нативных API и platform channels;
  • профилирование производительности;
  • ручную проверку ключевого релизного сценария на реальном устройстве.

Мы относимся к golden-тестам как к одному слою стратегии качества. Это соответствует и рекомендации Flutter: основа — большое число unit- и widget-тестов, дополненное интеграционными тестами важных пользовательских путей.

Цена подхода: репозиторий растёт

Section titled “Цена подхода: репозиторий растёт”

Главный недостаток проявился не сразу. PNG — бинарный формат. Когда эталон меняется, Git не может хранить визуальный diff так же эффективно, как несколько изменённых строк Dart-кода: в истории появляется новый бинарный объект.

На 30 августа 2026 года в нашем рабочем дереве находится:

  • 2031 тестовый PNG;
  • около 348 МБ изображений в их текущих версиях;
  • около 3,6 ГБ в локальной папке .git всего приложения.

Не весь объём .git создан golden-тестами: в большом кроссплатформенном приложении хватает и других бинарных ресурсов. Но тысячи скриншотов и их прошлые версии — заметная часть роста.

Примерно через три года разработки нам пришлось перенести активную разработку в новый GitLab-репозиторий, чтобы остаться на бесплатном тарифе. Такая миграция возвращает рабочее пространство, но сама по себе не является долгосрочной архитектурой хранения. Нужно перенести или связать историю, проверить CI/CD, права, переменные, интеграции и локальные remote у всей команды. Мы сделали это, потому что на том этапе отдельная миграция была проще, чем перестройка уже работающего golden-контура.

Где лучше хранить эталоны

Section titled “Где лучше хранить эталоны”

У каждого варианта свой компромисс.

ПодходПлюсыМинусы
PNG в основном репозиторииСамый простой checkout, один commit и удобный reviewРастут clone/fetch и история репозитория
Git LFS в том же проектеОсновной Git хранит маленькие pointer-файлы, бинарники скачиваются отдельноНужен LFS-клиент; repository и LFS учитываются в общей квоте проекта GitLab
Отдельный репозиторий как submoduleРаздельные истории и квоты, основной репозиторий хранит точный commit эталоновДва репозитория, дополнительная авторизация и настройка CI, изменения надо синхронизировать
Object Storage или CI artifactsКодовый репозиторий почти не растётНужен собственный versioning, политика хранения и интерфейс visual review

Git LFS действительно делает работу с тяжёлыми бинарными файлами эффективнее: вместо PNG Git хранит текстовый pointer. Однако на GitLab объём Git-репозитория и LFS считается вместе в лимите проекта, поэтому LFS улучшает clone/fetch, но не даёт бесплатного бесконечного хранилища.

Подходит ли для этого Git submodule

Section titled “Подходит ли для этого Git submodule”

Да — если цель состоит именно в том, чтобы отделить историю эталонных изображений от истории исходного кода и при этом сохранить строгую связь версий.

Submodule — это отдельный репозиторий внутри рабочего дерева. Основной проект хранит путь, URL и commit, на котором должны находиться скриншоты. Получается полезное свойство: commit приложения всегда указывает на точный набор эталонов, но старые PNG больше не раздувают историю основного репозитория.

Для GitLab Free это также даёт отдельный проект хранения. По состоянию на август 2026 года GitLab.com предоставляет 10 ГиБ на каждый проект бесплатного namespace. При необходимости submodule можно клонировать с ограниченной глубиной, если локально и в CI нужна только актуальная история изображений.

Но submodule не бесплатен с точки зрения процессов:

  • обычный clone не всегда загружает его автоматически;
  • CI должен уметь получить второй приватный репозиторий;
  • сначала нужно закоммитить новые PNG в submodule, затем обновить ссылку в основном проекте;
  • merge request с кодом и visual diff оказывается разделён между двумя проектами;
  • разработчику нужно следить, что локальный submodule стоит на ожидаемом commit.

Поэтому это не абсолютно «самое простое» решение. Хранить PNG рядом с тестом проще каждый день. Но среди вариантов, которые реально отделяют бинарную историю и сохраняют версионирование через Git, отдельный репозиторий с submodule — один из самых прямых и понятных подходов.

Если бы мы проектировали хранение с нуля, мы бы серьёзно рассматривали такую схему с самого начала. Перенести в неё уже существующие тысячи файлов и историю без остановки текущей разработки — отдельная инженерная задача. Пока периодическая миграция оказалась для нас прагматичнее.

Golden-тесты не делают интерфейс хорошим автоматически. Они делают визуальные решения воспроизводимыми: одна и та же страница, состояние, тема, локаль и геометрия экрана должны давать один и тот же результат.

Для кроссплатформенного ASO.dev это стало способом выпускать адаптивный дизайн без ручного просмотра сотен комбинаций. Теперь этот контур доступен другим Flutter-командам через ff_golden, а его visual review и публикация — через ff_golden_presenter. Мы платим за это временем тестов, поддержкой fixtures и ростом хранилища. Пока эта цена ниже стоимости визуальных регрессий, найденных пользователями после релиза.