Последний golden-набор, который мы приняли по наследству, состоял из 214 изображений. Всё зелёное, около восьми месяцев, на протяжении нескольких сотен пул-реквестов. А потом кто-то открыл один PNG, чтобы посмотреть, что именно он защищает.

Каждый глиф был чёрным прямоугольником.

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

В этом примере — вся проблема : они одновременно самый дешёвый способ поймать регрессию во Flutter и самая убедительная подделка. Зелёная галочка в обоих случаях выглядит одинаково.

Главное

  • Golden — это снимок дерева рендера при одном логическом размере, одной теме, одной локали и одном масштабе текста, и больше ничего. Это не скриншот вашего приложения на телефоне.
  • В тестовом окружении нет шрифтов приложения. Текст без доступного семейства отрисовывается сплошными прямоугольниками — намеренно, чтобы раскладка оставалась детерминированной. Без загрузки шрифтов вы golden-тестируете геометрию и называете это визуальным тестом.
  • Тени сводятся к жёстким блокам (debugDisableShadows): фреймворк убирает размытие. Объём в картинке остаётся, а радиус размытия, spread и градиент — нет, поэтому регрессии теней невидимы.
  • Пиксели зависят от движка, хостовой ОС и версии Flutter. Набор, сгенерированный на macOS, падает на Linux в CI. Фиксируйте тулчейн и генерируйте на той же платформе, где идёт CI.
  • tester.view — это поверхность, а не устройство. По умолчанию тестовая поверхность 800×600 логических пикселей при pixel ratio 3; без явного размера каждый экран телефона рендерится в разрешении, которого ни у кого нет.
  • pumpAndSettle зависает на спиннере — неопределённый индикатор прогресса планирует кадры бесконечно. Состояниям загрузки нужен pump(duration).
  • Матрица умножается. 12 экранов × 2 темы × 3 ширины × 2 масштаба текста × 2 локали — это 288 изображений, которые перегенерируются на каждом обновлении Flutter. Выбирайте оси, которые вы реально будете смотреть.
  • Ценность golden-набора равна вероятности, что кто-то посмотрит на диф. Всё остальное — обвязка. Коммит с --update-goldens, который никто не смотрел, превращает тест в lock-файл.

Что на самом деле делает golden-тест

Один тест, одно изображение, одно сравнение:

testWidgets('cart is empty', (tester) async {
  await pumpScreen(tester, const CartScreen(items: []));

  await expectLater(
    find.byType(CartScreen),
    matchesGoldenFile('goldens/cart_empty.light.390.ts1.0.en.png'),
  );
});

flutter test --update-goldens записывает этот PNG и проходит. Каждый следующий прогон декодирует его и сравнивает попиксельно с тем, что только что отрисовал движок. Не допуск, не перцептивный хеш — точное равенство, пока вы не подмените goldenFileComparator. При падении компаратор по умолчанию кладёт четыре артефакта в каталог failures/ рядом с файлом теста: эталон, новый рендер, изолированный диф и маскированный диф. (При несовпадении размеров пишутся только первые два.) Это и есть ваше ревью дифа, и им место в .gitignore.

Две детали в этом сниппете важнее, чем кажется.

find.byType(CartScreen) не снимает CartScreen. Матчер идёт вверх от найденного элемента до ближайшего предка RepaintBoundary и снимает этот слой. Обычно этот предок — всё приложение, поэтому golden-снимки так часто получаются полноэкранными, с debug-баннером в углу. Оберните проверяемый виджет в RepaintBoundary — и вы снимете ровно то, что имели в виду.

Отрисованное изображение — не ваше приложение. Это flutter_tester на вашей хост-машине: без системных шрифтов, без системного UI, без клавиатуры, с подменённой сетью, отвечающей только 400, с тенями, сведёнными к жёстким блокам, и на той поверхности, какая досталась тесту. Прошедший golden говорит, что вот это дерево, при этом размере, с этими входами не изменилось. Это действительно ценно — и намного уже, чем «интерфейс работает».

Пять причин, по которым первый же golden врёт

1. Шрифтов нет, поэтому каждый глиф — квадрат

flutter test запускается без шрифтов, объявленных в pubspec.yaml. Текст без доступного семейства откатывается на тестовый шрифт, каждый глиф которого — залитый прямоугольник: такое решение делает раскладку детерминированной и избавляет от файлов шрифтов, а ещё означает, что в PNG в вашем репозитории нет ни одной читаемой буквы.

Лечится это файлом test/flutter_test_config.dart, который Flutter выполняет перед наборами тестов в этом каталоге. Всё объявленное там — загрузка шрифтов, собственный компаратор, настройка часов — действует на каждый тест ниже:

// test/flutter_test_config.dart
import 'dart:async';
import 'package:flutter/services.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  await _loadFonts();
  await testMain();
}

Future<void> _loadFonts() async {
  // The family name must match what the theme asks for. If `fontFamily` is
  // null, register the same faces as 'Roboto' — that is what Material falls
  // back to, and a mismatch loads nothing and fails silently.
  // Both faces are declared under `assets:` in pubspec.yaml, and `flutter test`
  // can load them from there — a pure `dart test` cannot.
  final text = FontLoader('Roboto')
    ..addFont(rootBundle.load('assets/fonts/Roboto-Regular.ttf'))
    ..addFont(rootBundle.load('assets/fonts/Roboto-Medium.ttf'));

  // Icons are fonts too. Skip this and every `Icon` in every golden is a box.
  final icons = FontLoader('MaterialIcons')
    ..addFont(rootBundle.load('assets/fonts/MaterialIcons-Regular.otf'));

  await Future.wait([text.load(), icons.load()]);
}

Две вещи здесь ломаются тихо, а не громко. Неверное имя семейства загружает шрифт, который ни один виджет не запрашивает, — и ничего не меняется. А google_fonts скачивает свои начертания во время выполнения, чего тестовое окружение не делает: положите в репозиторий те .ttf, которые действительно поставляете, иначе ваши golden-снимки навсегда ждут сетевого вызова, которого не будет.

Есть и менее очевидное следствие — и оно бьёт по тем, кто этот раздел пропустил: с квадратными глифами не видно, что написано, и исчезает любое свойство, которое нормализует тестовый шрифт. Семейство, насыщенность, кернинг, форма глифа, реальные ascender и descender заменяются одинаковыми квадратами, поэтому переход с Roboto на Inter рисует те же прямоугольники, а правка текста с тем же числом символов не двигает ничего. Число символов и размер шрифта прямоугольники всё-таки двигают — более длинная русская строка даёт более широкую полосу, — так что переполнение и изменение размера всё-таки видны. Невидимо только то, что делает шрифт читаемым в продакшене.

2. Тени сведены к блокам, поэтому размытия не существует

debugDisableShadows не убирает тени, а заменяет их сплошными блоками. BoxShadow.toPaint() отбрасывает mask filter, а поверхность Material рисует свой объём жёстким контуром (strokeWidth: elevation * 2), так что в golden попадает неразмытая форма цветом тени. Тестовый биндинг включает его потому, что рендеринг теней не гарантированно совпадает пиксель в пиксель от версии к версии и даже от прогона к прогону.

Следствие уже, чем «теней нет», и коварнее: карточка на месте, а её радиус размытия, spread и градиент — нет, поэтому регрессия тени — размытие, выросшее вдвое, spread, сломавший раскладку, — отрисуется в golden идентично. Собственные тесты фреймворка Flutter выставляют debugDisableShadows = false, когда проверяют именно тени, и возвращают его в конце теста; биндинг проверяет, что флаг вернулся к значению по умолчанию (true), с которым работает остальной набор. Это правильный подход: включайте настоящие тени только там, где тень и есть предмет теста, принимайте там нестабильность и больше нигде флаг не трогайте.

3. Каждое сетевое изображение — заглушка

Image.network в тесте попадает в подменённый HttpClient, который отвечает пустым 400. Что будет дальше, зависит от виджета: без errorBuilder виджет Image в debug пробрасывает ошибку загрузки и тест падает с NetworkImageLoadException; с ним golden молча фиксирует вашу заглушку. cached_network_image и подобные пакеты нуждаются в собственной подмене. Либо оберните вызовы в HttpOverrides (так делают старые рецепты), либо сделайте то, что переживёт рефакторинг: принимайте в виджете ImageProvider и передавайте в тесте MemoryImage из PNG 1×1.

Аватарки и фотографии товаров — это основная визуальная масса реального экрана, поэтому набор, где все они заглушки, тестирует раскладку вокруг картинок и ничего про сами картинки.

4. Часы, локаль и случайность — не ваши

DateTime.now() в виджете, DateFormat из intl без фиксированной даты, Random без сида, uuid, сгенерированный при сборке, — каждый из них делает golden, который проходит сегодня и падает во второй вторник какого-нибудь месяца, или падает только в CI, где локаль машины другая.

Лечится это не в тесте, а в виджете: принимайте функцию now или Clock, принимайте Locale, принимайте сид. Это стоит сделать в любом случае — виджет, который читает системные часы напрямую, нельзя протестировать ни на одном уровне, ни golden-тестом, ни каким-либо ещё. Выставьте Intl.defaultLocale явно в flutter_test_config.dart, и вы уберёте целый класс падений «локально работает, в CI не тот язык»; для дат не на английском вызывайте ещё initializeDateFormatting(locale): DateFormat бросает исключение для локали, данных о которой у него нет.

5. pumpAndSettle не возвращается на состоянии загрузки

pumpAndSettle прокачивает кадры, пока не останется запланированных. Неопределённый CircularProgressIndicator планирует кадры, пока крутится, поэтому вызов падает по таймауту со знаменитым «pumpAndSettle timed out», а golden состояния загрузки — того самого состояния, на которое QA никогда не смотрит, потому что приложение в нём невозможно задержать, — оказывается тем, который вы не можете написать.

// Not this: it waits for an animation that never ends.
// await tester.pumpAndSettle();

await pumpScreen(tester, const CartScreen.loading());
await tester.pump(const Duration(milliseconds: 100)); // one fixed phase of the spinner
await expectLater(find.byType(CartScreen), matchesGoldenFile('goldens/cart_loading.light.390.ts1.0.en.png'));

Теперь golden снимает спиннер на 100 мс, одинаково, всегда. Это нормально: вы проверяете, что окружающая раскладка держится, пока крутится нечто неопределённое, а не сам спиннер.

Зелёный набор из 214 файлов — не сильный набор

Всё перечисленное даёт один и тот же симптом: тесты проходят, проверяя меньше, чем вы думаете. Ничто из этого не проявляется ни падением, ни предупреждением, ни снижением покрытия. Golden-набор необычен тем, что его режим отказа — тишина, поэтому единственный способ доверять ему — открыть PNG и убедиться, что в нём есть то, что, по вашему мнению, там должно быть. Сделайте это один раз, в первый день, для каждого вида снимка в наборе. Это десять минут, и это самые полезные десять минут во всей настройке.

Настройка, которая делает golden-снимки воспроизводимыми

Четыре решения, все скучные, все принимаются один раз:

Зафиксируйте тулчейн. .fvmrc в репозитории, CI читает тот же файл. Обновление Flutter меняет рендеринг текста, темы по умолчанию, переименования CardTheme в CardThemeData и отрисовку теней, поэтому все golden-снимки в репозитории меняются разом. Это не повод не обновляться; это повод сделать обновление осознанным коммитом, в котором нет ничего, кроме перегенерированных эталонов, и это сказано в сообщении.

Генерируйте на той платформе, где работает CI. Сглаживание и хинтинг текста различаются между macOS и Linux — один и тот же виджет того же размера даёт разные пиксели. Набор, сгенерированный на ноутбуке разработчика с macOS, падает на Linux-раннере, и обычное «лечение» — это --update-goldens там, где его быть не должно. Гоняйте golden-тесты локально тоже на Linux, в том же контейнере, что и CI, если команда смешанная.

Никогда не позволяйте CI перегенерировать. Защита — одна строка:

- uses: subosito/flutter-action@v2
  with:
    flutter-version-file: .fvmrc   # one version, repo and CI
    cache: true
- run: flutter test
# A "helpful" --update-goldens in CI or a stale golden written by a bad merge
# would make the suite green while comparing nothing. Fail instead. `git diff`
# alone misses untracked files, which is exactly what a regenerated golden is.
- run: test -z "$(git status --porcelain -- '*.png')"

Последняя строка — не паранойя. Самый частый способ, которым golden-набор перестаёт что-либо проверять, — это шаг CI, добавленный в два часа ночи во время красной сборки, который обновляет эталоны вместо того, чтобы упасть.

Задавайте поверхность явно. По умолчанию тестовая поверхность — 800×600 логических пикселей при pixel ratio 3. Проверьте на ней экран телефона и получите раскладку небольшого планшета, при том что MediaQuery приложения будет настаивать на обратном. Одна вспомогательная функция решает это для всех тестов, а если вы не можете прочитать имя golden-файла и узнать все пять осей, на которых он отрисован, имя неправильное:

// test/helpers/golden.dart
Future<void> pumpScreen(
  WidgetTester tester,
  Widget child, {
  Size size = const Size(390, 844), // iPhone 14 — pick your own baselines
  double textScale = 1,
  Locale locale = const Locale('en'),
  Brightness brightness = Brightness.light,
}) async {
  tester.view.physicalSize = size * 3;   // logical size × device pixel ratio
  tester.view.devicePixelRatio = 3;
  addTearDown(tester.view.reset);        // never leak a view into the next test

  await tester.pumpWidget(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: brightness == Brightness.dark ? AppTheme.dark : AppTheme.light,
      locale: locale,
      // Without the app's own delegates a locale it does not support resolves
      // back to English.
      localizationsDelegates: AppLocalizations.delegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: RepaintBoundary(
        child: Builder(
          builder: (context) => MediaQuery(
            // Merge, don't replace: a fresh MediaQueryData has size zero,
            // devicePixelRatio 1 and no padding.
            data: MediaQuery.of(context).copyWith(
              textScaler: TextScaler.linear(textScale),
            ),
            child: child,
          ),
        ),
      ),
    ),
  );
  await tester.pump();
}

Здесь легко ошибиться в двух деталях. MediaQueryData(...) вместо copyWith создаёт новый объект с size: Size.zero и devicePixelRatio: 1, так что MediaQuery.sizeOf врёт всему, что ниже. А MaterialApp, собранный внутри этой функции, сводит любую неподдерживаемую локаль обратно к английскому, если не передать ему делегаты приложения (за них здесь отвечает AppLocalizations), иначе ось локали молча тестирует английский.

addTearDown(tester.view.reset) — не опция. tester.view глобален для файла тестов, поэтому один тест, выставивший ширину 320pt, протекает во все следующие, а падение всплывает привязанным к невинному тесту дальше в том же файле.

Что снимать как golden и сколько стоит матрица

Ловушка в том, что матрица выглядит бесплатной. Она не бесплатна: каждое изображение — это файл, который надо перегенерировать, файл, который надо посмотреть, и файл, который добавит строку в диф вашего следующего несвязанного пул-реквеста.

СлойЧто покрываетИзображений
Компоненты дизайн-системы — кнопка, поле, бейдж, карточка, элемент спискаКаждое состояние × светлая/тёмная тема, на одной ширине20–40
Пять экранов, которые важны3 ширины (320/390/768) × светлая/тёмная30
Пустое состояние, ошибка и загрузкаСостояния, ради которых никто не открывает приложение6–10
Масштаб текста 2.0, тёмная тема, 320ptДва самых плотных компонента дизайн-системы4
ЛокальЯзык с самыми длинными строками, одна ширина, два экрана с наибольшим риском переполнения4

Это набор примерно из 60–90 изображений: около двадцати секунд CI и пул-реквест, визуальный диф которого человек действительно может посмотреть. Версия, где всё умножено на все оси — 12 экранов × 2 темы × 3 ширины × 2 масштаба × 2 локали, — это 288 изображений, и 288 изображений не смотрит никто.

Три вещи снимать как golden вообще не стоит:

  • Анимации и переходы. Golden снимает один кадр в один фиксированный момент. Он скажет, что кадр на 100 мс выглядит так же, а не что анимация плавная, и дополнительная польза поверх начального и конечного состояний близка к нулю.
  • Всё с реальным контентом внутри. Длинные названия товаров, цены, даты, сгенерированные идентификаторы — каждый из них — повод для golden измениться по невизуальной причине. Снимайте как golden только оболочку на фиксированных фикстурах, а рендеринг контента проверяйте widget-тестом с утверждением по тексту, а не по пикселям.
  • Целые сценарии. Три экрана на одном изображении дают втрое меньше обозримости, чем один экран на изображение.

А самое показательное, что стоит снимать как golden, по нашему опыту — вовсе не экран: это дизайн-система на 320pt, в тёмной теме, с текстом на 200%. Именно в этой комбинации реальные приложения ломаются, вручную проверить её по каждому компоненту почти невозможно, и именно для этого golden хорош.

Чего golden-тесты не проверяют

Golden — это картинка. Она ничего не знает про смысл, и несколько распространённых допущений стоит поправить явно.

Поведение. Кнопка, подключённая к не тому колбэку, рендерится идентично. Как и форма, которая больше не валидирует, список, потерявший контроллер прокрутки, диалог, который не закрывается. Это widget-тесты — tap, pump, проверка того, что произошло, — и никакое количество пикселей их не заменяет.

Доступность. Дерево семантики для скриншота невидимо: отсутствующие подписи, неправильные роли, зона нажатия, урезанная до 24pt, контраст ниже 4.5:1 для обычного текста (3:1 для крупного). Flutter поставляет нужные проверки, и они дешёвые:

testWidgets('cart meets a11y guidelines', (tester) async {
  final semantics = tester.ensureSemantics();
  addTearDown(semantics.dispose);

  await pumpScreen(tester, const CartScreen(items: []));

  // Tap targets (48dp Android / 44pt iOS) and text contrast.
  await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
  await expectLater(tester, meetsGuideline(textContrastGuideline));

  // The label and role the golden cannot see. `isSemantics` checks only the
  // values you pass; `matchesSemantics` would also require every other flag
  // to be false, and a real Material button sets `hasEnabledState`.
  // (`containsSemantics` is the same matcher on Flutter before 3.40.)
  expect(
    tester.getSemantics(find.byType(CheckoutButton)),
    isSemantics(label: 'Checkout', isButton: true, hasTapAction: true),
  );
});

Рендеринг на реальном устройстве. Golden-тесты идут на хостовом движке, поэтому не говорят, что на iOS сделает с тем же деревом и чем отличается системный шейпинг текста. Если разница важна — это integration_test на устройстве со скриншотом, снятым там же: медленнее, нестабильнее и единственное, что отвечает на вопрос.

Подтормаживания. Бюджет кадра измеряется на profile-сборке на реальном устройстве, а не выводится из PNG.

Правила, от которых зависит, стоило ли это всё

Ценность набора пропорциональна вероятности, что живой человек посмотрит на диф. Это вся отдача от вложений, и это свойство культуры, а не техники.

Итак, конкретно:

  1. --update-goldens только в коммите, сообщение которого называет визуальное изменение. «Update goldens» в сообщении означает, что диф не смотрели. «Update goldens: кнопка оформления заказа стала 48pt, состояние ошибки на новой палитре» означает, что смотрели.
  2. Изменение golden в экране, которого вы не касались, — это находка, а не шум. Так набор и работает. Сваливать это в «chore: update goldens» — значит выключать набор.
  3. Если ревьюер не видит изображения, сломалась обвязка. Настройте в CI загрузку артефактов failures/ или приложите диф к пул-реквесту. Ревью, состоящее из «поверь, пиксели изменились», — не ревью.
  4. Удаляйте golden-снимки, которые вы бы просто проштамповали. Набор из сорока изображений, которые все смотрят, лучше четырёхсот, которые одобряют одним кликом. Метрика — не покрытие; метрика — просмотренное покрытие.
  5. Когда golden меняются в несвязанных пул-реквестах, остановитесь и найдите причину — незафиксированная версия Flutter, незагруженный шрифт, DateTime.now(), добравшийся до виджета. Нестабильность в golden почти никогда не свойство механизма; это реальный источник недетерминированности, о котором вы теперь знаете.

Чек-лист golden-набора

От пустого репозитория до набора, которому можно доверять, за день-два.

Сделать рендеринг честным

  • Шрифты приложения загружены в test/flutter_test_config.dart, с тем именем семейства, которое действительно просит тема
  • Шрифт иконок загружен тоже — иначе каждая Icon это квадрат
  • Сетевые изображения заменены на in-memory заглушки
  • Часы, локаль и случайные сиды внедрены, а не читаются из окружения
  • Состояния загрузки рендерятся через pump(duration), а не pumpAndSettle

Сделать воспроизводимым

  • Версия Flutter зафиксирована в репозитории и читается из CI
  • Golden-снимки генерируются на Linux — той же платформе, где идёт CI
  • Шаг CI падает при любом изменённом или неотслеживаемом эталоне, так что тихой перегенерации не будет
  • failures/ в gitignore и при этом загружается артефактом сборки для ревью
  • Тестовая поверхность задана явно: размер, pixel ratio, масштаб текста, локаль

Сделать обозримым

  • Каждый golden обёрнут в RepaintBoundary, чтобы снимал нужный виджет
  • Пять осей закодированы в имени файла: предмет, тема, ширина, масштаб текста, локаль
  • Меньше ~90 изображений или внятная причина, почему больше
  • Правило: обновление golden — отдельный коммит с названной визуальной причиной
  • Кто-то открыл хотя бы один PNG каждого вида и убедился, что в нём есть текст

Сколько это стоит через полгода

Настройка набора — день, может два, большая часть на шрифты и CI-обвязку. Написать каждый golden — минуты. Регулярная цена та, которую никто не оценивает: каждое обновление Flutter перегенерирует каждое изображение. С матрицей выше это коммит из 60–90 PNG, два-четыре раза в год, каждому нужно собственное обоснование. Если вы не можете позволить себе такое ревью, вы не можете позволить себе набор — потому что непросмотренная перегенерация неотличима от отсутствия теста, только занимает дольше.

Взамен вы получаете регрессии, которые не ловит ни один другой тип теста: пустое состояние, потерявшее отступы в тёмной теме, русский перевод, выехавший из кнопки на 320pt, дизайн-систему, которая поплыла, потому что кто-то поменял один TextStyle в теме. И тот случай, из-за которого мы вообще этим занялись, — агент или коллега меняет общий компонент и искренне не видит четырёх экранов, которые сломал. Golden — это самый близкий аналог «глаз» для изменения кода, который есть во Flutter, и ровно этот аргумент мы приводили в статье о том, почему агенты не справляются с Flutter.

Коротко

Golden-тест — это попиксельное сравнение с PNG, и всё, что делает его полезным, касается условий этого сравнения. Загрузите шрифты, загрузите шрифт иконок, задайте поверхность, зафиксируйте тулчейн, перегенерируйте на той платформе, где работает CI, и никогда не позволяйте машине одобрять диф.

А потом держите набор достаточно маленьким, чтобы человек всё ещё в него смотрел. Режим отказа golden-тестирования — не нестабильный тест, а 214 изображений, которые все проходят, потому что сравнивают квадраты с квадратами, и зелёная галочка, которая восемь месяцев рассказывает одну и ту же удобную историю.

Источники

  • matchesGoldenFile — что именно снимает матчер и куда подключается goldenFileComparator: api.flutter.dev
  • FontLoader — регистрация начертания по имени семейства внутри теста: api.flutter.dev
  • debugDisableShadows — объявлен в painting/debug.dart, выставляется тестовым биндингом и включается обратно в собственных тестах Flutter на тени: github.com/flutter/flutter
  • TestFlutterView — physicalSize, devicePixelRatio и reset(): api.flutter.dev
  • WidgetTester.pumpAndSettle — «repeatedly calls pump with the given duration until there are no longer any frames scheduled»: api.flutter.dev
  • AccessibilityGuideline — androidTapTargetGuideline, iOSTapTargetGuideline, textContrastGuideline: api.flutter.dev
  • Golden-снимки фреймворка, которые проходят проверку в Skia Gold, и репозиторий flutter/goldens для тех, что не могут его использовать: skia.org, github.com/flutter/goldens
  • alchemist — каталоги эталонов по платформам, групповые golden из нескольких виджетов, конфигурация с учётом CI: pub.dev
  • golden_screenshot — скриншот-тесты с превью устройств и эталонами под конкретную платформу: pub.dev
  • golden_toolkit — пакет, который до сих пор советует большинство туториалов по golden-тестам; поддержка прекращена, последний релиз — февраль 2023: pub.dev
  • spot — golden-подобные widget-тесты, сравнивающие сериализованное дерево виджетов вместо пикселей, когда нестабильность пикселей — большая проблема: pub.dev
  • integration_test — скриншоты с реального устройства (на Android сначала вызовите convertFlutterSurfaceToImage()), единственный ответ на вопрос «как это выглядит на телефоне»: integration_test.dart

Частые вопросы

Golden-тест отрисовывает виджет и сравнивает результат попиксельно с PNG, лежащим в репозитории. PNG создаётся прогоном flutter test с флагом update-goldens, а каждый следующий прогон падает, если рендеринг отличается. Во Flutter это самый дешёвый способ поймать визуальную регрессию, потому что он идёт в том же тестовом процессе, что и обычные widget-тесты, и занимает секунды.
Потому что тестовое окружение не загружает шрифты, объявленные в pubspec. Текст без доступного семейства откатывается на тестовый шрифт, каждый глиф которого — залитый прямоугольник: так раскладка остаётся детерминированной и не нужны файлы шрифтов. Зарегистрируйте настоящие шрифты через FontLoader в test/flutter_test_config.dart, включая шрифт иконок, и golden начнут рисовать реальный текст.
Пиксели зависят от хостовой операционной системы и версии Flutter, а не только от дерева виджетов. Сглаживание и хинтинг текста различаются между macOS и Linux, поэтому набор, сгенерированный на ноутбуке разработчика, не совпадёт с Linux-раннером. Генерируйте эталоны на той же платформе и той же зафиксированной версии Flutter, что использует CI, и считайте обновление Flutter поводом намеренно перегенерировать все изображения.
Да. Вся ценность golden в том, что его изменение видно на ревью, а для этого изображение должно лежать в репозитории рядом с кодом, в той же ветке, меняясь в том же коммите, что и всё, что изменило рендеринг. Исключение — артефакты падения, которые пишет компаратор: эталон, новый рендер и два дифа — в gitignore и в артефакты CI.
Нет. pumpAndSettle прокачивает кадры, пока не останется запланированных, а индетерминированный CircularProgressIndicator планирует кадры бесконечно, поэтому вызов падает по таймауту. Используйте tester.pump с явной длительностью, чтобы снять одну фиксированную фазу спиннера. Вы проверяете, что раскладка вокруг спиннера держится, а её спиннер как раз и не меняет.
Нет, они покрывают разное. Golden проверяет пиксели, поэтому не скажет, что кнопка вызывает не тот колбэк, что перестала работать валидация или что диалог не закрывается: всё это рендерится идентично. Поведение покрывают widget-тесты, а семантику и зоны нажатия — проверки вроде meetsGuideline, которых скриншот вообще не видит.
Достаточно, чтобы человек всё ещё смотрел на дифы, а это на практике 60–90 изображений: компоненты дизайн-системы в их состояниях и обеих темах, несколько важных экранов на трёх ширинах, пустое состояние, состояние ошибки и состояние загрузки. Умножение каждого экрана на каждую тему, ширину, масштаб текста и локаль даёт сотни изображений, которые одобряют одним кликом, а набор, который одобряют одним кликом, не проверяет ничего.
Начните с того, что поставляет flutter_test: matchesGoldenFile, flutter_test_config.dart для загрузки шрифтов и небольшая вспомогательная функция, задающая размер поверхности. Пакеты вроде alchemist, golden_screenshot и spot добавляют каталоги эталонов по платформам, превью устройств или сравнение по дереву виджетов и стоят своих денег, когда вы упёрлись в проблему, которую каждый из них решает. Поддержка golden_toolkit, который до сих пор советует большинство туториалов, прекращена в 2023 году.

Если вы выстраиваете тестовый слой с нуля, смежные решения — в нашем разборе процесса разработки Flutter-приложения, а проблема модели состояния, из-за которой половина интерфейса оказывается непроверяемой, — в статье про пять состояний, которых не может быть. Общая мысль во всех трёх случаях одна: проверка, которая проходит, — это не то же самое, что проверка, которая смотрит.


Максим — старший инженер-программист в Nerdy Production, Flutter-агентстве, которое делает и поддерживает приложения в финтехе, медицине и ритейле. Мы также занимаемся разработкой приложений на Flutter для команд, которым важно понять, что именно сравнивает их тестовый набор, до, а не после следующего релиза.