При создании интерфейса важно проверить, как он реально выглядит. Часто это проверяют все участники процесса — от разработчиков до менеджеров. Golden-тесты
призваны автоматизировать и упростить процесс визуального тестирования приложения.
Это методология тестирования, в которой текущий UI сравнивается с предварительно сгенерированным «золотым» эталоном. Если вы уже слышали про скриншот-тесты
— это примерно то же самое, но есть нюансы.
В этом параграфе:
-
Познакомимся с методологией, рассмотрим её преимущества и недостатки.
-
Напишем базовый голден-тест на примере стандартных инструментов библиотеки
flutter_test
. -
Узнаем, что стоит и не стоит покрывать голденами.
-
Напоследок — рассмотрим основной недостаток
flutter_test
.
Приступим!
Что такое голден-тест
Сравнение с неким «золотым» эталоном или существование золотого стандарта — это довольно распространённая практика в различных областях человеческой деятельности.
-
В медицине есть золотой стандарт диагностики.
-
В музыке и видеоиграх —золотая мастер-копия, которая используется как основа для распространения финальной версии продукта и производства всех последующих копий
-
В экономике золотой стандарт известен как система, при которой стоимость валюты или денежной системы привязана к количеству золота.
Вот и в разработке ПО есть «золотой» образец UI (далее мы будем называть его «голден»), с которым мы сравниваем текущий. Этот образец создаётся при первом запуске теста и обычно сохраняется прямо в систему контроля версий (например, git).
Голден-тесты идейно очень похожи на Widget-тесты — точно так же отрисовывается какой-то виджет, но проверка происходит визуальная, а не через код: голден считается упавшим, если при прогоне находится хоть малейшее различие в пикселях с образцом.
Вместе с этим фреймворк тестирования генерирует разницу (дифф) и сохраняет её в виде картинок, чтобы разработчик мог их изучить:

Как получить голден и работать с диффами, расскажем далее, но сперва рассмотрим преимущества и недостатки этой методологии.
Преимущества
Польза голден-тестов в чём-то очень похожа на пользу от обычных Widget- и Unit-тестов.
Увеличение скорости разработки
-
Разработчикам — не обязательно было бы собирать всё приложение и вручную его проверять, достаточно прогнать изолированный тест.
-
Тестировщикам — могли бы использовать автотесты как основу и проверять лишь поведение компонентов, сокращая время тестирования. В некоторых случаях ручное тестирование вообще не потребуется.
-
Дизайнерам и менеджерам — становится проще проверять стандартизированные изменения.
За счёт этого новый функционал можно быстрее доставлять до пользователей.
Наглядная и точная визуальная проверка дизайна
Ни один другой тип тестов не является таким же наглядным. Подобная проверка в точности воспроизводит компонент, как если бы он был на устройстве пользователя.
Большая польза здесь — за счёт проверки дизайн-системы в местах, которые могут быть визуально еле различимы (и потому трудоёмки в проверке), но очень важны для бренда компании. Ниже в этом параграфе расскажем про конкретные примеры таких систем.
Эффективность
Время выполнения голден-теста не отличается от времени выполнения стандартных Unit- или Widget-тестов.
Защита от регрессий
Написав такой тест, мы фиксируем результаты надолго и защищаем компоненты от визуальных багов при изменениях в коде. Это особенно полезно при больших рефакторингах, когда изменения в одной части системы могут повлечь неочевидные изменения в противоположном конце.
Так, компоненты UI-библиотек имеют много параметров и используются многими модулями. Сложно делать правки вслепую, потому что есть вероятность задеть какой-то из многочисленных сценариев использования компонента.
Скорость написания и простота поддержания
Ценность любых тестов определяется соотношением совокупной пользы к трудозатратам на написание и поддержание такого теста. Применять и поддерживать голдены зачастую в разы проще, чем остальные типы тестов, а пользу они несут колоссальную.
База знаний, упрощение коммуникации
База картинок может стать самой правдивой базой знаний об актуальном виде компонентов и заменить собой документацию.
Картинки могут использоваться не только в самих тестах: разработчики могут показывать картинки друг другу и дизайнерам. В любой момент можно самостоятельно зайти в код и посмотреть своими глазами, для каких компонентов уже есть голдены и как они выглядят.
Качество кода
Как и Unit- или Widget-, голден-тесты заставляют больше думать о качестве кода и организовывать его так, чтобы его было легко тестировать.
Вариативность проверок
Можно написать один тест и сделать автоматическую генерацию его вариантов с разыми параметрами:
-
с разными размерами экрана;
-
в светлой/тёмной темах;
-
с направлениями текста LTR/RTL;
-
с разными TargetPlatform: Android, iOS и т. д;
-
с разными размерами шрифтов;
-
с какими угодно важными для вас.
Подробнее об этом мы поговорим в следующем параграфе.
Недостатки
И всё же нельзя назвать голден-тесты серебряной пулей на все случаи жизни: недостатки тоже присутствуют.
Трудоёмкая настройка
Подключение и поддержка тестов, настройка CI, написание и поддержание кода таким, чтобы его можно было тестировать, — всё это требует времени.
Подгрузка картинок
Если ваши виджеты содержат картинки, могут быть сложности с их загрузкой. Подробнее об этом мы расскажем в следующем параграфе.
Ограниченный скоуп
Не получится разгуляться в проверках поведения компонентов. Есть шанс не поймать какие-то баги, которые не влияют на визуал компонентов. Например, если в условной кнопке есть параметр onClick
, который никак не влияет на отображение, голден не сможет проверить, правильно ли передан этот параметр.
Ложные падения
Тесты могут упасть, если образец изображения неверный или устарел — например, кто-то после изменений забыл обновить образец, а наше CI по какой-то причине пропустило эту правку. Далее, когда другой разработчик запускает тест у себя, а он создаёт другую картинку, на первый взгляд это может показаться багом в самом приложении.
Такое случается достаточно редко, но может сильно ввести в заблуждение и отнять время на отладку.
Напишем тест
Библиотека flutter_test
— стандартная часть Flutter — предоставляет готовые инструменты для написания голден-тестов. Об этом почти не говорится в документации, а жаль: инструмент действительно неплохой.
Его синтаксис мало чем отличается от обычных Widget-тестов: каждый тест записывается в функцию testWidgets
и использует tester.pumpWidget
для отрисовки виджета. Аналогично можно описать различные взаимодействия — вроде нажатия на кнопку.
Отличие лишь в том, что этот тест проверяет: в самом конце он должен вызвать функцию expectLater (вместо expect
) и использовать матчер matchesGoldenFile.
Напишем наш первый тест, используя flutter_test
.
Давайте представим, что у нас есть небольшой виджет кнопки подтверждения ConfirmButton
, который мы написали для использования внутри нашего приложения на компонентах кнопок Flutter.
Код
1import 'package:flutter/material.dart';
2
3enum ConfirmButtonState { enabled, loading, disabled }
4
5class ConfirmButton extends StatelessWidget {
6 final VoidCallback onPressed;
7 final ConfirmButtonState state;
8 final String text;
9 final IconData? icon;
10 final Color? backgroundColor;
11 final Color? disabledColor;
12
13 const ConfirmButton({
14 required this.onPressed,
15 this.state = ConfirmButtonState.enabled,
16 this.text = 'Confirm',
17 this.icon,
18 this.backgroundColor,
19 this.disabledColor,
20 super.key,
21 });
22
23 @override
24 Widget build(BuildContext context) {
25 return FilledButton.icon(
26 onPressed: switch (state) {
27 ConfirmButtonState.enabled => onPressed,
28 ConfirmButtonState.loading || ConfirmButtonState.disabled => null,
29 },
30 style: ButtonStyle(
31 backgroundColor: WidgetStateProperty.resolveWith((states) {
32 if (states.contains(WidgetState.disabled)) {
33 return disabledColor;
34 }
35 return backgroundColor;
36 }),
37 ),
38 icon: AnimatedSize(
39 duration: const Duration(milliseconds: 100),
40 curve: Curves.easeOutCubic,
41 child: switch (state) {
42 ConfirmButtonState.enabled || ConfirmButtonState.disabled =>
43 icon == null ? const SizedBox.shrink() : Icon(icon),
44 ConfirmButtonState.loading => const SizedBox.shrink(),
45 },
46 ),
47 label: AnimatedSize(
48 duration: const Duration(milliseconds: 100),
49 curve: Curves.easeOutCubic,
50 child: switch (state) {
51 ConfirmButtonState.enabled ||
52 ConfirmButtonState.disabled => Text(text),
53 ConfirmButtonState.loading => SizedBox(
54 width: 24,
55 height: 24,
56 child: CircularProgressIndicator(strokeWidth: 2.0),
57 ),
58 },
59 ),
60 );
61 }
62}
У нашей кнопки:
3 состояния.

Может быть иконка.

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

Наша цель — проверить, что все параметры, влияющие на отображение кнопки, ведут себя как ожидается.
Для этого мы выполним восемь шагов.
Шаг №1: обновим .gitignore
Файлы упавших тестов не рекомендуется хранить в системе контроля версий. Сразу обновим .gitignore
, чтобы исключить их из репозитория.
1# Golden tests failures output
2**/failures/*.png
Шаг №2: подключим пакет flutter_test
Подключите пакет в раздел dev_dependencies
в файле pubspec.yaml
.
1dev_dependencies:
2 flutter_test:
3 sdk: flutter
Шаг №3: создадим в папке test/
папку goldens/
Это не обязательно, но так удобнее определять, что тут будут именно голдены.
Шаг №4: создадим файл теста
Создадим confirm_button_test.dart
и зададим ему структуру из group
и testWidgets
.
Код
1import 'package:flutter_test/flutter_test.dart';
2import 'package:golden_tests_handbook/components/confirm_button.dart';
3
4void main() {
5 group('$ConfirmButton', () {
6 testWidgets('enabled', (tester) async {
7
8 });
9 });
10}
Совет
Синтаксис $ConfirmButton
можно использовать, чтобы поддерживать актуальность названий на случай, если название компонента изменится.
Шаг №5: наполним тест содержанием
Проверим самое простое отображение кнопки с заданными обязательными параметрами text
и onPressed
. Также создадим специальную функцию wrapper
, в которую обернём нашу кнопку.
1 Widget wrapper(Widget child) => MaterialApp(
2 home: Center(
3 child: RepaintBoundary(
4 child: SizedBox(
5 width: 200,
6 height: 100,
7 child: Center(child: child),
8 ),
9 ),
10 ),
11 );
12
13 await tester.pumpWidget(
14 wrapper(ConfirmButton(text: 'Enabled', onPressed: () {})),
15 );
Эта функция нужна для того, чтобы полученный в результате выполнения теста файл-скриншот был фиксированного размера.
Зачем RepaintBoundary?
flutter_test
ищет ближайший предок типа RepaintBoundary
и записывает картинку его размера — здесь мы явно оборачиваем виджет в него, чтобы поход был не до RepaintBoundary
внутри Route
. Без этого картинка заняла бы размер всего приложения.
Шаг №6: добавим проверку
Здесь первым аргументом указываем желаемый виджет. Второй аргумент задаёт matcher
с путём до файла картинки.
1 await expectLater(
2 find.byType(ConfirmButton),
3 matchesGoldenFile('goldens/confirm_button.png'),
4 );
Финальный вид теста
1import 'package:flutter/material.dart';
2import 'package:flutter_test/flutter_test.dart';
3import 'package:golden_tests_handbook/components/confirm_button.dart';
4
5void main() {
6 group('$ConfirmButton', () {
7 testWidgets('enabled', (tester) async {
8 Widget wrapper(Widget child) => MaterialApp(
9 home: Center(
10 child: RepaintBoundary(
11 child: SizedBox(
12 width: 200,
13 height: 100,
14 child: Center(child: child),
15 ),
16 ),
17 ),
18 );
19
20 await tester.pumpWidget(
21 wrapper(ConfirmButton(text: 'Enabled', onPressed: () {})),
22 );
23
24 await expectLater(
25 find.byType(ConfirmButton),
26 matchesGoldenFile('goldens/confirm_button.png'),
27 );
28 });
29 });
30}
Совет
Рекомендуем под каждый отдельный компонент создавать свой файл с названием component_name_test.dart.
Шаг №7: сгенерируем файлы образцов консольной командой
Если бы мы запустили сейчас тест с помощью команды flutter test
, то получили бы ошибку:
1"No expectations provided. This may be a new test."
Что в переводе означает: «Ожидаемые значения не найдены. Возможно, это новый тест». Ошибка возникает потому, что нам ещё не с чем сравнивать текущую версию.
Чтобы создать эталон, используется специальный флаг --update-goldens
.
1flutter test --update-goldens
Он предназначен для создания и обновления эталонных изображений. Принцип работы флага заключается в пропуске этапа сравнения картинок: текущая версия изображения автоматически становится новым эталоном. Это означает, что при успешном выполнении теста без ошибок вы получите статус успешного прохождения во всех случаях.
Примечание
Если вы получили ошибку "No expectations provided. This may be a new test.", но уверены, что тест на самом деле уже существовал, — стоит разобраться: возможно, что-то случилось с названием файла.
Шаг №8: смотрим на нашу картинку
На выходе получаем в консоли вывод о том, что тесты успешно пройдены.
100:01 +1: All tests passed!
Также получаем картинку goldens/confirm_button.png
— по тому самому пути, который мы ранее указали в matchesGoldenFile
.
Очень важно
При любом создании и обновлении картинок обязательно нужно посмотреть на них и решить, можно ли их использовать как образец и всё ли корректно.
Если есть проблемы — нужно разобраться и поправить либо тест, либо проверяемый компонент.
В итоге наше изображение выглядит так:

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

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

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

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