
При создании интерфейса важно проверить, как он реально выглядит. Часто это проверяют все участники процесса — от разработчиков до менеджеров. И для автоматизации и упрощения процесса визуального тестирования приложения есть специальный инструмент — golden‑тесты. Это методология тестирования, в которой текущий UI сравнивается с предварительно сгенерированным «золотым» эталоном. Если вы уже слышали про скриншот‑тесты — это примерно то же самое, но есть нюансы.
Меня зовут Даниил Липаткин, я тимлид в команде разработки курьерского приложения Яндекс Доставки. В этой статье:
Мы познакомимся с методологией, рассмотрим её преимущества и недостатки, а также когда её стоит и не стоит применять.
Напишем базовый golden‑тест на примере стандартных инструментов библиотеки
flutter_test
.Рассмотрим пакет alchemist, который решает проблему платформозависимости
flutter_test
.Дадим прикладные рекомендации по применению тестов и настройке IDE и CI.
Что такое golden-тесты
Не пугайтесь необычного названия — на самом деле всё довольно просто. Golden‑тест — это разновидность widget‑тестов, которая сравнивает скриншот виджета с «золотым» эталоном. Такой эталон создаётся тем же тестом и обычно хранится прямо в системе контроля версий — например, в Git.
Сама идея сравнения с «золотым стандартом» встречается во многих сферах. В медицине есть «золотой стандарт» диагностики, в музыке и геймдеве — мастер‑копия, с которой делают все финальные релизы. В экономике золотой стандарт — это когда стоимость валюты напрямую привязана к запасам золота.
А в контексте Flutter всё ещё проще: golden‑тест считается проваленным, если при его прогоне находится хотя бы малейшее расхождение в пикселях по сравнению с эталоном. При этом фреймворк автоматически генерирует «разницу» (diff) и сохраняет её в виде картинок — разработчик может их посмотреть и понять, что именно изменилось.

Как создать golden и работать с диффами, расскажу далее. Но сперва рассмотрим преимущества и недостатки этой методологии.
Преимущества
В чём‑то golden‑тесты похожи на обычные unit‑ и widget‑тесты: они автоматизируют рутинную ручную проверку интерфейсов и ускоряют разработку.
Автоматизация = скорость.
Разработчикам не нужно собирать всё приложение и кликать по нему вручную — достаточно запустить изолированный golden‑тест.
Тестировщики могут использовать эти автотесты как основу: проверять только поведение компонентов и не тратить время на визуальную сверку. Иногда ручное тестирование и вовсе не требуется.
Дизайнерам и менеджерам становится проще быстро проверять стандартизированные правки без лишних созвонов и скриншотов. Всё это позволяет быстрее доставлять новую функциональность пользователям.
Визуальная проверка «как есть». Golden‑тесты — это самый наглядный способ убедиться, что UI выглядит именно так, как задумано. Скриншот‑тест воспроизводит компонент в точности как он отображается на устройстве пользователя.
Особенно это полезно при проверке дизайн‑системы: часто важные для бренда элементы меняются едва заметно глазу — вручную такие детали искать долго и муторно.
Эффективность и защита от регрессий. Golden‑тесты проверяют только визуальную часть. Это значит, что можно быстро прогонять сотни компонентов, не теряя в производительности. К тому же наличие «золотого эталона» защищает интерфейс от неожиданных визуальных багов при изменениях в коде. Особенно полезно при крупных рефакторингах: UI‑библиотеки часто состоят из множества параметров и сценариев использования, и легко что‑то сломать «вслепую».
Лёгкие в написании и поддержке. Главная ценность любого теста — соотношение пользы и времени на поддержку. С Golden‑тестами это соотношение обычно отличное: писать и поддерживать их зачастую проще, чем полноценные Widget‑ или Unit‑тесты.
Живая база знаний. Накопленные скриншоты становятся своеобразной «правдивой» базой знаний о том, как реально выглядят компоненты. Она может заменить часть документации: разработчики и дизайнеры всегда могут посмотреть свежие картинки и быстро понять, что уже реализовано.
Вариативность проверок. Ещё один плюс — гибкость. Можно написать один golden‑тест и автоматически генерировать варианты под разные размеры экранов, светлую и тёмную темы, разные направления текста (LTR/RTL), платформы (Android, iOS), размеры шрифтов — и любые другие параметры, важные для проекта.
Стимул к качеству кода. Наконец, golden‑тесты, как и любые другие автотесты, стимулируют держать код чистым и структурировать его так, чтобы его было удобно проверять.
Недостатки
Конечно, golden‑тесты — не панацея от всех багов. У них есть свои ограничения и нюансы:
Настройка требует времени. Чтобы golden‑тесты приносили пользу, придётся вложиться: настроить CI, организовать хранение эталонных картинок, убедиться, что код действительно тестируемый. Для новичков всё это может выглядеть трудоёмко, но обычно оправдывается в долгосрочной перспективе.
Проблемы с загрузкой картинок. Если ваши виджеты подгружают изображения из сети или ассетов, могут возникнуть сложности: картинки могут не прогрузиться во время теста, и сравнение даст ложный дифф. Как с этим справляться — расскажу в разделе «Подгрузка картинок».
Ограниченный охват. Golden‑тесты проверяют только визуальную часть интерфейса. Если баг не отражается во внешнем виде компонента, тест его не заметит. Поэтому их обычно комбинируют с Unit‑ и Widget‑тестами, которые проверяют логику и поведение.
Ложные срабатывания. Иногда тест может «упасть» по ошибке — например, если эталон устарел, а кто‑то забыл его обновить. Или если CI пропустил обновлённую версию скриншота. В результате кто‑то запускает тест локально, получает новый образец, а он отличается — и вот уже время уходит на поиск мнимой регрессии. Такое случается редко, но всё же случается.
Напишем свой golden-тест
Для написания golden‑тестов не нужно ставить какие‑то экзотические зависимости — в Flutter уже есть всё необходимое. Стандартная библиотека flutter_test
предоставляет готовые инструменты для скриншот‑тестирования. Правда, в официальной документации про это упоминается вскользь — хотя инструмент вполне рабочий.
Синтаксис golden‑тестов почти такой же, как у обычных Widget‑тестов: вы всё так же пишете функцию testWidgets
и используете tester.pumpWidget
для отрисовки виджета. Можно даже описать пользовательские взаимодействия — например, нажатие кнопки. Единственное отличие — финальная проверка: вместо привычного expect
вызывается expectLater
, а для сравнения используется матчер matchesGoldenFile
.
Напишем наш первый тест, используя flutter_test
. Давайте представим, что у нас есть небольшой виджет кнопки подтверждения, который мы написали для использования внутри нашего приложения на компонентах кнопок Flutter.
У нашей кнопки:
может быть три состояния;

может быть иконка;

может быть разный цвет фона и текста.

Наша цель: проверить, что все параметры, влияющие на отображение кнопки, ведут себя как ожидается.
Для этого мы выполним восемь шагов.
Реализация ConfirmButton
import 'package:flutter/material.dart';
enum ConfirmButtonState { enabled, loading, disabled }
class ConfirmButton extends StatelessWidget {
final VoidCallback onPressed;
final ConfirmButtonState state;
final String text;
final IconData? icon;
final Color? backgroundColor;
final Color? disabledColor;
const ConfirmButton({
required this.onPressed,
this.state = ConfirmButtonState.enabled,
this.text = 'Confirm',
this.icon,
this.backgroundColor,
this.disabledColor,
super.key,
});
@override
Widget build(BuildContext context) {
return FilledButton.icon(
onPressed: switch (state) {
ConfirmButtonState.enabled => onPressed,
ConfirmButtonState.loading || ConfirmButtonState.disabled => null,
},
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return disabledColor;
}
return backgroundColor;
}),
),
icon: AnimatedSize(
duration: const Duration(milliseconds: 100),
curve: Curves.easeOutCubic,
child: switch (state) {
ConfirmButtonState.enabled || ConfirmButtonState.disabled =>
icon == null ? const SizedBox.shrink() : Icon(icon),
ConfirmButtonState.loading => const SizedBox.shrink(),
},
),
label: AnimatedSize(
duration: const Duration(milliseconds: 100),
curve: Curves.easeOutCubic,
child: switch (state) {
ConfirmButtonState.enabled ||
ConfirmButtonState.disabled => Text(text),
ConfirmButtonState.loading => SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2.0),
),
},
),
);
}
}
Шаг 1. Обновим .gitignore
. Файлы упавших тестов не рекомендуется хранить в системе контроля версий. Сразу обновим .gitignore
, чтобы исключить их из репозитория.
# Golden tests failures output
**/failures/*.png
Шаг 2. Подключим пакет flutter_test
. Подключим пакет в раздел dev_dependencies
в файле pubspec.yaml
.
dev_dependencies:
flutter_test:
sdk: flutter
Шаг 3. Создадим в папке test/
папку goldens/
. Это не обязательно, но так удобнее определять, что тут будут именно голдены.
Шаг 4. Создадим файл теста. Создадим confirm_button_test.dart
и зададим ему структуру из group
и testWidgets
.
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_tests_handbook/components/confirm_button.dart';
void main() {
group('$ConfirmButton', () {
testWidgets('enabled', (tester) async {
});
});
}
Совет. Синтаксис
$ConfirmButton
можно использовать, чтобы поддерживать актуальность названий на случай, если название компонента изменится.
Шаг 5. Наполним тест содержанием. Проверим самое простое отображение кнопки с заданными обязательными параметрами text
и onPressed
. Также создадим специальную функцию wrapper, в которую обернём нашу кнопку. Она нужна, чтобы полученный в результате выполнения теста файл‑скриншот был фиксированного размера.
Widget wrapper(Widget child) => MaterialApp(
home: Center(
child: RepaintBoundary(
child: SizedBox(
width: 200,
height: 100,
child: Center(child: child),
),
),
),
);
await tester.pumpWidget(
wrapper(ConfirmButton(text: 'Enabled', onPressed: () {})),
);
Зачем здесь RepaintBoundary
? flutter_test
ищет ближайший предок типа RepaintBoundary
и записывает картинку его размера — здесь мы явно оборачиваем виджет в него, чтобы поход был не до RepaintBoundary
внутри Route
. Без этого картинка заняла бы размер всего приложения.
Шаг 6. Добавим проверку. Здесь первым аргументом указываем желаемый виджет. Второй аргумент задаёт matcher
с путём до файла картинки.
await expectLater(
find.byType(ConfirmButton),
matchesGoldenFile('goldens/confirm_button.png'),
);
Совет. Рекомендую под каждый отдельный компонент создавать свой файл с названием
component_name_test.dart
.
Финальный вид теста
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_tests_handbook/components/confirm_button.dart';
void main() {
group('$ConfirmButton', () {
testWidgets('enabled', (tester) async {
Widget wrapper(Widget child) => MaterialApp(
home: Center(
child: RepaintBoundary(
child: SizedBox(
width: 200,
height: 100,
child: Center(child: child),
),
),
),
);
await tester.pumpWidget(
wrapper(ConfirmButton(text: 'Enabled', onPressed: () {})),
);
await expectLater(
find.byType(ConfirmButton),
matchesGoldenFile('goldens/confirm_button.png'),
);
});
});
}
Шаг 7. Сгенерируем файлы образцов командой. Если бы мы запустили сейчас тест с помощью команды flutter test
, то получили бы ошибку No expectations provided. This may be a new test
, потому что ещё не с чем сравнивать результат.
Где же взять наш эталон, с которым мы будем сравнивать новые прогоны?
Для этого используется специальный флаг --update-goldens
. Он предназначен для создания и обновления эталонных изображений. Принцип работы флага заключается в пропуске этапа сравнения картинок: текущая версия изображения автоматически становится новым эталоном. Это означает, что при успешном выполнении теста без ошибок вы получите статус успешного прохождения во всех случаях.
flutter test --update-goldens
Примечание. Если вы получили ошибку
No expectations provided. This may be a new test
, но ожидаете, что тест на самом деле уже существовал, — стоит разобраться: возможно, что‑то случилось с названием файла.
Шаг 8. Смотрим на нашу картинку! На выходе получаем в консоли вывод о том, что тесты успешно прошли.
00:01 +1: All tests passed!
Также получаем картинку goldens/confirm_button.png
— по тому самому пути, который мы ранее указали в matchesGoldenFile
.

Важно! При любом обновлении (и создании) картинки нужно осознанно посмотреть на неё и принять решение о том, можно ли использовать её как образец и всё ли в ней корректно. Если всё в порядке, смело добавляйте её в систему контроля версий.
Здесь можно заметить следующее: картинка вместо реального текста Enabled, который мы написали, показывает квадратики. Так происходит потому, что по умолчанию фреймворк не загружает используемые шрифты в память, а использует специальный шрифт Ahem, состоящий целиком из таких квадратиков.
Как работать с этим, расскажу чуть дальше, а пока давайте примем это как условность.
И чтобы было проще запомнить процесс тестирования, я собрал процесс работы с golden‑тестами в одну диаграмму:

Работа с golden-тестами
На этом этапе наш golden‑тест уже готов. Теперь можно запустить его без флага --update-goldens
— тогда он сравнит текущий скриншот с эталонным файлом. Если ничего не поменялось, тесты пройдут успешно.
Внесение изменений
Теперь добавим иконку к нашей кнопке. В этом примере мы специально создаём разницу внутри теста — но в реальной жизни вы могли бы случайно задать дефолтное значение иконки прямо в конструкторе кнопки. В таком случае golden‑тест подскажет: внешний вид изменился, значит, стоит проверить, всё ли так, как задумано.
await tester.pumpWidget(
wrapper(
ConfirmButton(
text: 'Enabled',
onPressed: () {},
icon: Icons.done,
),
),
);
Тогда при новом запуске теста получим следующий вывод в консоли:
The following assertion was thrown while running async test code:
Golden "goldens/confirm_button.png": Pixel test failed, 62.22%, 4390px diff detected.
Failure feedback can be found at
/Users/nt4f04und/Desktop/golden_tests_handbook/test/goldens/failures
...
00:01 +0 -1: Some tests failed.
Тест завершился с ошибкой и сохранил дифф между тестовой картинкой и референсом в папку failures/
, расположенную рядом с тестовыми файлами. Если перейти в эту папку, то там мы увидим четыре картинки для каждого упавшего теста:

Обновление файлов
Мы внимательно изучили дифф и пришли к выводу, что в нашем случае изменение с иконкой было намеренным. Мы хотим обновить файлы — тогда выполним flutter test --update-goldens
для обновления.
Полный процесс будет выглядеть примерно так:

О покрытии тестами
Что стоит покрывать golden-тестами
Библиотеку UI‑компонентов. Подобные библиотеки являются ключевой частью приложений, где они используются. Поэтому критически важно убедиться, что условная кнопка выглядит и ведёт себя именно так, как было задумано.
Приложение со своей дизайн‑системой. Здесь, аналогично обособленной UI‑библиотеке, может потребоваться проверка визуального оформления критически важных элементов приложения. К ним относятся как отдельные виджеты, так и их группы, а также небольшие экраны.
Backend Driven UI (BDUI). Это подход, при котором сервер отправляет мобильному приложению не только данные, но и инструкции о том, как эти данные отображать, позволяя менять интерфейс без обновления самого приложения. Часто BDUI работает по следующей схеме: есть модель данных вёрстки (например, JSON), которую необходимо преобразовать в UI, в случае Flutter — в определённое дерево виджетов. Это создаёт идеальные условия для использования golden‑тестов: парсим JSON и сверяем полученный результат с картинкой. Тесты порой пишутся за считаные секунды.
Одна из известных библиотек для BDUI — DivKit, разработанная Яндексом и доступная в опенсорсе. И там как раз используются Golden‑тесты для проверки визуала на всех платформах, которые поддерживает библиотека.
Что не стоит покрывать golden-тестами
UI с высокой изменчивостью.
Пользовательский интерфейс часто меняется, и golden‑тесты быстро устаревают.
UI зависит от времени суток, локалей или данных, меняющихся при каждом запуске.
«Сложные» компоненты.
Большое количество вложенных компонентов и состояний, чувствительных к изменениям макета.
Сложные моки зависимостей (API, базы данных, внешние сервисы) с обработкой реальных сценариев.
Компоненты с анимациями или интерактивными переходами.
Сложность точного контролирования очерёдности кадров повышает сложность настройки и поддержания тестов.
Платформозависимость тестов
У golden‑тестов есть ещё одна особенность, на которой хочется подробно остановиться. Они фундаментально платформозависимые: их результат будет отличаться в зависимости от того, на какой платформе они запускаются.
Различия могут возникать в зависимости от архитектуры GPU и CPU, операционной системы платформы и даже её версии. Так происходит потому, что разные аппаратные и программные конфигурации могут по‑разному обрабатывать графику и выполнять код, влияя на рендеринг (в частности, тени и шрифты), алгоритмы антиалиасинга (сглаживания) и другие визуальные аспекты приложения.
Пример №1 — разные ОС
При запуске на macOS будет один результат, при запуске на Linux — второй, при запуске на Windows будет третий. Различий немного, но они есть.
На скриншотах ниже golden‑тест, сделанный на macOS, прогоняется на Linux:

Пример №2 — разные GPU
Разница будет в рендеринге между macOS на базе x86-64 (процессоры Intel) и на базе Apple Silicon (процессоры M1, M2, M3, M4).
На скриншотах ниже golden‑тест, сделанный на Mac Intel, прогоняется на Mac M1:

Что с этим делать? Для нас это означает, что golden‑тесты из коробки чувствительны к окружению, и это создаёт сложности при внедрении. Необходимо решать проблемы с разными окружениями у разработчиков компании, а также на CI‑серверах. В противном случае тесты будут регулярно завершаться с ошибками (такие тесты называют flaky).
К счастью, существует пакет alchemist, который был создан решить эту проблему.
Пакет alchemist — зачем он нужен и почему стоит с ним поработать
Энтузиасты из Flutter‑сообщества создали пакет alchemist, чтобы усовершенствовать стандартные инструменты golden‑тестирования и сделать их более удобными в реальных проектах. Мы рекомендуем использовать именно его — и вот почему.
Основные плюсы alchemist:
Преодоление платформозависимости. Заставляет тесты работать одинаково на любой машине.
Простая настройка. Интуитивный интерфейс и декларативный API экономят время на конфигурацию тестового окружения.
Улучшенная читаемость и масштабируемость. Лаконичный синтаксис делает логику тестов понятнее, а поддерживать их становится проще.
Автоматический размер скриншота. alchemist сам масштабирует скриншот под размер виджета, в отличие от стандартных тестов Flutter, которые по умолчанию растягивают виджет под размер экрана.
Пакет вводит разделение между двумя категориями тестов:
Платформенные — те, которые разработчики используют при локальном запуске.
CI — те, которые в конечном итоге коммитятся и используются на CI.
Таким образом, удалось объединить лучшее из обоих подходов: сохранить возможность разработчикам работать с платформозависимыми изображениями, приближёнными к реальному окружению, одновременно обеспечив идемпотентность тестов в отношении окружения.
При запуске команды flutter test --update-goldens
на файл с тестом test/goldens/<component_name>_golden_test.dart
alchemist автоматически создаст две картинки:
Для CI —
test/goldens/goldens/ci/<component_name>.png

Для каждой платформы соответственно —
test/goldens/goldens/<platform_name>/<component_name>.png
. Здесь и далее в повествовании такие тесты буду называть macos — для примера конкретной платформы.

Но на кнопках всё равно квадратики — в чём отличие от flutter_test
?
Да, визуально разницы и правда нет. И, вероятно, задумка команды Flutter была как раз в этом, но, кажется, это не работает.
То есть во flutter_test
golden‑тесты по умолчанию используют шрифт Ahem, и он всё ещё может по‑разному отображаться на разных платформах, а CI‑тесты alchemist используют специальный BlockedTextPaintingContext, который гарантированно отрисуется одинаково.
Я подключил alchemist, но в моём платформенном тесте всё ещё квадратики. Почему?
Это ограничение пакета: скорее всего, у вас используется шрифт, который подключается в том же пакете, что и тест.
Решение:
либо вынести тест в отдельный пакет от шрифта;
либо сам шрифт вынести в отдельный пакет.
Есть ли другие решения платформозависимости?
Это решение, предлагаемое пакетом alchemist — будем придерживаться его использования далее в этой статье. В большинстве случаев его достаточно, но, используя его, мы всё же идём на компромисс: точность проверки падает.
Есть ли другие библиотеки для golden‑тестирования? Почему не golden_toolkit
?
CI‑тесты alchemist отличаются от платформенных как минимум:
квадратиками вместо шрифтов;
видом теней;
отсутствием эффектов вроде блюра.
Среди комьюнити самыми популярными оказались alchemist и golden_toolkit
.
Наш выбор среди них двух пал на alchemist по следующим причинам:
alchemist решает проблему платформозависимости.
alchemist требует меньше кода для настройки и написания тестов.
Авторы
golden_toolkit
сделали официальное заявление, что они перестают поддерживать библиотеку, и пометили её как discontinued на pub.dev.
Пишем golden-тест с использованием alchemist
Шаг 1. Обновим .gitignore
под обновлённые требования к файлам, включая только CI‑тесты в систему контроля версий.
# Ignore non-CI golden files and failures
test/**/goldens/**/*.png
test/**/failures/**/*.png
!test/**/goldens/ci/*.png
Шаг 2. Подключим пакет alchemist. Подключим пакет в раздел dev_dependencies
в файле pubspec.yaml
.
dev_dependencies:
flutter_test:
sdk: flutter
alchemist: ^0.12.1
Шаг 3. Настроим конфигурацию тестов. Для настройки тестов используем специальный файл flutter_test_config.dart
. Он позволяет добавить дополнительную логику для всех тестов в той папке, в которой он находится. При каждом запуске тестов библиотека flutter_test
ищет ближайший к тесту файл с таким названием и ожидает определённую структуру в нём. Подробнее об этом механизме можно почитать в документации.
В нашем случае мы хотим завязку на окружение CI, чтобы не создавать лишних файлов, а также установить тему для наших тестов:
import 'dart:async';
import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
const isRunningInCi = bool.fromEnvironment('CI', defaultValue: false);
return AlchemistConfig.runWithConfig(
config: AlchemistConfig.current().copyWith(
goldenTestTheme:
GoldenTestTheme.standard().copyWith(backgroundColor: Colors.white)
as GoldenTestTheme?,
platformGoldensConfig: const PlatformGoldensConfig(
enabled: !isRunningInCi,
),
),
run: testMain,
);
}
Шаг 4. Создадим файл dart_test.yaml
в корне проекта со следующим содержанием:
tags:
golden:
Этот файл нужен для корректной работы фильтрации тестов по тегам.
Шаг 5. Перепишем наш тест. В написании теста с использованием стандартных инструментов Flutter мы проверили лишь самое простое, но вспоминаем нашу цель: проверить, что все параметры, влияющие на отображение кнопки, ведут себя как ожидается. Значит, нам нужно:
писать несколько тестов с разными файлами;
либо как‑то самостоятельно группировать эти проверки внутри одного файла.
У golden‑тестов на alchemist совсем другая структура, что помогает избежать этой проблемы. Вместо testWidgets
они используют goldenTest
— эта функция объявляет тест и имеет ряд параметров для настройки. Каждому тесту необходимо задать:
описание;
название файла;
виджет, который он проверяет.
Обычно виджет для проверки — это GoldenTestGroup
. Это специальный виджет, который принимает набор виджетов GoldenTestScenario
и отображает их в виде сетки. Каждый сценарий содержит название и виджет для проверки.
В нашем случае ConfirmButton
в состоянии enabled
— один из таких сценариев.
import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';
import 'package:golden_tests_handbook/components/confirm_button.dart';
void main() {
goldenTest(
'$ConfirmButton',
fileName: 'confirm_button',
builder:
() => GoldenTestGroup(
columns: 1,
children: [
GoldenTestScenario(
name: 'enabled',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(text: 'Enabled', onPressed: () {}),
),
),
],
),
);
}
Получаем два файла:

Шаг 6. Добавим сценарий для состояния загрузки. Мы хотим добавить остальные сценарии, как обсудили выше.
GoldenTestScenario(
name: 'loading',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(
text: 'Loading',
onPressed: () {},
state: ConfirmButtonState.loading,
),
),
),
Запускаем тест…
…но получаем ошибку.
The following assertion was thrown running a test:
pumpAndSettle timed out
По умолчанию alchemist использует функцию pumpAndSettle
и ждёт завершения анимаций перед тем, как финализировать картинку. В нашем случае кнопка loading показывает бесконечную анимацию загрузки.
Исправим это, передав в pumpBeforeTest
конкретную длительность pump
функции pumpNTimes
.
goldenTest(
'$ConfirmButton',
fileName: 'confirm_button',
pumpBeforeTest: pumpNTimes(1, Durations.medium1),
builder:
Примечание. Предопределённые в alchemist функции для
pumpBeforeTest
:
onlyPumpAndSettle
— дляtester.pumpAndSettle
(используется по умолчанию);
pumpOnce
иpumpNTimes
— дляtester.pump
;
precacheImages
— для подгрузки локальных изображений.
Получаем картинки — на них кнопка loading
запечатлена в определённый момент своей анимации.

Шаг 7. Добавим сценарии выключенной кнопки и разных цветов. То есть мы проверяем состояние disabled
и параметры цветов backgroundColor
и disabledColor
. Для этого добавим ещё три сценария:
GoldenTestScenario(
name: 'disabled',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(
text: 'Disabled',
onPressed: () {},
state: ConfirmButtonState.disabled,
),
),
),
GoldenTestScenario(
name: 'green button',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(
text: 'Green',
onPressed: () {},
backgroundColor: Colors.green,
),
),
),
GoldenTestScenario(
name: 'green disabled button',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(
text: 'Green',
onPressed: () {},
state: ConfirmButtonState.disabled,
disabledColor: Colors.green[900],
),
),
),
Получаем ещё несколько голденов. Выглядят отлично!

Шаг 8. Добавим вариант теста с иконкой. Нам осталось проверить, что кнопка умеет показывать иконку. Кажется, это было бы полезно сразу для всех сценариев, поэтому создадим отдельный вариант теста. Не будем дублировать код, а постараемся переиспользовать большую его часть с помощью простого цикла.
void main() {
for (final icon in [null, Icons.done]) {
goldenTest(
// Правим название теста в зависимости от наличия иконки
'$ConfirmButton ${icon == null ? '' : 'with icon'}',
fileName: 'confirm_button${icon == null ? '' : '_with_icon'}',
pumpBeforeTest: pumpNTimes(1, Durations.medium1),
builder:
() => GoldenTestGroup(
columns: 1,
children: [
GoldenTestScenario(
name: 'enabled',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(
text: 'Enabled',
// Добавили проброс иконки
icon: icon,
onPressed: () {},
),
),
),
// Остальные сценарии ...
],
),
);
}
}
Финальный вид теста
import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';
import 'package:golden_tests_handbook/components/confirm_button.dart';
void main() {
for (final icon in [null, Icons.done]) {
goldenTest(
'$ConfirmButton ${icon == null ? '' : 'with icon'}',
fileName: 'confirm_button${icon == null ? '' : '_with_icon'}',
pumpBeforeTest: pumpNTimes(1, Durations.medium1),
builder:
() => GoldenTestGroup(
columns: 1,
children: [
GoldenTestScenario(
name: 'enabled',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(
text: 'Enabled',
icon: icon,
onPressed: () {},
),
),
),
GoldenTestScenario(
name: 'loading',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(
text: 'Loading',
icon: icon,
onPressed: () {},
state: ConfirmButtonState.loading,
),
),
),
GoldenTestScenario(
name: 'disabled',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(
text: 'Disabled',
icon: icon,
onPressed: () {},
state: ConfirmButtonState.disabled,
),
),
),
GoldenTestScenario(
name: 'green button',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(
text: 'Green',
icon: icon,
onPressed: () {},
backgroundColor: Colors.green,
),
),
),
GoldenTestScenario(
name: 'green disabled button',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(
text: 'Green',
icon: icon,
onPressed: () {},
state: ConfirmButtonState.disabled,
disabledColor: Colors.green[900],
),
),
),
],
),
);
}
}
Получаем в этот раз уже четыре картинки: две такие же без иконки и две новые для варианта с иконкой.

Интерактивный тест
Иногда в тестах требуется эмулировать какое‑то взаимодействие пользователя с компонентами. Рассмотрим на примере с ConfirmButton
. Где‑то в приложении на её основе создали кнопку, которая по нажатию переходит в состояние loading
и спустя какое‑то время возвращается в состояние enabled.

Реализация DelayedConfirmButton
class DelayedConfirmButton extends StatefulWidget {
const DelayedConfirmButton({super.key});
@override
State<DelayedConfirmButton> createState() => DelayedConfirmButtonState();
}
class DelayedConfirmButtonState extends State<DelayedConfirmButton> {
ConfirmButtonState buttonState = ConfirmButtonState.enabled;
Timer? timer;
@override
void dispose() {
timer?.cancel();
timer = null;
super.dispose();
}
@override
Widget build(BuildContext context) {
return ConfirmButton(
state: buttonState,
icon: Icons.done,
onPressed: () {
setState(() {
buttonState = ConfirmButtonState.loading;
});
// Симулируем долгую операцию через таймер.
// В реальном мире тут мог бы быть, например, запрос в сеть.
timer?.cancel();
timer = Timer(Durations.medium4, () {
setState(() {
buttonState = ConfirmButtonState.enabled;
});
});
},
);
}
}
Для написания интерактивных тестов в функции goldenTest
предусмотрен параметр whilePerforming
, который принимает в себя колбэк, где можно описать взаимодействие с компонентом с помощью WidgetTester
.
Посмотрим на пример кода для DelayedConfirmButton
:
goldenTest(
'Press on $DelayedConfirmButton',
fileName: 'pressed_delayed_confirm_button',
whilePerforming: (WidgetTester tester) async {
await tester.tap(find.byType(DelayedConfirmButton));
await tester.pump(Duration(milliseconds: 250));
await tester.pump(Duration(milliseconds: 250));
await tester.pump(Duration(milliseconds: 250));
return null;
},
builder:
() => Padding(
padding: const EdgeInsets.all(8.0),
child: DelayedConfirmButton(),
),
);
Вот что здесь происходит:
Делаем нажатие на кнопку — через вызов метода
tap()
.Ждём выполнения анимации перехода к состоянию загрузки — чтобы в зафиксированной картинке по итогу теста была кнопка в состоянии
loading
. Для этого нужно пропустить несколько кадров с определённой задержкой, вызвав методpump()
.В конце возвращаем
null
— здесь можно было бы вернуть колбэк для очистки состояния жестов, но нам это не нужно.
При запуске такого теста получаем картинки:

Пример неудачного теста
Как мы уже обсудили выше — бывают случаи, когда компонент не получится покрыть golden‑тестом.Представим, что у нас есть кнопка, запускающая эффект конфетти на основе пакета flutter_confetti.

Реализация ConfettiButton
import 'package:flutter/material.dart';
import 'package:flutter_confetti/flutter_confetti.dart';
class ConfettiButton extends StatelessWidget {
final String text;
final VoidCallback onPressed;
const ConfettiButton({
super.key,
required this.text,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: () {
Confetti.launch(
context,
options: ConfettiOptions(particleCount: 100, spread: 70, y: 1),
);
onPressed.call();
},
child: Text(text),
);
}
}
Если мы попробуем написать на неё тест и сгенерировать образцы, у нас вроде бы даже всё получится — и выйдут хорошие картинки.
void main() {
goldenTest(
'$ConfettiButton',
fileName: 'confetti_button',
pumpBeforeTest: pumpNTimes(1, Durations.medium1),
whilePerforming: (WidgetTester tester) async {
await tester.tap(find.text('Wohoo!'));
await tester.pump(Durations.medium1);
await tester.pump(Durations.medium1);
await tester.pump(Durations.medium1);
await tester.pump(Durations.medium1);
return null;
},
constraints: BoxConstraints.tightFor(width: 200, height: 200),
builder: () => ConfettiButton(text: 'Wohoo!', onPressed: () {}),
);
}

Но! Если мы теперь попробуем запустить тест через flutter test
, то он упадёт с ошибкой:
The following assertion was thrown while running async test code:
Golden "goldens/macos/confetti_button.png": Pixel test failed, 17.55%, 7021px diff detected.
А в диффах мы обнаружим следующее:

И мы видим, что при каждом новом запуске создаётся разная картинка. Так происходит потому, что в самом пакете flutter_confetti используются рандомно генерируемые числа, и написать тест на такой компонент без внесения правок в сам пакет не получится.
Варианты golden-тестов
Ранее мы писали тесты на компонент в стандартной теме Flutter. Но что, если наши пользователи используют тёмную тему? Или направление текста в их языке не слева направо (LTR), а справа налево (RTL)?
Неплохо было бы покрыть тестами и эти случаи. Вы можете написать и свои варианты тестов в зависимости от ваших потребностей, но разберём на примере этих двух запросов.
Сначала давайте посмотрим на сам тест и результат, который хочется получить.
void main() {
makeGoldenTest(
description: 'Confirm button variants',
fileName: 'confirm_button_variants',
cases: [
GoldenTestScenario(
name: 'enabled',
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ConfirmButton(
text: 'Enabled',
icon: Icons.done,
onPressed: () {},
),
),
),
],
);
}
Внешне почти никаких отличий, кроме того, что здесь используем не функцию goldenTest, а нашу вспомогательную makeGoldenTest.
При её запуске у нас получится не два голдена, как раньше, а целых восемь!

Это потому, что комбинаций двух вариантов по два (светлая/тёмная тема, RTL/LTR) — как раз четыре. Плюс каждый нужно сделать для CI и для platform. Получается восемь голденов.

Реализация вспомогательной функции makeGoldenTest
Выше мы уже затронули пример с несколькими вариантами тестов, когда написали тест для компонента с иконкой и без.
void main() {
for (final icon in [null, Icons.done]) {
goldenTest(
// Правим название теста в зависимости от наличия иконки
'$ConfirmButton ${icon == null ? '' : 'with icon'}',
fileName: 'confirm_button${icon == null ? '' : '_with_icon'}',
Внутри makeGoldenTest
используется этот же принцип для создания вариантов.
void makeGoldenTest({
required String description,
required String fileName,
required List<GoldenTestScenario> cases,
}) {
for (final isDarkTheme in [false, true]) {
final themeName = isDarkTheme ? 'dark' : 'light';
for (final textDirection in [TextDirection.ltr, TextDirection.rtl]) {
final textDirectionName = textDirection.name;
// Логика вариантов...
}
}
}
Дальше пишем реализацию:
// Логика вариантов...
// Модификация имени файла и описания теста
final modifiedFileName = '$fileName.$themeName.$textDirectionName';
final modifiedDescription =
'$description | $themeName | $textDirectionName';
// Переопределение темы и конфигурации Alchemist
final theme = isDarkTheme ? ThemeData.dark() : ThemeData.light();
final modifiedConfig = AlchemistConfig.current().merge(
AlchemistConfig(
theme: theme,
goldenTestTheme:
AlchemistConfig.current().goldenTestTheme?.copyWith(
backgroundColor: theme.scaffoldBackgroundColor,
)
as GoldenTestTheme?,
),
);
AlchemistConfig.runWithConfig(
config: modifiedConfig,
run:
// Создание теста на каждый вариант
() => goldenTest(
modifiedDescription,
fileName: modifiedFileName,
builder:
// Использование Directionality для поддержки RTL
() => Directionality(
textDirection: textDirection,
child: GoldenTestGroup(columns: 1, children: cases),
),
),
);
Финальный вид функции
import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';
void makeGoldenTest({
required String description,
required String fileName,
required List<GoldenTestScenario> cases,
}) {
for (final isDarkTheme in [false, true]) {
final themeName = isDarkTheme ? 'dark' : 'light';
for (final textDirection in [TextDirection.ltr, TextDirection.rtl]) {
final textDirectionName = textDirection.name;
// Логика вариантов...
// Модификация имени файла и описания теста
final modifiedFileName = '$fileName.$themeName.$textDirectionName';
final modifiedDescription =
'$description | $themeName | $textDirectionName';
// Переопределение темы и конфигурации Alchemist
final theme = isDarkTheme ? ThemeData.dark() : ThemeData.light();
final modifiedConfig = AlchemistConfig.current().merge(
AlchemistConfig(
theme: theme,
goldenTestTheme:
AlchemistConfig.current().goldenTestTheme?.copyWith(
backgroundColor: theme.scaffoldBackgroundColor,
)
as GoldenTestTheme?,
),
);
AlchemistConfig.runWithConfig(
config: modifiedConfig,
run:
// Создание теста на каждый вариант
() => goldenTest(
modifiedDescription,
fileName: modifiedFileName,
builder:
// Использование Directionality для поддержки RTL
() => Directionality(
textDirection: textDirection,
child: GoldenTestGroup(columns: 1, children: cases),
),
),
);
}
}
}
Подгрузка картинок
Бывает, что в наших виджетах используются какие‑то картинки: локальные или сетевые. Рассмотрим подробнее, как их тестировать.
Локальные картинки
Это могут быть картинки:
из Flutter:
Image.asset
,Image.file
,Image.memory
;
из пакета flutter_svg:
SvgPicture.asset
,SvgPicture.file
,SvgPicture.memory
,SvgPicture.string
.
По умолчанию тесты не показывают никакие картинки, даже локальные. Чтобы это исправить, можно воспользоваться функцией precacheImages
, передав её в pumpBeforeTest
внутри goldenTest
.
Представим пример компонента, который должен нарисовать цветовой тест Ишихары.
import 'package:flutter/material.dart';
class IshiharaImage extends StatelessWidget {
const IshiharaImage({super.key});
static const double size = 250.0;
@override
Widget build(BuildContext context) =>
Image.asset('assets/ishihara.png', height: size, width: size);
}
Результат:

Пробуем написать и запустить тест:
void main() {
goldenTest(
'Color vision test',
fileName: 'color_vision_test',
builder:
() => GoldenTestGroup(
columns: 1,
children: [
GoldenTestScenario(name: 'Ishihara', child: IshiharaImage()),
],
),
);
}
Получаем пустоту:

Добавим precacheImages
.
void main() {
goldenTest(
'Color vision test',
fileName: 'color_vision_test',
pumpBeforeTest: precacheImages, // <--
Получаем голдены.

К сожалению, сейчас у функции precacheImages
есть ограничение, и она всегда внутри вызывает pumpAndSettle
. Как было показано выше, pumpAndSettle
не подойдёт для тестов с бесконечными анимациями — и сейчас в таких случаях написать тест не получится.
Сетевые картинки
Это могут быть картинки:
из Flutter
Image.network
;из пакета
flutter_svg
:SvgPicture.network
;из пакета
cached_network_image
:CachedNetworkImage
.
Сейчас в открытом доступе нет готовых инструментов для написания тестов с такими картинками, поэтому написать тест на использующие их виджеты не получится.
Настройка CI c GitHub Actions
В финальном варианте с использованием alchemist никаких дополнительных настроек CI не требуется — достаточно запускать тесты при каждом пуше в пул‑реквесты и на main‑ветку.
flutter test
Важно! Не используйте флаг
--update-goldens
на CI, потому что тогда все тесты будут всегда считаться пройденными.
Далее разберём настройку на конкретном примере бесплатного и популярного CI от GitHub.
Шаг № 1. Оставляем только CI‑тесты. Для этого мы уже выше сконфигурировали файл flutter_test_config.dart
для наших тестов. Тогда мы сможем воспользоваться конструкцией flutter test --dart-define=CI=true
, чтобы передать в код флаг о запуске в CI‑окружении.
import 'dart:async';
import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
const isRunningInCi = bool.fromEnvironment('CI', defaultValue: false);
return AlchemistConfig.runWithConfig(
config: AlchemistConfig.current().copyWith(
goldenTestTheme:
GoldenTestTheme.standard().copyWith(backgroundColor: Colors.white)
as GoldenTestTheme?,
platformGoldensConfig: const PlatformGoldensConfig(
enabled: !isRunningInCi,
),
),
run: testMain,
);
}
Шаг № 2. Создаём конфигурационный файл. Теперь напишем конфигурационный файл для самого CI. Для этого создадим в корне репозитория папку .github/workflows/
и положим в неё файл tests_ci.yaml
. Этот workflow запустит прогон тестов в вашем GitHub‑репозитории:
при каждом коммите в главную ветку;
при каждом Pull Request в главную ветку.
name: Flutter tests CI
on:
push:
pull_request:
branches:
- main
- master
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: ? Git Checkout
uses: actions/checkout@v4
- name: ? Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.32.1'
channel: 'stable'
- name: ? Install Dependencies
shell: bash
run: flutter pub get
- name: ? Run Tests
shell: bash
run: flutter test --no-pub --dart-define=CI=true test/goldens/
- name: ? Save diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: diffs
path: test/goldens/failures
if-no-files-found: ignore
Разберём содержимое файла.
name: Flutter tests CI
— объявляет название workflow.on:
— условия запуска.-
steps:
— действия, которые нужно выполнить в рамках прогона:Git Checkout
— монтирует ваш репозиторий в окружении CI;Setup Flutter
— устанавливает Flutter указанной версии;Install Dependencies
— выполняет pub get;Run Tests
— запускаетflutter test
;Save diffs
— сохраняет диффы.
Теперь при коммитах или PR получаем примерно такие прогоны:

Если тесты упадут, то вывод будет таким, а также во вкладке Summary можно будет найти артефакт с диффами, который можно скачать и посмотреть:


Рекомендации
Настройка IDE
Можно пользоваться исключительно консолью, но ведь правда будет удобней, если мы сможем запускать команды прямо из IDE?
В Visual Studio Code можно настроить запуск тестов из каждого файла по отдельности.

В корне проекта создайте папку .vscode
/ (если её ещё нет). Внутри неё в launch.json
добавьте следующую конфигурацию:
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "golden",
"request": "launch",
"type": "dart",
"codeLens": {
"for": [
"run-file",
"run-test",
"run-test-file",
"debug-file",
"debug-test",
"debug-test-file",
],
"title": "${debugType} golden",
},
"args": [
"--update-goldens"
]
},
]
}
В Android Studio/IntelliJ IDEA можно отфильтровать только по пути, но опции запуска внутри самого файла настроить не получится.

Нужно добавить такую конфигурацию запуска:
В корне проекта создайте папку
.run/
(если её ещё нет).Внутри неё создайте файл
update_
goldens.run
.xml
:
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="update_goldens" type="FlutterTestConfigType" factoryName="Flutter Test">
<option name="testDir" value="$PROJECT_DIR$/test/goldens/" />
<option name="useRegexp" value="false" />
<option name="additionalArgs" value="--update-goldens" />
<method v="2" />
</configuration>
</component>
Получится вот такая конфигурация запуска:

Фильтрация тестов
Мы создали файл dart_test.yaml
и указали в нём тег golden
. Flutter позволяет фильтровать тесты по подобным тегам:
# Выполнить все Golden тесты.
flutter test --tags golden
# Выполнить тесты, кроме Golden.
flutter test --exclude-tags golden
Но несмотря на то, что такой подход работает, у него есть большой минус — время выполнения. Основная часть времени прогона большинства тестов — это их компиляция. Используя теги, тесты хоть и отфильтруются, но всё равно пройдут этап компиляции, а значит, на это уйдёт время — часто очень большое, больше, чем само исполнение.
Чтобы избежать этого, мы рекомендуем фильтровать тесты по пути:
# Выполнить все Golden тесты.
flutter test test/goldens
# Выполнить тесты, кроме Golden.
flutter test test/unit
Выводы
Golden‑тесты во Flutter — это мощный инструмент для автоматизации визуального тестирования, обеспечивающий стабильность UI и защиту от регрессий. Они позволяют:
Автоматизировать проверку компонентов дизайна.
Фиксировать даже незначительные изменения, незаметные при код‑ревью.
Упростить коммуникацию между разработчиками, тестировщиками и дизайнерами за счёт наглядности эталонных изображений.
Масштабировать тестирование для различных сценариев: тёмная/светлая тема, RTL/LTR, разные размеры экранов.
Использование пакета alchemist решает ключевую проблему платформозависимости, разделяя тесты на CI‑ и локальные, а также упрощает настройку и повышает читаемость кода.
Ключевые советы
Интегрируйте golden‑тесты в CI/CD, но исключите флаг
--update-goldens
на сервере.Фильтруйте тесты по путям (например,
test/goldens
), чтобы ускорить выполнение.Проверяйте эталонные изображения вручную после генерации, чтобы избежать ложных эталонов.
Избегайте тестов с анимациями или используйте детерминированные сценарии (например,
pumpNTimes
).
Golden‑тесты — не серебряная пуля, но при грамотном применении они становятся незаменимым инструментом для поддержания визуальной целостности и качества UI.
Полезные ссылки
Полный код из этой статьи для вдумчивого изучения.
Ещё один наш хендбук по пакету alchemist.
Наш рассказ про golden‑тесты на лекции летней школы мобильной разработки.