🧪

【Flutter】Golden Test に触れてみる

2024/12/19に公開

初めに

今回は Golden Test について簡単にまとめてみたいと思います。

記事の対象者

  • Flutter 学習者
  • Golden Test について簡単に知りたい方

目的

今回は、Flutter の Golden Test について簡単に触れて、具体的なメリットなどを見ていくことが目的です。

Golden Test とは

Writing a golden file test for package:flutter には以下のような記述があります。

Golden file tests for package:flutter use Flutter Gold for baseline and version management of golden files. This allows for golden file testing on Linux, Windows, macOS and Web, which accounts for the occasional subtle rendering differences between these platforms.

(日本語訳)
Flutterのゴールデンファイルテストでは、ゴールデンファイルの基準とバージョン管理にFlutter Goldを使用します。これにより、Linux、Windows、macOS、およびWeb上でゴールデンファイルテストを実行でき、これらのプラットフォーム間で発生する場合がある微妙なレンダリングの違いに対応できます。

また、flutter/devtools/TESTING.md には以下のような記述があります。

DevTools is test covered by multiple types of tests, all of which are run on the CI for each DevTools PR / commit:

  1. Unit tests
    - tests for business logic
  2. Widget tests
    - tests for DevTools UI components using mock or fake data
    - some widget tests may contain golden image testing
  3. Partial integration tests
    - tests for DevTools UI and business logic with a real VM service connection to a test app
  4. Full integration tests
    - Flutter web integration tests that run DevTools as a Flutter web app and connect it to a real test app
    on multiple platforms (Flutter mobile, Flutter web, and Dart CLI)

(日本語訳)
DevToolsは、さまざまな種類のテストによってカバーされており、これらはすべてDevToolsの各PR/コミットでCI上で実行されます:

  1. ユニットテスト
    ビジネスロジックのテスト
  2. ウィジェットテスト
    モックまたはフェイクデータを使用したDevToolsのUIコンポーネントのテスト
    一部のウィジェットテストにはゴールデンイメージテストが含まれる場合があります
  3. 部分的な統合テスト
    テストアプリとの実際のVMサービス接続を使用して、DevToolsのUIとビジネスロジックをテスト
  4. 完全な統合テスト
    DevToolsをFlutter Webアプリとして実行し、実際のテストアプリに接続するFlutter Web統合テスト
    複数のプラットフォーム(Flutterモバイル、Flutter Web、Dart CLI)で実行されます

また、多くの記事で Golden Test は Visual Regression Test であるとの記載がありました。 VisualRegression Test については What is Visual Regression Testing?
から以下を引用します。

Regression testing is done whenever there are any code changes made by the developers to check if it has not broken any other functionalities of the software. In the same way, visual regression testing checks whether the code changes have not affected the software’s visual interface.

Visual regression testing helps to catch visual errors or defects that are caused due to improper styles, alignments, and fonts. Common visual issues include overlapping modules, hidden or missing elements, elements that render off-screen, etc.

(日本語訳)
リグレッションテスト(回帰テスト)は、開発者によってコード変更が行われた際に、他の機能が壊れていないことを確認するために実施されます。同様に、ビジュアルリグレッションテストは、コードの変更がソフトウェアのビジュアルインターフェースに影響を与えていないかを確認します。

ビジュアルリグレッションテストは、不適切なスタイル、配置、フォントによって引き起こされる視覚的なエラーや欠陥を検出するのに役立ちます。よくある視覚的な問題には、モジュールの重なり合い、隠れたまたは欠落した要素、画面外に描画される要素などがあります。

まとめると以下のようなことが言えそうです。

  • Golden Test の目的はスタイルやレイアウト、フォントの崩れや欠陥といった視覚的な問題を検知すること
  • Golden Test のは Visual Regression Test (VRT) の一種であり、VRT は「コードの変更がソフトウェアの見た目に影響していないかを確認する」テスト
  • 各プラットフォーム間の微妙な見た目の差異を検知できる
  • Flutter では Widget Test の一種として扱われる

準備

まずは golden_toolkitパッケージの最新バージョンを pubspec.yamlに記述します。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  golden_toolkit: ^0.15.0

または

以下をターミナルで実行

flutter pub add golden_toolkit

次にルートディレクトリに dart_test.yaml ファイルを追加し、以下のように内容を編集します。

dart_test.yaml
tags:
  golden:

次に test ディレクトリに flutter_test_config.dart を作成して、以下のように変更しておきます。
loadAppFonts メソッドでは、テスト実行時にフォントを表示するように設定しています。 Golden Test ではデフォルトではフォントを表示せず、フォントが ⬛︎ のような四角で表示されます。

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

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

これで準備は完了です。

実装

今回は以下の三つの例をもとに Golden Test の実装方法や効果についてみていきたいと思います。

  1. 実装の差異が小さいケース①
  2. 実装の差異が小さいケース②
  3. プラットフォームごとの差異があるケース
  4. デザインの崩れを検知するケース

1. 実装の差異が小さいケース①

まずは実装の差異が小さいケースの一つ目についてみていきます。
以下の二つの画像でどの部分が変更されたかわかるでしょうか?
Before

After

よく見ればわかるかもしれませんが、一見すると同じように見えます。
この章ではこのようなケースの Golden Test を実装していきます。

まずはテスト対象となる Widget を作成していきます。
コードは以下のようにしておきます。
実行結果はこの章の冒頭で示した通りのような見た目で、テキストを三つ並べたシンプルなUIになっています。

lib/simple_widget.dart
import 'package:flutter/material.dart';

class SimpleWidget extends StatelessWidget {
  const SimpleWidget({super.key});

  
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Sample Text 1',
              style: TextStyle(fontSize: 30),
            ),
            SizedBox(height: 20),
            Text(
              'Sample Text 2',
              style: TextStyle(fontSize: 30),
            ),
            SizedBox(height: 20),
            Text(
              'Sample Text 3',
              style: TextStyle(fontSize: 30),
            ),
          ],
        ),
      ),
    );
  }
}

次にこの Widget のテストを実装していきます。
test/simple_widget ディレクトリに simple_widget_test.dart ファイルを作成します。
テストファイルは基本的にテスト対象の Widget が含まれるファイルの名前に test をつけたファイル名にしておきます。(例:テスト対象のファイル名が hoge.dart の場合は hoge_test.dart としておく)

コードは以下のようにしておきます。

import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:sample_flutter/golden_test/simple_widget.dart';

Future<void> main() async {
  testGoldens('SimpleWidget Golden Test', (tester) async {
    final devices = [
      Device.iphone11,
      Device.tabletPortrait,
      Device.tabletLandscape,
    ];
    final builder = DeviceBuilder()
      ..overrideDevicesForAllScenarios(devices: devices)
      ..addScenario(widget: const SimpleWidget(), name: 'simple_widget');

    await tester.pumpDeviceBuilder(builder);

    await screenMatchesGolden(tester, 'simple_widget');
  });
}

以下で詳しくみていきます。

以下では main 関数の中で testGoldens メソッドを実行しています。
testGoldens メソッドでは第1引数にテストの名前を指定し、第2引数にテストの実行内容を定義するコールバック関数を指定します。

Future<void> main() async {
  testGoldens('SimpleWidget Golden Test', (tester) async {

以下ではテストを実行する対象デバイスをリストで定義しています。
iPhone 11、縦向きタブレット、横向きタブレットの3つのデバイスでテストを実行するようにします。

final devices = [
  Device.iphone11,
  Device.tabletPortrait,
  Device.tabletLandscape,
];

以下では DeviceBuilder で複数のデバイスで Widget をビルドするシナリオを追加しています。
addScenario メソッドではテスト対象の SimpleWidget を「simple_widget」という名前でシナリオに追加しています。

final builder = DeviceBuilder()
  ..overrideDevicesForAllScenarios(devices: devices)
  ..addScenario(widget: const SimpleWidget(), name: 'simple_widget');

以下では、pumpDeviceBuilder メソッドで定義したビルダーをテスト環境に適用し、 Widget をレンダリングします。
screenMatchesGolden メソッドで現在のレンダリング結果が事前に保存された Golden Image と一致するかを検証します。ここで画像が一致しない場合はエラーとして出力されます。

await tester.pumpDeviceBuilder(builder);
await screenMatchesGolden(tester, 'simple_widget');

テストコードの追加ができたら以下のコマンドを実行します。
これで、 Golden Image を更新しつつテストを実行できます。

flutter test --update-goldens

なお、特定のファイルのみテストを実行する場合は以下を実行すれば、そのファイルのみのテストが実行されます。

flutter test { テストファイルのパス } --update-goldens

上記のコマンドを実行すると goldens ディレクトリに、各プラットフォームで Widget を表示させた際のUIが以下の画像のように保存されます。
ディレクトリの名前からもわかる通り、これで生成されるのが Golden Image です。
この Golden Image を基準として、UIに変更があったかどうかをテストしていきます。

次に、 SimpleWidget に少し変更を加えてみます。
コードを以下のように変更します。

import 'package:flutter/material.dart';

class SimpleWidget extends StatelessWidget {
  const SimpleWidget({super.key});

  
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Sample Text 1',
              style: TextStyle(fontSize: 30),
            ),
            SizedBox(height: 20),
            Text(
              'Sample Text 2',
-             style: TextStyle(fontSize: 30),
+             style: TextStyle(fontSize: 32),
            ),
            SizedBox(height: 20),
            Text(
              'Sample Text 3',
              style: TextStyle(fontSize: 30),
            ),
          ],
        ),
      ),
    );
  }
}

この状態で以下のコマンドを実行します。

flutter test

すると以下のような文言とともにテストが失敗するかと思います。
「Golden Image と一致するかどうかテストを行なった結果、 0.42% 異なる部分が見つかった」ということがわかります。このように、ピクセルごとにテストを行うため、非常に小さな差異も見つけることができます。

══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following assertion was thrown while running async test code:
Golden "goldens/simple_widget.png": Pixel test failed, 0.42%, 16569px diff detected.

また、テストが失敗した際には failure ディレクトリが作成され、そこに画像が保存されます。
simple_widget_maskedDiff.png ファイルを見てみると以下のようになっています。
このファイルでは変更前後のデザインを重ねることで、変更部分をよりわかりやすく表示しています。
以下の画像では「Simple Text 2」の文字が大きくなり、それに伴って上下のテキストも移動していることがわかります。

今回は「Simple Text 2」のテキストのフォントの大きさを 30 から 32 に変更するという非常に小さな変更でしたが、違いを確認できました。

2. 実装の差異が小さいケース②

次に実装の差異が小さいケースの二つ目のケースについてみていきます。
前の章と同様に Before / After の画像を比較するところから始めます。
以下の二つの画像でどの部分が変更されたかわかるでしょうか?
前の章とは異なり、テキストも通常のサイズにしていて非常にわかりにくいかと思います。
Before

After

まずはテスト対象となる Widget の実装を進めていきます。
コードは以下の通りです。
見た目はシンプルで、三つのテキストフィールドを持つフォームの Widget を実装しています。

lib/form_widget.dart
import 'package:flutter/material.dart';

class FormWidget extends StatelessWidget {
  const FormWidget({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Form'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Form(
          child: Column(
            children: [
              TextFormField(
                decoration: const InputDecoration(
                  labelText: 'Email',
                  border: OutlineInputBorder(),
                ),
                keyboardType: TextInputType.emailAddress,
              ),
              const SizedBox(height: 16),
              TextFormField(
                decoration: const InputDecoration(
                  labelText: 'Password',
                  border: OutlineInputBorder(),
                ),
                keyboardType: TextInputType.visiblePassword,
              ),
              const SizedBox(height: 16),
              TextFormField(
                decoration: const InputDecoration(
                  labelText: 'Password (Confirm)',
                  border: OutlineInputBorder(),
                ),
                keyboardType: TextInputType.visiblePassword,
              ),
              const SizedBox(height: 24),
              ElevatedButton(
                onPressed: () {},
                child: const Text('Submit'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Widget が追加できたらテストを追加していきます。
test/form_widget ディレクトリに form_widget_test.dart を追加してテストを実装します。
コードは以下の通りです。
基本的には前の章と同じ実装なので、それぞれのメソッドの役割については割愛します。

test/form_widget/form_widget_test.dart
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:sample_flutter/golden_test/form_widget.dart';

Future<void> main() async {
 testGoldens('FormWidget Golden Test', (tester) async {
   final devices = [
     Device.iphone11,
     Device.tabletPortrait,
     Device.tabletLandscape,
   ];
   final builder = DeviceBuilder()
     ..overrideDevicesForAllScenarios(devices: devices)
     ..addScenario(widget: const FormWidget(), name: 'form_widget');

   await tester.pumpDeviceBuilder(builder);

   await screenMatchesGolden(tester, 'form_widget');
 });
}

次に以下のコマンドを実行して、 Golden Image を作成します。

flutter test --update-goldens

これで goldens ディレクトリに画像が生成されたかと思います。

次に FormWidget を変更してみます。
以下のように変更します。
以下ではそれぞれの要素の間隔を少し大きくする変更を行なっています。

import 'package:flutter/material.dart';

class FormWidget extends StatelessWidget {
  const FormWidget({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Form'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Form(
          child: Column(
            children: [
              TextFormField(
                decoration: const InputDecoration(
                  labelText: 'Email',
                  border: OutlineInputBorder(),
                ),
                keyboardType: TextInputType.emailAddress,
              ),
-             const SizedBox(height: 16),
+             const SizedBox(height: 20),
              TextFormField(
                decoration: const InputDecoration(
                  labelText: 'Password',
                  border: OutlineInputBorder(),
                ),
                keyboardType: TextInputType.visiblePassword,
              ),
-             const SizedBox(height: 16),
+             const SizedBox(height: 20),
              TextFormField(
                decoration: const InputDecoration(
                  labelText: 'Password (Confirm)',
                  border: OutlineInputBorder(),
                ),
                keyboardType: TextInputType.visiblePassword,
              ),
-             const SizedBox(height: 24),
+             const SizedBox(height: 28),
              ElevatedButton(
                onPressed: () {},
                child: const Text('Submit'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

これで以下のコマンドを実行してみます。

flutter test

すると先ほどと同じような文言でテストが失敗するかと思います。
同時に、生成された failure ディレクトリを見ると以下の画像ファイルが生成されているかと思います。この画像を見ると、 Email 以下のテキストフィールドやボタンが少しずつ下に移動していることがわかります。

デザイン修正のタスクで、 Padding を変更したり、要素の間隔を8の倍数に揃えたりといった修正を行うことはかなり多いと思います。
Golden Test ではこのようなレイアウトの細かな変更も視覚的にみやすく提示することができます。

3. プラットフォームごとの差異があるケース

次に各プラットフォームでデザインの差異があるケースをみていきます。
まずはテスト対象となる Widget を作成していきます。
コードは以下の通りです。

lib/responsive_widget.dart
import 'package:flutter/material.dart';

class ResponsiveWidget extends StatelessWidget {
  const ResponsiveWidget({super.key});

  
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.sizeOf(context).width;
    const searchItems = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'];
    return Scaffold(
      appBar: AppBar(
        title: const Text('Responsive Widget'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(10),
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              SizedBox(
                width: screenWidth * 0.65,
                child: const TextField(
                  decoration: InputDecoration(
                    labelText: 'Input Text',
                  ),
                ),
              ),
              const SizedBox(width: 20),
              SizedBox(
                width: screenWidth * 0.25,
                child: FilledButton(
                  style: FilledButton.styleFrom(
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(10),
                    ),
                  ),
                  onPressed: () {},
                  child: const Text('Search'),
                ),
              ),
            ],
          ),
          ...searchItems.map(
            (item) => ListTile(
              title: Text(item),
            ),
          ),
        ],
      ),
    );
  }
}

このコードを実行して、iPhone 16 Pro の iOS Simulator で実行すると以下のような見た目になります。特に問題なく表示されていそうです。(デザインが質素であることは置いておいて...)

では、この Widget をテスト対象とする Golden Test を実装してみます。
test/responsive_widget ディレクトリに responsive_widget_test.dart ファイルを追加してテストを実装します。
コードは以下の通りです。
こちらも基本的には前の章と同じ実装なので、それぞれのメソッドの役割については割愛します。

test/responsive_widget/responsive_widget_test.dart
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:sample_flutter/golden_test/responsive_widget.dart';

Future<void> main() async {
  testGoldens('ResponsiveWidget Golden Test', (tester) async {
    final devices = [
      Device.iphone11,
      Device.tabletPortrait,
      Device.tabletLandscape,
    ];
    final builder = DeviceBuilder()
      ..overrideDevicesForAllScenarios(devices: devices)
      ..addScenario(widget: const ResponsiveWidget(), name: 'responsive_widget');

    await tester.pumpDeviceBuilder(builder);

    await screenMatchesGolden(tester, 'responsive_widget');
  });
}

これで以下のコマンドを実行します。

flutter test --update-goldens

そして、 goldens ディレクトリに保存されたファイルを見てみると、以下のようになっています。
一番右側の画像は iPad で横向きにした場合のUIですが、紫色の「Search」ボタンがとても横長くなっていることがわかります。
原因は、レイアウトに画面のサイズを使用していることです。場合によっては修正が必要であるかと思います。

このように、単一のプラットフォーム上では表示に問題がなかったとしても、別のプラットフォームで表示が崩れていたり、表示されていなかったりする場合があります。
Golden Test ではこのようなケースも検出することができます。

4. デザインの崩れを検知するケース

最後に、デザインの崩れを検知するケースについてみていきます。
このケースでは先ほどの、ResponsiveWidget を変更する形で進めてみます。
ResponsiveWidget を以下のように変更します。

import 'package:flutter/material.dart';

class ResponsiveWidget extends StatelessWidget {
  const ResponsiveWidget({super.key});

  
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.sizeOf(context).width;
    const searchItems = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'];
    return Scaffold(
      appBar: AppBar(
        title: const Text('Responsive Widget'),
      ),
      body: ListView(
-       padding: const EdgeInsets.all(10),
+       padding: const EdgeInsets.all(20),
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              SizedBox(
                width: screenWidth * 0.65,
                child: const TextField(
                  decoration: InputDecoration(
                    labelText: 'Input Text',
                  ),
                ),
              ),
              const SizedBox(width: 20),
              SizedBox(
                width: screenWidth * 0.25,
                child: FilledButton(
                  style: FilledButton.styleFrom(
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(10),
                    ),
                  ),
                  onPressed: () {},
                  child: const Text('Search'),
                ),
              ),
            ],
          ),
          ...searchItems.map(
            (item) => ListTile(
              title: Text(item),
            ),
          ),
        ],
      ),
    );
  }
}

これで以下のコマンドを実行してみます。

flutter test --update-goldens

するとテストが失敗して、以下のようなエラーが表示されるかと思います。
出力内容を見てみると、レイアウトのオーバーフローが起きていることがわかります。

══╡ EXCEPTION CAUGHT BY RENDERING LIBRARY ╞═════════════════════════════════════════════════════════
The following assertion was thrown during layout:
A RenderFlex overflowed by 19 pixels on the right.

The relevant error-causing widget was:
  Row
  Row:file:///Users/koichi/StudioProjects/sample_flutter/lib/golden_test/responsive_widget.dart:17:11

生成された画像を見てみると以下のようになっています。
左側のiPhoneのUIでオーバーフローが起きていることが確認できます。

スマートフォンとタブレットの両方に対応したアプリケーションで、それぞれ縦向き、横向きのテストをしようとすると時間がかかることが多いと思いますが、 Golden Test では短時間で複数のプラットフォームの表示に問題がないか検知することができます。

まとめ

最後まで読んでいただいてありがとうございました。

Golden Test では意図しないUIの変更を検知できたり、細かい部分の修正を見やすくしたりさまざまなメリットがあることがわかりました。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://zenn.dev/taisei_dev/articles/f88b46aad05dba

https://note.shiftinc.jp/n/nb4d72c1ca3ee

https://zenn.dev/susa/articles/3efa01d37d03e2

Discussion