Flutterで始めるGoldenTest

この記事は [MIXI DEVELOPERS Advent Calendar 2022](https://qiita.com/advent-calendar/2022/mixi) 21日目の記事です。
この記事ではFlutterのテストについて、テスト手法の1つであるGoldenTestをどのように実装するかご紹介したいと思います。

# GoldenTestとは
そもそもGoldenTestとは何なのか簡単に説明します。
GoldenTestとは「過去に実行したテストの結果を保存し、再度テストを実行する際にそのファイルと同じ結果になるかどうかをチェックするテスト」です。
RegressionTestの手法の1つで、Flutterとりわけクライアントサイドの開発においては、UIが予期せず変更されていないかを確かめるテストとなります。
※RegressionTestは回帰テストとも言い、プログラムの一部を変更したことで他の箇所に不具合が出ていないかを確認するためのテストです。

今回は`flutter create`で作成できるアプリを例にしてどのようにテストするかご紹介したいと思います。

# 下準備
## テスト対象にするアプリを作成
今回使用するFlutterのバージョンは以下にします。
```
$ flutter --version
Flutter 3.3.6 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 6928314d50 (5 weeks ago) • 2022-10-25 16:34:41 -0400
Engine • revision 3ad69d7be3
Tools • Dart 2.18.2 • DevTools 2.15.0
```

最初に雛形となるアプリを作成します。
```
$ flutter create flutter_golden_test
```

`flutter run`を実行するとこのようなアプリが起動できます。
<img width="200" alt="sample app" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/3599da92-879b-c085-4257-09a262ef2758.png">

## GoldenTestを組み込む
GoldenTestのパッケージ「[golden-toolkit](https://pub.dev/packages/golden_toolkit)」を追加します。
※余談ですがgolden_toolkitはebayで開発しているOSSのようです。
```
$ flutter pub add golden_toolkit
```

`pubspec.yml`に以下のように依存関係が追加されます。
```
dependencies:
  flutter:
    sdk: flutter

  〜中略〜  

  golden_toolkit: ^0.13.0
```

次にプロジェクト直下に`dart_test.yaml`を追加して以下のように記述してGoldenTestの設定を追加します。
```
tags:
  golden:
```

# テストを書いてみる
先ほど作成したアプリのテストを実際に書いていきます。

testディレクトリに`my_app_golden_test.dart`を追加して以下のように記述します。※ファイル名は何でも良いです
```dart
import 'package:flutter/material.dart';
import 'package:flutter_golden_test/main.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';

void main() {
  // テスト対象のウィジェット
  const targetWidget = MyApp();
  // テスト対象の画面サイズ
  const targetSize = Size(392, 813);

  testGoldens(
    'my_app',
    (WidgetTester tester) async {
      // 指定したウィジェットを指定したサイズでビルド
      await tester.pumpWidgetBuilder(targetWidget, surfaceSize: targetSize);
      // 保存しているスクリーンショットとUIが一致するか検証
      await screenMatchesGolden(tester, 'my_app');
    },
  );
}
```

以下のコマンドを実行します。
```
$ flutter test --update-goldens
```

すると`test`ディレクトリ以下に`./goldens/my_app.png`が生成されます。
こちらが冒頭に記載した「過去に実行したテストの結果」になります。
<img width="200" alt="my_app" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/768067af-e554-01be-cc81-71f6d9f3f80d.png">

このままだとフォントやアイコンが読み込めていないので、読み込み処理を追加します。
`test`ディレクトリ直下に`flutter_test_config.dart`を追加し以下のように記述します。
```dart
import 'dart:async';

import 'package:golden_toolkit/golden_toolkit.dart';

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

```

再び`flutter test --update-goldens`を実行すると画像ファイルが更新されます。
先ほどマスクされていた文字やアイコンなどが表示されるようになります。
<img width="200" alt="my_app" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/33895ce2-a45b-0d68-8ca1-ec5037d87bca.png">

また、このままだとサイズを指定してテストを行う形なので、ページ全体のテストとしては不適感があります。
そこでテストを以下のように変更します。
```dart
import 'package:flutter_golden_test/main.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';

void main() {
  const targetWidget = MyApp();

  testGoldens(
    'my_app',
    (WidgetTester tester) async {
      final builder = DeviceBuilder()
        ..overrideDevicesForAllScenarios(
          devices: [
            Device.phone,
            Device.iphone11,
          ],
        )
        ..addScenario(
          widget: targetWidget,
          name: 'my_app',
        );
      await tester.pumpDeviceBuilder(builder);
      await screenMatchesGolden(tester, 'my_app');
    },
  );
}
```

golden_toolkitの`Device`というクラスを指定してテストを行う形に変更しました。
今回は`Device`クラスに定義されている`phone`と`iphone11`を使用していますが、システムの要件に合わせてよしなに変更すると良いと思います。
`flutter test --update-goldens`を実行すると以下のように`./test/goldens/my_app.png`が更新されます。
<img width="400" alt="my_app" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/dc756b58-5274-08c2-4812-b984f8f9cb28.png">

## UIを変更してみる
テスト結果がわかりやすいようにデバイスではなくサイズを指定したテストで見ていきます。
### その１（プライマリーカラーを変更）
プライマリーカラーを青→赤に変更してみます
<img width="200" alt="red" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/cb106bd6-6788-7fda-6286-7a84ccc1a0e1.png">

この状態で先程の「過去に実行したテストの結果」を**変えずに**`flutter test`を実行してみると以下のようにテストが失敗するようになります。
```
❯ flutter test
00:01 +0: loading /Users/.../flutter_golden_test/test/my_app_golden_test.dart
00:02 +1: /Users/.../flutter_golden_test/test/my_app_golden_test.dart: my_app
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following assertion was thrown while running async test code:
Golden "goldens/my_app.png": Pixel test failed, 5.53% diff detected.
Failure feedback can be found at
/Users/.../flutter_golden_test/test/failures

When the exception was thrown, this was the stack:
#0      LocalFileComparator.compare (package:flutter_test/src/_goldens_io.dart:101:7)
<asynchronous suspension>
<asynchronous suspension>
(elided one frame from package:stack_trace)
════════════════════════════════════════════════════════════════════════════════════════════════════
00:02 +1 -1: /Users/.../flutter_golden_test/test/my_app_golden_test.dart: my_app [E]
  Test failed. See exception logs above.
  The test description was: my_app


To run this test again: /Users/.../flutter/bin/cache/dart-sdk/bin/dart test /Users/.../flutter_golden_test/test/my_app_golden_test.dart -p vm --plain-name 'my_app'
00:02 +1 -1: Some tests failed.
```

また、`test`ディレクトリ以下に`failures`ディレクトリが生成され、配下に失敗したテストの結果が保存されます。
こちらを参照すると画像ファイルベースでテストの実行結果が確認できます。
| my_app_isolatedDiff.png | my_app_maskedDiff.png | my_app_masterImage.png | my_app_testImage.png |
|:-:|:-:|:-:|:-:|
| ![my_app_isolatedDiff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/d8d4b72a-9480-2e28-6522-bc04f9a77416.png) | ![my_app_maskedDiff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/71e521ad-ef8b-1757-0b1b-e3fc3f213e98.png) | ![my_app_masterImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/899d024c-2327-ff94-6828-8037bbc578d5.png) | ![my_app_testImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/78524687-f24c-1dae-4165-c409ccb81936.png) |

`flutter test --update-goldens`を実行して「過去に実行したテストの結果」を更新した後に、`flutter test`を実行すると再度テストが通るようになります。

### その2（テキストを変更）
数字の上の`You have pushed the button this many times:`という文言を`changed`という文言に変えてみます。
<img width="200" alt="changed" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/d198eaf3-a4f5-2006-0eaf-960f9ce13bed.png">

先程と同様に「過去に実行したテストの結果」を**変えずに**`flutter test`を実行すると失敗します。テストを実行して失敗した結果が`failures`ディレクトリ以下に保存されます。
| my_app_isolatedDiff.png | my_app_maskedDiff.png | my_app_masterImage.png | my_app_testImage.png |
|:-:|:-:|:-:|:-:|
| ![my_app_isolatedDiff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/e5c3b495-3680-9ba9-5ffc-ef4cd6719681.png) | ![my_app_maskedDiff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/db5c2b08-dcd5-d9d3-2b59-9db12d98c0c3.png) | ![my_app_masterImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/444e2a4e-ce59-573d-cf5c-f3659be3c292.png) | ![my_app_testImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/544013c2-528e-561a-e9a2-2ff163717dfa.png) |

### その3（アイコンを変更）
次に右下のフロートボタンのアイコンを`+`から`☆`に変えます。
<img width="200" alt="my_app" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/371bb167-5732-8f46-c6fe-55a0e7bce454.png">

例によって「過去に実行したテストの結果」を**変えない**と`flutter test`が失敗するようになり、テストの実行結果ファイルがfailuresディレクトリ以下に保存されます。
| my_app_isolatedDiff.png | my_app_maskedDiff.png | my_app_masterImage.png | my_app_testImage.png |
|:-:|:-:|:-:|:-:|
| ![my_app_isolatedDiff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/fa3e01c8-8efe-5845-205a-6be1bf219df4.png) | ![my_app_maskedDiff.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/7e2519a0-b6de-d689-c7ee-54f88a7dab85.png) | ![my_app_masterImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/a2eaaffa-a306-e2d0-58e3-3d5e2de5639a.png) | ![my_app_testImage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264889/bffe0359-f359-219c-685a-efaf57dfde4b.png) |

# まとめ
色や文字、アイコンを変更してテストが通らなくなることを確認しました。
今回は意図的にUIを変更して確認してみましたが、本来は内部のロジックを変更したりリファクタリングを行った際などに効力を発揮するテスト手法になります。意図しない不具合が起きていないかチェックすることが出来るようになり、品質をより強固に保つことができるようになります。
品質担保するのにGoldenTestのみでは不十分だとは思いますが、CIに組み込んで上手く仕組み化出来れば、人では気づけないような微妙な違いなどを自動で検出できるとても有用なツールになるのではないかと思います。
Flutterはテストも手軽に実装することができますが、今回ご紹介したGoldenTestも今回ご紹介したように割と手軽に導入できるのではないかと思います。ぜひ取り入れてみてはいかがでしょうか。

今回使用したコードはgithub上にも公開しておりますので見てみてください。
https://github.com/yukihiro-numata/flutter_golden_test