💯

Flutterのゴールデンテストパッケージ、alchemist の解説

2025/02/12に公開

はじめに

こんにちは!
株式会社アンドエーアイの荻野と申します!
今回は「Flutter で alchemist を使ってゴールデンテストを簡単に実装する」と題して記事を書いていこうと思います!

モチベーション

以前、パッケージを使わない標準の flutter_test だけを使った、ゴールデンテストの実装を記事にしました。

https://zenn.dev/and_ai/articles/03e4bd6736a24b

しかしながら、Flutter標準だけだと、複数のサイズや複数の状態を検証しようとしたとき、どうしても実装が複雑になったりしてしまう問題があります。

今回はそれらをより便利な機能をより簡単に実現することができる alchemist というパッケージを使い、どのようなことができるのかを検証していきたいと思います。

alchemistとは

golden_toolkitにインスパイアされた、Flutterのゴールデンテストをより簡単に実装するためのパッケージです。
人気パッケージだったgolden_toolkitdiscontinuedとなったことで、その代替として最近注目されているパッケージとなります。

https://pub.dev/packages/alchemist

実装

実装環境

flutter: 3.24.4
alchemist: 0.10.0

プロジェクト作成 & インストール

いつも通りプロジェクトを作成し、alchemistをインストールします。

# 検証用アプリの作成
flutter create alchemist_app
# alchemistを追加
flutter pub add alchemist

テストを実装する

テストファイル作成

プロジェクトの/test内にゴールデンテスト用のコードを作成します。
このときファイル名はご多分に漏れず、文末が_test.dartとなるように注意します。
ユニットテストなどと分けるために、test/golden_test/my_widget_golden_test.dartなどとしておくと良いでしょう。

今回はFlutterのカウンターアプリのホーム画面を対象にするので、test/golden_test/my_home_page_golden_test.dartとしておきます。

テストコード実装

テストファイルを追加したところで、まずは以下のようにコードを実装してみます。

my_widget_golden_test.dart
void main() {
  group('MyHomePage Golden Tests', () {
    goldenTest(
      'renders correctly',
      fileName: 'my_home_page',
      builder: () => GoldenTestScenario(
        name: 'HomePage',
        constraints: BoxConstraints.tight(Size(320, 568)),
        child: MyHomePage(title: 'My Home Page'),
      ),
    );
  });
}

テストを実行する

ゴールデンイメージの生成

# ゴールデンイメージの生成
flutter test --update-goldens

実行するとテストファイルと同じディレクトリにgoldens/フォルダが生成され、
以下のように画像が格納されました。

test
├── golden_test
│   ├── goldens
│   │   ├── ci
│   │   │   └── my_home_page.png
│   │   └── macos
│   │       └── my_home_page.png
│   └── my_home_page_golden_test.dart

標準のgolden_testと異なるのは、goldens/の中に、ci/macos/というフォルダが生成され、それぞれに画像が格納されている点です。

生成されたゴールデンイメージ

ci macos

ciの方は以前の記事で確認した、標準のgolden_testで生成したイメージと同様の特徴が見て取れます。
一方でmacosの方はいつも私たちが見ている見た目がそのまま再現されています。

ゴールデンイメージと比較

--update-goldensを外して、テストを実行します。

# テストを全て実行
flutter test

# ゴールデンテストのみを実行
flutter test --tags golden

# ゴールデンテスト以外を実行
flutter test --exclude-tags golden

成功時

成功時ログ
flutter test                 
00:01 +0: loading /Users/user/Desktop/alchemist_app/test/golden_test/my_home_page_golden_test.dart                           
Warning: A tag was used that wasn't specified in dart_test.yaml.
  golden was used in:
    the test "MyHomePage Golden Tests renders correctly (variant: macOS)"
    the test "MyHomePage Golden Tests renders correctly (variant: CI)"

00:02 +2: All tests passed!     

失敗時

失敗時はfailuresフォルダがテストファイルと同じディレクトリに生成されます。
また、失敗時画像はフォルダが分かれておらず、プラットフォームテストの結果が格納されるようです。

失敗時ログ
flutter test
00:01 +0: loading /Users/user/Desktop/alchemist_app/test/golden_test/my_home_page_golden_test.dart                           
Warning: A tag was used that wasn't specified in dart_test.yaml.
  golden was used in:
    the test "MyHomePage Golden Tests renders correctly (variant: macOS)"
    the test "MyHomePage Golden Tests renders correctly (variant: CI)"

00:01 +0: MyHomePage Golden Tests renders correctly (variant: macOS)                                                        
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following assertion was thrown while running async test code:
Golden "goldens/macos/my_home_page.png": Pixel test failed, 0.35%, 668px diff detected.
Failure feedback can be found at /Users/user/Desktop/alchemist_app/test/golden_test/failures

When the exception was thrown, this was the stack:
#0      LocalFileComparator.compare (package:flutter_test/src/_goldens_io.dart:108:5)
<asynchronous suspension>
#1      MatchesGoldenFile.matchAsync.<anonymous closure> (package:flutter_test/src/_matchers_io.dart:121:32)
<asynchronous suspension>
<asynchronous suspension>
#3      _expect.<anonymous closure> (package:matcher/src/expect/expect.dart:123:26)
<asynchronous suspension>
<asynchronous suspension>
#5      expectLater.<anonymous closure> (package:flutter_test/src/widget_tester.dart:518:24)
<asynchronous suspension>
#6      FlutterGoldenTestAdapter.withForceUpdateGoldenFiles (package:alchemist/src/golden_test_adapter.dart:201:14)
<asynchronous suspension>
#7      FlutterGoldenTestRunner.run (package:alchemist/src/golden_test_runner.dart:110:9)
<asynchronous suspension>
#8      goldenTest.<anonymous closure> (package:alchemist/src/golden_test.dart:170:7)
<asynchronous suspension>
#9      testWidgets.<anonymous closure>.<anonymous closure> (package:flutter_test/src/widget_tester.dart:189:15)
<asynchronous suspension>
#10     TestWidgetsFlutterBinding._runTestBody (package:flutter_test/src/binding.dart:1027:5)
<asynchronous suspension>
<asynchronous suspension>
(elided 3 frames from dart:async and package:stack_trace)

The exception was caught asynchronously.
════════════════════════════════════════════════════════════════════════════════════════════════════
00:01 +0 -1: MyHomePage Golden Tests renders correctly (variant: macOS) [E]                                                 
  Test failed. See exception logs above.
  The test description was: renders correctly (variant: macOS)
  

To run this test again: /Users/user/fvm/versions/3.27.1/bin/cache/dart-sdk/bin/dart test /Users/user/Desktop/alchemist_app/test/golden_test/my_home_page_golden_test.dart -p vm --plain-name 'MyHomePage Golden Tests renders correctly (variant: macOS)'
00:01 +1 -1: Some tests failed.       
test
└── golden_test
    ├── failures
    │   ├── my_home_page_isolatedDiff.png
    │   ├── my_home_page_maskedDiff.png
    │   ├── my_home_page_masterImage.png
    │   └── my_home_page_testImage.png
    ├── goldens
    │   ├── ci
    │   │   └── my_home_page.png
    │   └── macos
    │       └── my_home_page.png
    └── my_home_page_golden_test.dart

このように、標準のみを利用して実装したテストコードと比較して、少ないコード量で、より高機能なゴールデンテストを実装できました。

その他機能

複数のシナリオをテストする

先ほどの実装コードだと、1画像につき1ウィジェットしかテストできないため、以下のようなケースにおいて一覧性がよくありません。

  • 複数の画面サイズでの見た目で確認したい。
  • ウィジェットのステート毎のパターンを確認したい。

このようなケースにおいて、GoldenTestGroupが便利です。
これは内部的にはTableを使用して実装されており、複数のGoldenTestScenarioをグリッド状に並べることが可能となります。

my_home_page_golden_test.dart
goldenTest(
  'renders correctly',
  fileName: 'my_home_page',
  builder: () => GoldenTestGroup(
    children: [
      //  画面サイズ別タイトルあり状態
      GoldenTestScenario(
        name: 'with title, 820 x 1180 (tablet)',
        constraints: BoxConstraints.tight(Size(820, 1180)),
        child: MyHomePage(title: 'My Home Page'),
      ),
      ...

      //  画面サイズ別タイトルなし状態
      GoldenTestScenario(
        name: 'no title, 820 x 1180 (tablet)',
        constraints: BoxConstraints.tight(Size(820, 1180)),
        child: MyHomePage(),
      ),
      ...
    ],
  ),
);

テスト内容をカスタマイズする

alchemist にはテスト内容をカスタマイズするAlchemistConfigというクラスがあります。

使い方

test/flutter_test_config.dartを作成し、以下のように実装することで、テスト全てに設定を適用することができます。
testExecutableを実装することで、テストの前に初期化処理を行うことが可能です。)

test/flutter_test_config.dart
import 'dart:async';

import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  return AlchemistConfig.runWithConfig(
    config: AlchemistConfig(
      forceUpdateGoldenFiles: false,
      theme: null,
      goldenTestTheme: GoldenTestTheme(
        backgroundColor: const Color(0xFF2b54a1),
        borderColor: const Color(0xFF3d394a),
        nameTextStyle: const TextStyle(fontSize: 18),
        padding: EdgeInsets.all(8),
      ),
      platformGoldensConfig: PlatformGoldensConfig(
          platforms: HostPlatform.values,
          obscureText: false,
          renderShadows: true,
          filePathResolver: (fileName, environmentName) {
            return 'goldens/$environmentName/$fileName.png';
          },
          theme: ThemeData.dark(),
        ),
      ciGoldensConfig: CiGoldensConfig(
          enabled: true,
          obscureText: true,
          renderShadows: false,
          filePathResolver: (fileName, environmentName) {
            return 'goldens/$environmentName/$fileName.png';
          },
          theme: ThemeData.dark(),
        ),
    ),
    run: testMain,
  );
}

また、一部のテストに設定を適用したい場合はAlchemistConfig.runWithConfig()を使いましょう。
current(),copyWith(),merge()などのメソッドを組み合わせ、現在の設定を部分的にオーバーライドすることも可能です。

AlchemistConfig.runWithConfig(
    config: AlchemistConfig.current().merge(myAlchemistConfig),
    run: () {
      group('MyHomePage Golden Tests', () {
        goldenTest(
            ...
        )
      });
    },
);

AlchemistConfig クラス

プロパティ デフォルト値 説明
bool forceUpdateGoldenFiles false Trueのとき、--update-goldensタグがない時も常にゴールデンイメージを生成します。
ThemeData? theme null テストに使用するテーマを指定できます。null のときはデフォルトのThemeData.light()を使用します。
GoldenTestTheme? theme null 生成されるゴールデンイメージのスクリーンショット以外の部分のテーマを指定できます。画像内で表示される「シナリオ名のスタイル」、シナリオ間の「余白」と「ボーダー」、「背景色」を変更できます。
PlatformGoldensConfig platformGoldensConfig const PlatformGoldensConfig() CI 以外のプラットフォームでテストを行うときの設定を行います。
CiGoldensConfig ciGoldensConfig const CiGoldensConfig() CI でテストを行うときの設定を行います。

PlatformGoldensConfig & CiGoldensConfig クラス

プロパティ デフォルト値 説明
bool enabled true CI テスト、もしくはプラットフォームテストを実行するかどうかを設定できます。
bool obscureText CI: true
Platform: false
true のとき、テキストを矩形でレンダリングします。生成されたゴールデンイメージの項目で CI テストのフォントが矩形で表示されていたのはこの設定によるものです。
bool renderShadows CI: true
Platform: false
true のとき、シャドウを単純な塗りつぶしでレンダリングします。生成されたゴールデンイメージの項目で CI テストのシャドウが黒一色で表示されていたのはこの設定によるものです。
FilePathResolver filePathResolver <_defaultFilePathResolver> 生成したイメージを保存する相対パスを設定できます。デフォルトはゴールデンイメージの生成の項目の通りです。
ThemeData? theme null テストタイプ毎のテーマを設定できます。null のときは AlchemistConfig のデフォルトもしくは設定テーマが適用されます。

PlatformGoldensConfig クラスのみ

プロパティ デフォルト値 説明
Set<HostPlatform> platforms すべて 実行するプラットフォームを限定できます。

ジェスチャの実行

goldenTest()の引数whilePerformingを使用することで、
ボタンなど、ジェスチャ時のイメージを出力したい場合も対応可能です。
pressの他、longPressscrollのジェスチャにも対応しています。

goldenTest(
    whilePerforming: press(find.byType(FloatingActionButton)),
    ...
)

ゴールデンイメージのリサイズ

goldenTest()の引数constraintsを使用することで、そのサイズ制約よって画像がリサイズされます。制約を本来のウィジェットサイズよりも小さくした場合、はみ出す部分はクロップされます。

400x400 4000x4000

ポンピングメソッドの拡張

pumpBeforeTest

goldenTest()は、デフォルトでtester.pumpAndSettle() を使用してウィジェットツリーを描画します。
これは設定した時間の間(デフォルト:100ms)だけウィジェットをpump()し続けるメソッドですが、これでは例えば非同期処理中の表示を確認したい場合や、100ms以上表示を遅延させる場合に対応できません。
このような場合、goldenTest()pumpBeforeTestを設定することで、ゴールデンテスト実行前に、ウィジェットツリーを描画する動作をオーバーライドできます。

goldenTest(
  pumpBeforeTest: (tester) async {
    await tester.pumpAndSettle(Duration(milliseconds: 2000));
  },
  builder: () => FutureBuilder(
    future: Future.delayed(Duration(milliseconds: 1000))
        .then((_) => 'completed'),
    initialData: 'waiting...',
    builder: (context, snapshot) {
      return Text(snapshot.data ?? '');
    },
  ),
)

また、ゴールデンイメージ内で画像データを読み込みたい場合、本パッケージが提供するprecacheImages()pumpBeforeTestに渡すことで、ツリー内のすべての画像をプリロードし、生成されるゴールデンファイルに表示されるようにすることができます。

pumpWidget

goldenTest()pumpWidget引数を指定することでウィジェットを描画する際の動作をオーバーライドし、テスト対象のウィジェットを任意の数のウィジェットでラップすることができます。
例えば下記のように、描画したいウィジェットが特定の親要素(InheritedWidget等)に依存する場合に便利です。

goldenTest(
    pumpWidget: (tester, widget) async => {
        await tester.pumpWidget(
          MyInheritedWidget(
            child: widget,
          ),
        ),
      },
    ...
)

ScaleFactor の設定

goldenTest()の引数scaleFactorを設定することで、テストの scale factor を設定できます。
また、GoldenTestScenariowithTextScaleFactorコンストラクタを使用することで、そのシナリオ毎に scale factor を指定できます。

goldenTest(
  'renders correctly',
  fileName: 'my_home_page',
  // テストのデフォルト scale factor を指定する。
  textScaleFactor: 1.0,
  builder: () => GoldenTestGroup(
    children: [
      //  withTextScaleFactor コンストラクタを使用する。
      GoldenTestScenario.withTextScaleFactor(
        name: 'with title, 820 x 1180 (tablet), x2.0',
        textScaler: TextScaler.linear(2.0),
        constraints: BoxConstraints.tight(Size(820, 1180)),
        child: MyHomePage(title: 'My Home Page'),
      ),
      ...
    ],
  )
)

最後に

Alchemist を使用すると、標準のみを利用して実装したテストコードと比較して、少ないコード量で、より高機能なゴールデンテストを実装できることがわかりました。

また、メンテナンスやアップデートも問題なく行われていて、Flutterにゴールデンテストを実装する予定の方にとっては強い味方になると感じました。

もし、プロジェクトに VRT の導入を検討されている場合は是非ともご検討ください。

ここまで当記事を読んでいただきありがとうございました!

PR

アンドエーアイでは事業拡大のため、即戦力エンジニアを募集中!Flutterだけでなく、インフラ、Web、ネイティブ開発などの知識を持つ方も歓迎します。最新技術を追い、チームに積極的に貢献できる方をお待ちしています!

採用ページ
https://iwantyou.andai.net/

エンジニア採用ページ
https://iwantyou.andai.net/engineer

参考資料

https://pub.dev/packages/alchemist

アンドエーアイTechBlog

Discussion