💰

🥇Golden Testを導入してUI開発の不安を解消する

2023/12/23に公開

https://qiita.com/advent-calendar/2023/arsaga

はじめに

Flutter好きの皆さんこんにちは!
アルサーガパートナーズ株式会社でFlutterエンジニアをしているtaiseiです!

突然ですが皆さん、FlutterでUIの開発を進める中で、「このUIはどんなデバイスでも適切に表示されるのだろうか..?」と不安に感じたことはありませんか?

レスポンシブデザインはもちろんのこと、テキストのフォントや色、ダークモードの表示など複数のデバイスで複数の画面を一回一回チェックするのは骨の折れる作業ですし、開発を進める中で副作用によってUIが変更され、気付かないうちに開発が進んでしまうといったこともあります。

そこで、これらの問題を解決する方法として、Golden TestというUIテスト手法があります!!

今回はこのGolden Testについて、設定から基本的な使い方、私が実際に遭遇したエラーに対するトラブルシューティング等を解説していこうと思います!

この記事が皆さんの開発の一助になれば嬉しいです!

対象者

・Golden Testについて興味がある方
・UIテスト方法を迷っている方

では、解説してきます🚀

Golden Testとは?

Widgetテストの一種で、通常のWidgetテストがコードを探索的に検証するのに対し、Golden Testは開発前後の画面のスクリーンショットを比較して、差分を検出するテスト手法です。ピクセル単位での差分の検出が可能なため、目視で確認するよりも正確かつ効率的にUIのテストを行うことができます。

Golden Testでは、Golden ImageというアプリのUIがどのように見えるべきかの基準となる画像を元にテストを行います。テスト実施時には、テスト実行中に生成されたスクリーンショットと基準であるGolden Imageが比較され、完全に一致する場合はテストが成功したとみなされます。一方で、一致しない場合はテストが失敗したとみなされ、UIのどの部分で差分が発生しているかの詳細な情報が提供されます。

Golden Testのメリット

Golden Testを実施するメリットとしては下記が挙げられます。
- UIの品質担保:
→ ウィジェットやレイアウトがデザイン通りに実装されているかどうかをスクリーンショットを通じて確認できます。
- ビジュアルリグレッションの検出:
→ ピクセル単位での差分を検出してくれるため、UIの意図しない変更にすぐに気づくことができます。
- 効率的なUIの確認
→ 複数のシナリオ[1]を一つのスクリーンショットで一括で確認できるため、目視で確認するより効率的です。

あとは非エンジニアに開発中のUIをすぐに共有することができる点も大きなメリットかなと思います。

Golden Testの運用

Golden Testの運用について、私が参画しているPJでは下記のように運用しています。

  1. ブランチを切ってUI開発
  2. flutter test --update-goldensコマンドで基準となるGolden Imageを作成・更新
  3. 差分を確認して、意図しない画面のGolden Imageが更新されている場合は修正
  4. 更新したGolden Imageを含めてPRを作成

[2]

ローカルではテストを行うというよりは、新規画面作成や画面更新時にGolden Imageを更新し、意図しない変更が含まれていないかをチェックするような運用をしています。また、CIのワークフローでgolden testを実施することで、画面更新後のGolden Imageの更新忘れを防いでいます。

PR時にスワイプして開発前後の比較することが出来ます。

導入

それでは、Golden Testを導入していきましょう!
本記事では、FlutterでGolden Testを行う際に便利なパッケージであるgolden_toolkitに焦点を当てます。
https://pub.dev/packages/golden_toolkit

1. インストール

下記コマンドを実行

flutter pub add golden_toolkit

もしくはpubspec.yamlに直接golden_toolkitのバージョンを指定してください。

dependencies:
  golden_toolkit: ^0.15.0

以上でインストールは完了です!

2. やっておくべき設定

golden_toolkitを使用してGolden Testを実施するためにやっておくべき設定がいくつかあります。

1. failuresフォルダを.gitignoreに追加
テスト失敗時にfailuresディレクトリに失敗したスクリーンショットが格納されます。こちらの画像はgitで管理する理由がないのでgit監視対象外に設定しておきましょう。

**/failures/*.png

2. 「golden」タグの追加

dart_test.yamlファイルに「golden」タグを追加します。

tags:
  golden:
dart_test.yamlとは?

テストの設定をカスタマイズするための設定ファイルです。
詳しい設定方法については下記参照ください。

https://github.com/dart-lang/test/blob/master/pkgs/test/doc/configuration.md

Golden Testを記述するtestGoldens関数には、デフォルトで「golden」タグが設定されているため、例えばflutter test --tags=goldenといったコマンドを実行すると、goldenタグが設定されているテストのみ実行されるようになり、テストの効率化につながります。

https://flutter.salon/error_warning/dart-test-yaml/

3. VScodeの設定
エディタにVSCodeを使用している場合、.vscode/launch.jsonに下記設定を追加しておくと便利です。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Golden",
      "request": "launch",
      "type": "dart",
      "codeLens": {
        "for": ["run-test", "run-test-file"]
      },
      "args": ["--update-goldens"]
    }
  ]
}

4. フォントの読み込み(重要)
Flutterのテストでは、デフォルトで「Ahem」というテスト専用のフォントが使用されます。このフォントは、すべての文字とアイコンに黒いスペースを表示するように設計されており、アプリの見た目を正確にテストするGolden Testには適していません。

こちらを解決するためには提供されている下記ヘルパーメソッドをテスト前に実行する必要があります。

await loadAppFonts();

テスト時に毎回こちらのメソッドを実行してもいいですが、抜け漏れる可能性があるので、テスト実行前に自動でフォントを読み込んでもらうよう、専用の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の基本

下記の画面を使ってGolden Testの基本について解説していきます。

altテキスト

ソースコード
import 'package:flutter/material.dart';

enum Weather {
  sunny(
    '晴れ',
    Icons.sunny,
  ),
  cloudy(
    '曇り',
    Icons.cloud,
  ),
  rain(
    '雨',
    Icons.grain,
  ),
  snow(
    '雪',
    Icons.ac_unit,
  );

  const Weather(this.text, this.icon);

  final String text;
  final IconData icon;
}

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color.fromARGB(255, 241, 241, 241),
      appBar: AppBar(
        title: const Text('Golden Test'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: GridView.builder(
          itemCount: Weather.values.length,
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 2,
            crossAxisSpacing: 16,
            mainAxisSpacing: 16,
          ),
          itemBuilder: (_, index) => WeatherScreenItem(
            weather: Weather.values[index],
          ),
        ),
      ),
    );
  }
}

class WeatherScreenItem extends StatelessWidget {
  const WeatherScreenItem({super.key, required this.weather});

  final Weather weather;

  
  Widget build(BuildContext context) {
    return SizedBox(
      width: 200,
      height: 200,
      child: ClipRRect(
        borderRadius: BorderRadius.circular(8),
        child: Stack(
          clipBehavior: Clip.hardEdge,
          children: <Widget>[
            Container(
              alignment: Alignment.center,
              decoration: const BoxDecoration(
                color: Colors.amber,
              ),
              width: 200,
              height: 200,
            ),
            Positioned(
              left: -80,
              top: -80,
              child: Icon(
                weather.icon,
                color: Colors.white,
                size: 200,
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(16),
              child: Align(
                alignment: Alignment.bottomRight,
                child: Text(
                  weather.text,
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 24,
                    fontWeight: FontWeight.w900,
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

テスト作成

通常のテストと同様に任意の名前_test.dartというファイルを作成し、テストを書いていきます。

void main() {
  // ゴールデンテストを定義するための関数
  testGoldens(
    'Weather Screen Test',
    (tester) async {
      // 異なるデバイスサイズでWidgetをテストするために使用
      final builder = DeviceBuilder()
        // デバイスの種類を指定。指定した種類分スクリーンショットが作成されます。
        ..overrideDevicesForAllScenarios(devices: [
          Device.phone,
          Device.iphone11,
        ])
	// テストするシナリオを追加。今回はWeatherScreen全体をテスト。
        ..addScenario(
          widget: testableWidget(
	    child: const WeatherScreen(),
	  )
        );
      // addScenarioで指定したWidgetをレンダリング
      await tester.pumpDeviceBuilder(
        builder,
      );
      
      // レンダリングされたWidgetのスクリーンショットを作成し、第二引数で指定したファイルと比較します。
      await screenMatchesGolden(tester, 'weather_screen_test');
    },
  );
}

testableWidgetは、フォントの読み込みやThemeの設定を適用させるため、MaterialAppを返却する関数を作成しています。Themeの部分は、実際に利用するMaterialAppと共通のThemeを指定しておくと良いでしょう。

Widget testableWidget({required Widget child}) {
  return MaterialApp(
    theme: ThemeData(
      colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      useMaterial3: true,
      fontFamily: 'Murecho',
    ),
    debugShowCheckedModeBanner: false,
    home: child,
  );
}

Golden Imageを作成

テストを作成したら下記コマンドを実行してGolden Imageを作成します。

flutter test --update-goldens

以上でgoldens/ディレクトリ配下にスクリーショットが作成されたかと思います!

altテキスト

全てのGolden Testで複数のデバイスを設定する

overrideDevicesForAllScenariosで出力したいデバイスの設定を行うことができますが、テスト毎で指定するのも冗長なので共通化したいところです。golden_toolkitではGoldenToolkit.runWithConfiguration()という設定用クラスが提供されているため、こちらの設定をflutter_test_config.dartに追加します。

import 'dart:async';
import 'dart:io';

import 'package:golden_toolkit/golden_toolkit.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  return GoldenToolkit.runWithConfiguration(
    () async {
      // フォントの読み込み
      await loadAppFonts();
      await testMain();
    },
    config: GoldenToolkitConfiguration(
      // 出力するデバイスを指定
      defaultDevices: const [
        Device.phone,
        Device.iphone11,
      ],
      skipGoldenAssertion: () => !Platform.isMacOS,
    ),
  );
}

コンポーネント単位でGolden Testを行う

先ほどはDeviceBuilderを使用して、端末ごとのGolden Imageの生成を行いましたが、GoldenBuilderを使用することによってコンポーネント単位でスクリーンショットを作成することができます。

  testGoldens(
    'Weather Screen Item Test',
    (tester) async {
      final builder = GoldenBuilder.grid(
        // 横に配置するコンポーネントの数
        columns: 2,
	// カラム数と幅と高さの比率
        widthToHeightRatio: 1,
      )
        // 各Enumの値でシナリオを作成
        ..addScenario(
          'Sunny',
          const WeatherScreenItem(weather: Weather.sunny),
        )
        ..addScenario(
          'Cloudy',
          const WeatherScreenItem(weather: Weather.cloudy),
        )
        ..addScenario(
          'Raining',
          const WeatherScreenItem(weather: Weather.rain),
        )
        ..addScenario(
          'Snow',
          const WeatherScreenItem(weather: Weather.snow),
        );

      await tester.pumpWidgetBuilder(
        builder.build(),
        wrapper: (child) => TestUtils.buildTestMaterialApp(child),
      );
      
      await screenMatchesGolden(
        tester,
        'weather_types_grid',
        autoHeight: true,
      );
    },
  );

altテキスト

出力するコンポーネントによっては、スクリーンショットが見切れてしまう場合があるのでautoHeight: trueを設定することによって、自動的に高さを合わせてくれます。

Golden Imageの出力先を変更する

デフォルトではテストファイルと同階層にgoldens/ディレクトリが作成され、その配下にGolden Imageが格納されます。もしGolden Imageの出力先を変更したい場合は、GoldenToolkitConfigurationfileNameFactoryで変更することが可能です。

    Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  return GoldenToolkit.runWithConfiguration(
    () async {
      // 省略
    },
    config: GoldenToolkitConfiguration(
      fileNameFactory: (name) {
        return 'goldens/$name.png';Golden Imageの保存先を変更
      },
      // 省略
    ),
  );
}

引数で受け取るnameはテスト実行時のawait screenMatchesGolden(tester, 'counter_screen');の第二引数に指定した名前が渡されます。

トラブルシューティング

この章では、私が実際に遭遇したエラーと、それらを解決する方法について解説していきます。

Golden Imageに黒い枠線が表示される

上記画像のようにWidgetに黒色の枠線がつくことがあります。
これはFlutterのバージョン間(または実行環境)によって影の表示が変わる可能性があるため、デフォルトでは影が黒い枠線に置き換えられて表示されます。

https://pub.dev/documentation/golden_toolkit/latest/golden_toolkit/GoldenToolkitConfiguration/enableRealShadows.html

この黒い枠線を削除したい場合はGoldenToolkit.runWithConfigurationに下記設定を加えることで削除することができます。

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  return GoldenToolkit.runWithConfiguration(
    () async {
      // 省略
    },
    config: GoldenToolkitConfiguration(
      enableRealShadows: true, ← 追加
      // 省略
    ),
  );
}

flutter_screenutilパッケージ使用時のエラー

レスポンシブ対応を簡単に実現できるパッケージとして、flutter_screenutilがあります。

https://pub.dev/packages/flutter_screenutil

こちらのパッケージを使用した画面でGolden Testを実施すると下記のようなエラーが発生します。

══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following LateError was thrown building WeatherScreenItem(dirty):
LateInitializationError: Field '_data@32084504' has not been initialized.

The relevant error-causing widget was:
  WeatherScreenItem
  WeatherScreenItem:file:///Users/t-z/workspace/flutter/advent_calendar_2023/test/ui/weather_screen_test.dart:46:17

エラー内容を読むと、weather_screen_test.dartの46行目で_data@32084504というフィールドが初期化されていないというエラー内容になりますが、46行目にはテスト対象画面が指定されおり、テスト対象の画面ではflutter_screenutilパッケージを使用しています。

flutter_screenutilパッケージを使用する際には、ScreenUtilInitウィジェットを用いて、アプリケーションの画面が描画される前にScreenUtilを初期化する必要があります。Golden Testでも同様にテスト実行前にScreenUtilの初期化をする必要があるため、冒頭で作成したWrapper関数を下記のように修正します。

class _Wrapper extends StatelessWidget {
  final Widget child;
  const _Wrapper(this.child);
  
  Widget build(BuildContext context) {
    ScreenUtil.init(
      context,
      designSize: const Size(430.0, 932.0),
    );
    return child;
  }
}

Widget testableWidget({required Widget child}) {
  return _Wrapper(MaterialApp(
    theme: ThemeData(
      colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      useMaterial3: true,
      fontFamily: 'Murecho',
    ),
    debugShowCheckedModeBanner: false,
    home: child,
  ));
}

自分の環境では発生しませんでしたが、【Flutter】screenutilに対応したGolden Testの実装の記事の通りスクリーンショット解像度がおかしくなる場合はがあるので、その場合はMediaQueryでラップしてあげる必要があります。

Widget testableWidget({required Widget child}) {
  return MediaQuery(
    data: const MediaQueryData(),
    child: _Wrapper(MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
        fontFamily: 'Murecho',
      ),
      debugShowCheckedModeBanner: false,
      home: child,
    )),
  );
}

https://github.com/OpenFlutter/flutter_screenutil/issues/115

まとめ

今回はgolden_toolkitを使ったGolden Testについて解説しました!
まだまだ発展途上な部分もありますが、導入も簡単なので皆さんプロジェクトにもぜひ取り入れてみてください!

ちなみにFlutter公式のUI test手法としても紹介されているのでこれからの進化が楽しみです!
https://youtu.be/vka33yBz5e4?feature=shared

それでは良いGoldenライフを!!

参考

FutureCon2022 Flutter Golden Testのすすめ
Flutter でCI/CDの取り組む前に知っておきたかったテスト関連の覚え書き
Flutterパッケージを管理する際に役立つCI/CD (GitHub Actions)
FlutterにGolden Testを導入しました

余談

今回の記事を書くにあたって「なぜGolden TestはGoldenというのか?」について気になったのでChatGPTに聞いてみました。

あなた
Golden TestのGoldenとはどういう意味で使用されていますか??

ChatGPT
「ゴールデンテスト」または「ゴールデンファイルテスト」という用語は、ソフトウェアテストの分野で使われる言葉です。この用語の「ゴールデン」という部分は、テストの基準となる出力や結果が「金のように価値がある」、つまり非常に信頼性が高く、正確であることを意味しています。ゴールデンファイルは、テストの正確な基準点として機能し、新しいテスト結果がこの基準とどのように異なるかを評価するために使用されます。

この用語の由来は、一般的に「ゴールデンスタンダード」または「黄金基準」という表現に由来しています。これは、何かが最高水準または最も信頼性の高い例として認識されることを意味します。ソフトウェアテストの文脈では、ゴールデンファイルはそのような最高水準の例と見なされ、テストの信頼性と正確性を保証するための基準点として機能します。

※一部改変しています。

「ゴールデン」という部分は、テストの基準となる出力や結果が「金のように価値がある」、つまり非常に信頼性が高く、正確であることを意味しています

なるほど!!納得!!

脚注
  1. 例えば、ユーザの権限によって表示するUIを変更する場合、テストコードにそれぞれのケースを書くことによって、一つのスクリーンショットで全てのケースの画面を確認することができます。 ↩︎

  2. このフローチャートはChatGPTで生成してもらいました。普通にすごい。 ↩︎

Discussion