При создании интерфейса важно проверить, как он реально выглядит. Часто это проверяют все участники процесса — от разработчиков до менеджеров. И для автоматизации и упрощения процесса визуального тестирования приложения есть специальный инструмент — 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‑тесты — не панацея от всех багов. У них есть свои ограничения и нюансы: 

  1. Настройка требует времени. Чтобы golden‑тесты приносили пользу, придётся вложиться: настроить CI, организовать хранение эталонных картинок, убедиться, что код действительно тестируемый. Для новичков всё это может выглядеть трудоёмко, но обычно оправдывается в долгосрочной перспективе.

  2. Проблемы с загрузкой картинок. Если ваши виджеты подгружают изображения из сети или ассетов, могут возникнуть сложности: картинки могут не прогрузиться во время теста, и сравнение даст ложный дифф. Как с этим справляться — расскажу в разделе «Подгрузка картинок».

  3. Ограниченный охват. Golden‑тесты проверяют только визуальную часть интерфейса. Если баг не отражается во внешнем виде компонента, тест его не заметит. Поэтому их обычно комбинируют с Unit‑ и Widget‑тестами, которые проверяют логику и поведение.

  4. Ложные срабатывания. Иногда тест может «упасть» по ошибке — например, если эталон устарел, а кто‑то забыл его обновить. Или если 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 по следующим причинам:

  1. alchemist решает проблему платформозависимости.

  2. alchemist требует меньше кода для настройки и написания тестов.

  3. Авторы 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(),
      ),
);

Вот что здесь происходит:

  1. Делаем нажатие на кнопку — через вызов метода tap().

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

  3. В конце возвращаем 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 не подойдёт для тестов с бесконечными анимациями — и сейчас в таких случаях написать тест не получится.

Сетевые картинки

Это могут быть картинки:

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

Настройка 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 можно отфильтровать только по пути, но опции запуска внутри самого файла настроить не получится.

Нужно добавить такую конфигурацию запуска:

  1. В корне проекта создайте папку .run/ (если её ещё нет).

  2. Внутри неё создайте файл 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‑ и локальные, а также упрощает настройку и повышает читаемость кода.

Ключевые советы

  1. Интегрируйте golden‑тесты в CI/CD, но исключите флаг --update-goldens на сервере.

  2. Фильтруйте тесты по путям (например, test/goldens), чтобы ускорить выполнение.

  3. Проверяйте эталонные изображения вручную после генерации, чтобы избежать ложных эталонов.

  4. Избегайте тестов с анимациями или используйте детерминированные сценарии (например, pumpNTimes).

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

Полезные ссылки

Комментарии (0)