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

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

Шаг №7: добавим сценарии выключенной кнопки и разных цветов
Проверим далее:
-
Состояние “disabled”
-
Параметры цветов
backgroundColor
иdisabledColor
Добавим ещё три сценария.
Код
1 GoldenTestScenario(
2 name: 'disabled',
3 child: Padding(
4 padding: const EdgeInsets.all(8.0),
5 child: ConfirmButton(
6 text: 'Disabled',
7 onPressed: () {},
8 state: ConfirmButtonState.disabled,
9 ),
10 ),
11 ),
12 GoldenTestScenario(
13 name: 'green button',
14 child: Padding(
15 padding: const EdgeInsets.all(8.0),
16 child: ConfirmButton(
17 text: 'Green',
18 onPressed: () {},
19 backgroundColor: Colors.green,
20 ),
21 ),
22 ),
23 GoldenTestScenario(
24 name: 'green disabled button',
25 child: Padding(
26 padding: const EdgeInsets.all(8.0),
27 child: ConfirmButton(
28 text: 'Green',
29 onPressed: () {},
30 state: ConfirmButtonState.disabled,
31 disabledColor: Colors.green[900],
32 ),
33 ),
34 ),
Получаем ещё несколько голденов. Выглядят отлично!

Шаг №8: добавим вариант теста с иконкой
Нам оставлось проверить, что кнопка умеет показывать иконку. Кажется, это было бы полезно сразу для всех сценариев, поэтому создадим отдельный вариант теста. Не будем дублировать код, а постараемся переиспользовать большую его часть с помощью простого цикла.
Код
1void main() {
2 for (final icon in [null, Icons.done]) {
3 goldenTest(
4 // Правим название теста в зависимости от наличия иконки
5 '$ConfirmButton ${icon == null ? '' : 'with icon'}',
6 fileName: 'confirm_button${icon == null ? '' : '_with_icon'}',
7 pumpBeforeTest: pumpNTimes(1, Durations.medium1),
8 builder:
9 () => GoldenTestGroup(
10 columns: 1,
11 children: [
12 GoldenTestScenario(
13 name: 'enabled',
14 child: Padding(
15 padding: const EdgeInsets.all(8.0),
16 child: ConfirmButton(
17 text: 'Enabled',
18 // Добавили проброс иконки
19 icon: icon,
20 onPressed: () {},
21 ),
22 ),
23 ),
24 // Остальные сценарии ...
25 ],
26 ),
27 );
28 }
29}
Получаем в этот раз уже четыре голдена: две такие же без иконки и две новые для варианта с иконкой.

Финальный вид теста
1import 'package:alchemist/alchemist.dart';
2import 'package:flutter/material.dart';
3import 'package:golden_tests_handbook/components/confirm_button.dart';
4
5void main() {
6 for (final icon in [null, Icons.done]) {
7 goldenTest(
8 '$ConfirmButton ${icon == null ? '' : 'with icon'}',
9 fileName: 'confirm_button${icon == null ? '' : '_with_icon'}',
10 pumpBeforeTest: pumpNTimes(1, Durations.medium1),
11 builder:
12 () => GoldenTestGroup(
13 columns: 1,
14 children: [
15 GoldenTestScenario(
16 name: 'enabled',
17 child: Padding(
18 padding: const EdgeInsets.all(8.0),
19 child: ConfirmButton(
20 text: 'Enabled',
21 icon: icon,
22 onPressed: () {},
23 ),
24 ),
25 ),
26 GoldenTestScenario(
27 name: 'loading',
28 child: Padding(
29 padding: const EdgeInsets.all(8.0),
30 child: ConfirmButton(
31 text: 'Loading',
32 icon: icon,
33 onPressed: () {},
34 state: ConfirmButtonState.loading,
35 ),
36 ),
37 ),
38 GoldenTestScenario(
39 name: 'disabled',
40 child: Padding(
41 padding: const EdgeInsets.all(8.0),
42 child: ConfirmButton(
43 text: 'Disabled',
44 icon: icon,
45 onPressed: () {},
46 state: ConfirmButtonState.disabled,
47 ),
48 ),
49 ),
50 GoldenTestScenario(
51 name: 'green button',
52 child: Padding(
53 padding: const EdgeInsets.all(8.0),
54 child: ConfirmButton(
55 text: 'Green',
56 icon: icon,
57 onPressed: () {},
58 backgroundColor: Colors.green,
59 ),
60 ),
61 ),
62 GoldenTestScenario(
63 name: 'green disabled button',
64 child: Padding(
65 padding: const EdgeInsets.all(8.0),
66 child: ConfirmButton(
67 text: 'Green',
68 icon: icon,
69 onPressed: () {},
70 state: ConfirmButtonState.disabled,
71 disabledColor: Colors.green[900],
72 ),
73 ),
74 ),
75 ],
76 ),
77 );
78 }
79}
Интерактивный тест
Иногда в тестах требуется эмулировать какое-то взаимодейтсвие пользователя с компонентами.
Рассмотрим на примере с ConfirmButton
.
Где-то в приложении на её основе создали кнопку, которая по нажатию переходит в состояние loading
, и спустя какое-то время возвращается в состояние enabled
.

Реализация DelayedConfirmButton
1class DelayedConfirmButton extends StatefulWidget {
2 const DelayedConfirmButton({super.key});
3
4 @override
5 State<DelayedConfirmButton> createState() => DelayedConfirmButtonState();
6}
7
8class DelayedConfirmButtonState extends State<DelayedConfirmButton> {
9 ConfirmButtonState buttonState = ConfirmButtonState.enabled;
10
11 Timer? timer;
12
13 @override
14 void dispose() {
15 timer?.cancel();
16 timer = null;
17 super.dispose();
18 }
19
20 @override
21 Widget build(BuildContext context) {
22 return ConfirmButton(
23 state: buttonState,
24 icon: Icons.done,
25 onPressed: () {
26 setState(() {
27 buttonState = ConfirmButtonState.loading;
28 });
29
30 // Симулируем долгую операцию через таймер.
31 // В реальном мире тут мог бы быть, например, запрос в сеть.
32 timer?.cancel();
33 timer = Timer(Durations.medium4, () {
34 setState(() {
35 buttonState = ConfirmButtonState.enabled;
36 });
37 });
38 },
39 );
40 }
41}
Чтобы написать тест для интерактивного компонента, в функции goldenTest
предусмотрен параметр whilePerforming
. Он принимает в себя коллбек, в котором можно описать взаимодействие с компонентом с помощью WidgetTester
.
Посмотрим на пример кода для DelayedConfirmButton
1goldenTest(
2 'Press on $DelayedConfirmButton',
3 fileName: 'pressed_delayed_confirm_button',
4 whilePerforming: (WidgetTester tester) async {
5 await tester.tap(find.byType(DelayedConfirmButton));
6 await tester.pump(Duration(milliseconds: 250));
7 await tester.pump(Duration(milliseconds: 250));
8 await tester.pump(Duration(milliseconds: 250));
9 return null;
10 },
11 builder:
12 () => Padding(
13 padding: const EdgeInsets.all(8.0),
14 child: DelayedConfirmButton(),
15 ),
16);
Вот, что здесь происходит:
-
Делаем нажатие на кнопку — через вызов метода
tap()
-
Ждём выполнения анимации перехода к состянию загрузки — чтобы в зафиксированной картинке по итогу теста была кнопка в состоянии
loading
. Для этого нужно пропустить несколько кадров с определенной задержкой, вызвав методpump()
. -
В конце возвращаем
null
— здесь можно было бы вернуть коллбек для очистки состояния жестов, но нам это не нужно.
Запустим тест, получаем годдены.

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

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

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

То есть, при каждом новом запуске создаётся разная картинка. Так происходит потому, что в самом пакете flutter_confetti
используются рандомно генерируемые числа, и написать тест на такой компонент без правок в сам пакет не получится.
Варианты тестов
Ранее мы писали тесты на компонент в стандартной теме Flutter. Но что, если:
-
наши пользователи используют тёмную тему?
-
или направление текста в их языке не слева направо (LTR), а справа налево (RTL)?
Неплохо было бы покрыть тестами и эти случаи.
Вы можете написать и свои варианты тестов в зависимости от ваших потребностей, но разберём на примере этих двух запросов.
Пример
Сначала давайте посмотрим на сам тест и результат, который хочется получить.
Код
1void main() {
2 makeGoldenTest(
3 description: 'Confirm button variants',
4 fileName: 'confirm_button_variants',
5 cases: [
6 GoldenTestScenario(
7 name: 'enabled',
8 child: Padding(
9 padding: const EdgeInsets.all(8.0),
10 child: ConfirmButton(
11 text: 'Enabled',
12 icon: Icons.done,
13 onPressed: () {},
14 ),
15 ),
16 ),
17 ],
18 );
19}
Внешне почти никаких отличий, кроме того, что здесь используем не функцию goldenTest
, а нашу вспомогательную makeGoldenTest
.
При его запуске у нас получится не два голдена, как раньше, а целых восемь!

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

Реализация вспомогательной функции makeGoldenTest
Выше мы уже затронули пример с несколькими вариантами тестов, когда написали тест для компонента с иконкой и без.
1void main() {
2 for (final icon in [null, Icons.done]) {
3 goldenTest(
4 // Правим название теста в зависимости от наличия иконки
5 '$ConfirmButton ${icon == null ? '' : 'with icon'}',
6 fileName: 'confirm_button${icon == null ? '' : '_with_icon'}',
Внутри makeGoldenTest
используется этот же принцип для создания вариантов.
1void makeGoldenTest({
2 required String description,
3 required String fileName,
4 required List<GoldenTestScenario> cases,
5}) {
6 for (final isDarkTheme in [false, true]) {
7 final themeName = isDarkTheme ? 'dark' : 'light';
8
9 for (final textDirection in [TextDirection.ltr, TextDirection.rtl]) {
10 final textDirectionName = textDirection.name;
11
12 // Логика вариантов...
13 }
14 }
15}
Дальше пишем реализацию
Код
1// Логика вариантов...
2// Модификация имени файла и описания теста
3final modifiedFileName = '$fileName.$themeName.$textDirectionName';
4final modifiedDescription =
5 '$description | $themeName | $textDirectionName';
6
7// Переопределение темы и конфигурации Alchemist
8final theme = isDarkTheme ? ThemeData.dark() : ThemeData.light();
9final modifiedConfig = AlchemistConfig.current().merge(
10 AlchemistConfig(
11 theme: theme,
12 goldenTestTheme:
13 AlchemistConfig.current().goldenTestTheme?.copyWith(
14 backgroundColor: theme.scaffoldBackgroundColor,
15 )
16 as GoldenTestTheme?,
17 ),
18);
19
20AlchemistConfig.runWithConfig(
21 config: modifiedConfig,
22 run:
23 // Создание теста на каждый вариант
24 () => goldenTest(
25 modifiedDescription,
26 fileName: modifiedFileName,
27 builder:
28 // Использование Directionality для поддержки RTL
29 () => Directionality(
30 textDirection: textDirection,
31 child: GoldenTestGroup(columns: 1, children: cases),
32 ),
33 ),
34);
Финальный вид функции
1import 'package:alchemist/alchemist.dart';
2import 'package:flutter/material.dart';
3
4void makeGoldenTest({
5 required String description,
6 required String fileName,
7 required List<GoldenTestScenario> cases,
8}) {
9 for (final isDarkTheme in [false, true]) {
10 final themeName = isDarkTheme ? 'dark' : 'light';
11
12 for (final textDirection in [TextDirection.ltr, TextDirection.rtl]) {
13 final textDirectionName = textDirection.name;
14
15 // Логика вариантов...
16 // Модификация имени файла и описания теста
17 final modifiedFileName = '$fileName.$themeName.$textDirectionName';
18 final modifiedDescription =
19 '$description | $themeName | $textDirectionName';
20
21 // Переопределение темы и конфигурации Alchemist
22 final theme = isDarkTheme ? ThemeData.dark() : ThemeData.light();
23 final modifiedConfig = AlchemistConfig.current().merge(
24 AlchemistConfig(
25 theme: theme,
26 goldenTestTheme:
27 AlchemistConfig.current().goldenTestTheme?.copyWith(
28 backgroundColor: theme.scaffoldBackgroundColor,
29 )
30 as GoldenTestTheme?,
31 ),
32 );
33
34 AlchemistConfig.runWithConfig(
35 config: modifiedConfig,
36 run:
37 // Создание теста на каждый вариант
38 () => goldenTest(
39 modifiedDescription,
40 fileName: modifiedFileName,
41 builder:
42 // Использование Directionality для поддержки RTL
43 () => Directionality(
44 textDirection: textDirection,
45 child: GoldenTestGroup(columns: 1, children: cases),
46 ),
47 ),
48 );
49 }
50 }
51}
Подгрузка картинок
Бывает, что в наших виджетах используются какие-то картинки:
-
Локальные
-
Сетевые
Рассмотрим подробнее, как их тестировать.
Локальные картинки
Это могут быть картинки:
-
из Flutter:
Image.asset
,Image.file
,Image.memory
-
из пакета flutter_svg:
SvgPicture.asset
,SvgPicture.file
,SvgPicture.memory
,SvgPicture.string
По умолчанию тесты не показывают никакие картинки, даже локальные. Чтобы это исправить можно воспользоваться фукнцией precacheImages, передав ее в pumpBeforeTest
внутри goldenTest
.
Представим пример компонента, который должен нарисовать цветовой тест Ишихары.
Код
Код:
1import 'package:flutter/material.dart';
2
3class IshiharaImage extends StatelessWidget {
4 const IshiharaImage({super.key});
5
6 static const double size = 250.0;
7
8 @override
9 Widget build(BuildContext context) =>
10 Image.asset('assets/ishihara.png', height: size, width: size);
11}
Результат:

Пробуем написать и запустить тест.
Код
1void main() {
2 goldenTest(
3 'Color vision test',
4 fileName: 'color_vision_test',
5 builder:
6 () => GoldenTestGroup(
7 columns: 1,
8 children: [
9 GoldenTestScenario(name: 'Ishihara', child: IshiharaImage()),
10 ],
11 ),
12 );
13}
Получаем пустоту.

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

Ограничение precacheImages
К сожалению, сейчас у этой функции есть ограничение, и она всегда внутри вызывает pumpAndSettle
. Как было показано выше pumpAndSettle
не подойдёт для тестов с бесконечными анимациями — и сейчас в таких случаях написать тест не получится.
Сетевые картинки
Это могут быть картинки:
-
из Flutter:
Image.network
-
из пакета
flutter_svg
:SvgPicture.network
-
из пакета
cached_network_image
:CachedNetworkImage
Сейчас в открытом доступе нет готовых инструментов для написания тестов с такими картинками, поэтому написать тест на использующие их виджеты не получится.
Настройка CI c GitHub Actions
В финальном варианте с использованием alchemist
, никаких дополнительных настроек CI не требуется — достаточно запускать тесты при каждом пуше в пул-реквесты и на main
ветку.
1flutter test
Важно
Не используйте флаг --update-goldens
на CI, потому что тогда все тесты будут всегда проходить.
Далее разберём настройку на конкретном примере бесплатного и популярного CI от GitHub.
Шаг №1: оставляем только CI-тесты
Для этого мы уже выше сконфигугрировали файл flutter_test_config.dart
для наших тестов.
Тогда мы сможем воспользоваться конструкцией flutter test --dart-define=CI=true
для того, чтобы передать в код флаг о запуске в CI окружении.
Код
1import 'dart:async';
2
3import 'package:alchemist/alchemist.dart';
4import 'package:flutter/material.dart';
5
6Future<void> testExecutable(FutureOr<void> Function() testMain) async {
7 const isRunningInCi = bool.fromEnvironment('CI', defaultValue: false);
8
9 return AlchemistConfig.runWithConfig(
10 config: AlchemistConfig.current().copyWith(
11 goldenTestTheme:
12 GoldenTestTheme.standard().copyWith(backgroundColor: Colors.white)
13 as GoldenTestTheme?,
14 platformGoldensConfig: const PlatformGoldensConfig(
15 enabled: !isRunningInCi,
16 ),
17 ),
18 run: testMain,
19 );
20}
Шаг №2: создаём конфигурационный файл
Теперь напишем конфигурационный файл для самого CI.
Для этого создадим в корне репозитория папку .github/workflows/
, и в нее положим файл tests_ci.yaml
.
Этот workflow запустит прогон тестов в вашем GitHub репозитории при:
-
каждом коммите в главную ветку;
-
каждом Pull Request в главную ветку.
Код
1name: Flutter tests CI
2
3on:
4 push:
5 pull_request:
6 branches:
7 - main
8 - master
9
10jobs:
11 test:
12 runs-on: ubuntu-latest
13 steps:
14 - name: 📚 Git Checkout
15 uses: actions/checkout@v4
16
17 - name: 🐦 Setup Flutter
18 uses: subosito/flutter-action@v2
19 with:
20 flutter-version: '3.32.1'
21 channel: 'stable'
22
23 - name: 📦 Install Dependencies
24 shell: bash
25 run: flutter pub get
26
27 - name: 🧪 Run Tests
28 shell: bash
29 run: flutter test --no-pub --dart-define=CI=true test/goldens/
30
31 - name: 📁 Save diffs
32 if: always()
33 uses: actions/upload-artifact@v4
34 with:
35 name: diffs
36 path: test/goldens/failures
37 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?
Вот как это сделать в разных редакторах.
VSCode
В Visual Studio Code можно настроить запуск тестов из каждого файла по отдельности.

-
В корне проекта создайте папку
.vscode/
(если ее еще нет) -
Внутри нее в
launch.json
добавьте следующуюю конфигурацию:1{ 2 // Use IntelliSense to learn about possible attributes. 3 // Hover to view descriptions of existing attributes. 4 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 "version": "0.2.0", 6 "configurations": [ 7 { 8 "name": "golden", 9 "request": "launch", 10 "type": "dart", 11 "codeLens": { 12 "for": [ 13 "run-file", 14 "run-test", 15 "run-test-file", 16 "debug-file", 17 "debug-test", 18 "debug-test-file", 19 ], 20 "title": "${debugType} golden", 21 }, 22 "args": [ 23 "--update-goldens" 24 ] 25 }, 26 ] 27}
Android Studio/IntelliJ IDEA
Тут можно отфильтровать только по пути, но опции запуска внутри самого файла настроить не получится.

Нужно добавить такую конфигурацию запуска
-
В корне проекта создайте папку
.run/
(если ее еще нет) -
Внутри нее создайте файл
update_goldens.run.xml
:1<component name="ProjectRunConfigurationManager"> 2 <configuration default="false" name="update_goldens" type="FlutterTestConfigType" factoryName="Flutter Test"> 3 <option name="testDir" value="$PROJECT_DIR$/test/goldens/" /> 4 <option name="useRegexp" value="false" /> 5 <option name="additionalArgs" value="--update-goldens" /> 6 <method v="2" /> 7 </configuration> 8</component>
Получится вот такая конфигурация запуска:
Фильтрация тестов
Мы создали файл dart_test.yaml
и указали в нем тег golden
.
Flutter позволяет фильтровать тесты по подобным тегам
1# Выполнить все Golden тесты.
2flutter test --tags golden
3
4# Выполнить тесты, кроме Golden.
5flutter test --exclude-tags golden
Но несмотря на то, что такой подход работает, у него есть большой минус — время выполнения.
Основная часть времени прогона большинства тестов — это их компиляция. Используя теги, тесты хоть и отфильтруются, но все равно пройдут этап компиляции, а значит на это уйдёт время — часто очень большое, больше, чем само исполнение.
Чтобы избежать этого, мы рекомендуем фильтровать тесты по пути:
1# Выполнить все Golden тесты.
2flutter test test/goldens
3
4# Выполнить тесты, кроме Golden.
5flutter test test/unit
Итак, вот вы и узнали все нюансы работы с голден-тестами во Flutter.
Как видите, это мощный инструмент для автоматизации визуального тестирования, который защитит ваши компоненты от регрессии.
А с помощью пакета alchemist
вы сможете развернуть голден-тестирование на проекте с большим количеством разработчиков — он решает решает ключевую проблему платформозависимости, разделяя тесты на CI- и локальные, а также упрощает настройку и повышает читаемость кода.
Полный код параграфа можно найти тут.