7.4. Голден-тестирование: пакет alchemist

В предыдущем параграфе мы познакомились с основами голден-тестирования с помощью библиотеки flutter_test, рассмотрели их преимущества и недостатки, написали первый тест и столкнулись с основным недостатком flutter_test — платформозавсимостью тестов.

В этом параграфе углубим знания:

  1. Рассмотрим пакет alchemist, который решает проблему платформозависимости.

  2. Напишем тест с использованием alchemist, научимся подгружать картинки и делать варианты тестов.

  3. Дадим прикладные рекомендации по применению тестов и настройке 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

7.4_1.webp

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

7.4_2.webp

Здесь и далее в повествовании такие тесты будут называться macos — для примера конкретной платформы.

Прежде чем двинуться дальше — ненадолго остановимся и ответим на вопросы, которые могли у вас возникнуть.

Но тут все равно квадратики в CI варианте — в чём отличие от flutter_test?

Да, визуально разницы и правда нет. И, вероятно, задумка команды Flutter была как раз в этом, но кажется это не работает.

То есть, во flutter_test голдены по умолчанию используют шрифт Ahem, и он всё еще может по-разному отображаться на разных платформах, а CI тесты Alchemist используют специальный BlockedTextPaintingContext, который гарантированно отрисуется одинаково.

Я подключил Alchemist, но в моем платформенном тесте все еще квадратики. Почему?

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

Решение:

  • либо вынести тест в отдельны пакет от шрифта

  • либо сам шрифт вынести в отдельный пакет

Alchemist точно решает проблему платформозависимости?

Далее в этом параграфе мы будем придерживаться решения от alchemist. В большинстве случаев его достаточно, но используя его, мы все же идем на компромисс: точность проверки падает.

CI тесты alchemist отличаются от платформенных как минимум:

  • квадратиками вместо шрифтов;

  • видом теней;

  • отсутвием эффектов вроде блюра.

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

Почему мы рассматриваем alchemist, а не например golden_toolkit?

Да, среди сообщества это самые популярные библиотеки. Но мы для себя выбрали alchemist по следующим причинам:

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

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

  3. Авторы golden_toolkit сделали официальное заявление, что они перестают поддерживать библиотеку, и пометили ее как {red}(discontinued) на pub.dev

Напишем тест с использованием 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}

Получаем два голдена.

7.4

Шаг №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.4

Шаг №7: добавим сценарии выключенной кнопки и разных цветов

Проверим далее:

  1. Состояние “disabled”

  2. Параметры цветов 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            ),

Получаем ещё несколько голденов. Выглядят отлично!

7.4

Шаг №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}

Получаем в этот раз уже четыре голдена: две такие же без иконки и две новые для варианта с иконкой.

7.4

Финальный вид теста
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.

7.4

Реализация 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);

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

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

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

  3. В конце возвращаем null — здесь можно было бы вернуть коллбек для очистки состояния жестов, но нам это не нужно.

Запустим тест, получаем годдены.

7.4

Пример неудачного теста

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

Представим, что у нас есть кнопка, запускаюущая эффект конфетти на основе пакета flutter_confetti.

7.4

Реализация 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}

Получаем голдены.

7.4

Но! Если мы теперь попробуем запустить тест через 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.

А в диффах мы обнаружим следующее

7.4

То есть, при каждом новом запуске создаётся разная картинка. Так происходит потому, что в самом пакете 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.

При его запуске у нас получится не два голдена, как раньше, а целых восемь!

7.4

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

7.4

Реализация вспомогательной функции 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}

Подгрузка картинок

Бывает, что в наших виджетах используются какие-то картинки:

  1. Локальные

  2. Сетевые

Рассмотрим подробнее, как их тестировать.

Локальные картинки

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

  • из 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}

Результат:

7.4_14.

Пробуем написать и запустить тест.

Код
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}

Получаем пустоту.

7.4

Добавим precacheImages

1void main() {
2  goldenTest(
3    'Color vision test',
4    fileName: 'color_vision_test',
5    pumpBeforeTest: precacheImages, // <--

Получаем голдены.

7.4

Ограничение precacheImages

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

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

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

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

Настройка 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

Разберём содержимое файла:

  1. name: Flutter tests CI — объявляет название workflow;

  2. on: — условия запуска;

  3. steps: — действия, которые нужно выполнить в рамках прогона:

    • 📚 Git Checkout — монтирует ваш репозиторий в окружении CI.

    • 🐦 Setup Flutter — устанавливает Flutter указанной версии.

    • 📦 Install Dependencies — выполняет pub get.

    • 🧪 Run Tests — запускает flutter test.

    • 📁 Save diffs — сохраняет диффы.

Теперь при коммитах или PR получаем примерно такие прогоны:

7.4

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

7.4

7.4

А сейчас — перейдём к полезным фишкам и советам по органинизации голден-тестирования и будем закругляться.

Рекомендации

Настройка IDE

Можно пользоваться исключительно консолью, но ведь правда будет удобней, если мы сможем запускать команды прямо из IDE?

Вот как это сделать в разных редакторах.

VSCode

В Visual Studio Code можно настроить запуск тестов из каждого файла по отдельности.

7.4

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

  2. Внутри нее в 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

Тут можно отфильтровать только по пути, но опции запуска внутри самого файла настроить не получится.

7.4

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

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

  2. Внутри нее создайте файл 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>
    

Получится вот такая конфигурация запуска:

7.4

Фильтрация тестов

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

Полный код параграфа можно найти тут.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E
Предыдущий параграф7.3. Голден-тестирование: основы
Следующий параграф7.5. Профилирование: нативные девтулзы