Flutter アプリに画像回帰テスト(VRT)を導入する
こんにちは✋ 株式会社ウーオの髙橋です。
2021年7月に株式会社ウーオに入社し、ソフトウェアエンジニアとしてプロダクトの開発に携わっています。これまではメガベンチャーや保険のスタートアップでAndroid エンジニアとして開発してきました。
この note では、プロダクト開発していくなかで、品質面の課題に対して画像回帰テスト(Visual Regression Test; VRT)という手法でアプローチしてみた話を書いてみます。
背景
ウーオでは、開発スタイルとしてアジャイル開発を採用しています。1スプリントを2週間に定めて、スプリントごとにプロダクトチーム内で成果物をレビューし、UIデザインとして気になるポイントから体験上の課題まで、あらゆるFBを集め改善に活かしています。
スプリントの期間とは少しずれますが、現在はおよそ2週間ごとにアプリのリリースを実施しています。これには、メインで実施していた施策のリリースのほかにも、現場やセールス・CSから頂いたフィードバックをもとにした既存機能の改善、不具合修正なども含まれます。
また、リリース前にはプロダクチームはもちろん、セールスチームやCSチームを含めたQAを実施しています。QAとして不具合を発見・修正する以外にも、機能を社内で使ってもらうことで、ユーザビリティの観点からも最終的なチェックをしていくという目的があります。
課題
日々開発を進め、およそ2週間ごとにリリースしていくなかで、大きく2つの課題がありました:
QAにかかる時間が増大していた。
デグレが発生しやすくなっていた。
前者については、施策単位だと開発規模としても大きくなるため、QAとして確認すべき内容も多くなりがちでした。水産業というドメイン自体が複雑ということもあり、あらゆる条件でのQAを実施する必要がありました。
後者は、一度リリースしたあとでも既存機能を頻繁に改善するようにしているためデグレが発生することがありました。QAの段階で気付いて修正できれば不幸中の幸いといったところですが、最悪の場合はリリース後に発覚し、急遽 hotfix としてリリースすることもありました。とはいえ、既存機能を逐一チェックしていくのはコストが高く、スピードとのトレードオフとなっていました。
このように、プロダクトが進化していくにつれ、リリース前の不具合修正に時間をとってしまい、せっかく頂いたユーザビリティに関する貴重なフィードバックをなかなか反映できないという課題がありました。
また、プロダクトチーム以外のメンバーに時間を取っていただいているので、できる限りユーザビリティに関する検証に集中してもらいたいという意図もありました。
対応
「新規施策の不具合やデグレ修正のためQA」から、「ユーザビリティ改善のためのQA」に重きを置いていくため、不具合を早期発見するための仕組みとして、自動テストを導入することにしました。
プロダクトの特性として、入力フォームが動的に変化したり、ユーザの種別によって表示内容が切り替わるなど、各画面の状態が多い状況でした。
例えば、ウーオに魚を出品するユーザが出品を作成する画面では、どの魚を出品するかを選択するフィールド「魚種」があります。
ここで、魚種には「鮮魚BOX」という項目があり、これを選択したときだけ「鮮魚BOXの内訳」という、鮮魚BOXにどんな魚が含まれているのかを選択できるフィールドが表示されるようになっています。
このような機能を含め、こうした各状態が常に正しく動いているかということと、加えてデザイン崩れが起きていないかも含めて自動でチェックできると、不具合の早期発見に役立つのではと考えました。
単にテストコード上で検証するだけでは、検証コードも複雑になりがちで、かつデザイン崩れの検証が難しいため、画像回帰テスト(Visual Regression Test; VRT)の導入を進めました。
画像回帰テストは、一言で表現すると、変更前後の画面のスクリーンショット比較・差分検出し、「見た目に不具合があるかどうか」を検証するテストです。
実装方針
Flutter アプリで画像回帰テスト(VRT)を実施している事例は既にいくつか見つかります。
上記記事を参考にしつつ、主に以下のツールを用いて対応しました。
golden_toolkit: スクリーンショットの生成
reg-suit: 画像差分の検出とテストレポートの生成
CloudFront + S3: テストレポートの保存と配信
実装する
golden_toolkit を導入する
pubspec.yaml に以下を追記します。
dependencies:
golden_toolkit: ^0.13.0
reg-suit をセットアップする
基本的に、 https://github.com/reg-viz/reg-suit#getting-started の手順通りに導入を進めます。
まずは、 yarn で reg-suit をプロジェクトに追加します。
(yarn がプロジェクトにセットアップされていない場合は、package.json、yarn.lock が自動的に追加されます。)
$ yarn add reg-suit
次に、reg-suit の初期設定を実施します。
$ yarn reg-suit init --use-yarn
最初に、導入するプラグインの選択を求められるので、今回はデフォルト設定のまま、 reg-keygen-git-hash-plugin、reg-notify-github-plugin、reg-publish-s3-plugin の3つのプラグインをインストールします。
[reg-suit] info version: 0.12.1
? Plugin(s) to install (bold: recommended) (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter>
to proceed)
◯ reg-notify-slack-plugin : Notify reg-suit result to Slack channel.
◯ reg-publish-gcs-plugin : Fetch and publish snapshot images to Google Cloud Storage.
◯ reg-simple-keygen-plugin : Determine snapshot key with given values
❯◉ reg-keygen-git-hash-plugin : Detect the snapshot key to be compare with using Git hash.
◉ reg-notify-github-plugin : Notify reg-suit result to GitHub repository
◉ reg-publish-s3-plugin : Fetch and publish snapshot images to AWS S3.
◯ reg-notify-chatwork-plugin : Notify reg-suit result to Chatwork channel.
(Move up and down to reveal more choices)
reg-notify-github-plugin を使用する場合、途中で Client ID の入力を求められるので、手順に従って Client ID を取得し入力します。
[reg-suit] info Set up reg-notify-github-plugin:
? notify-github plugin requires a client ID of reg-suit GitHub app. Open installation window in your browser (Y/n)
? This repositoriy's client ID of reg-suit GitHub app <取得した Client ID を入力>
最終的なアウトプットは以下の通りです。
golden_toolkit はデフォルトでテストコードのファイル (*_test.dart) があるディレクトリに goldens/ というディレクトリを作成し、そこにスクリーンショットを出力するため、 reg-suit 側でも 「Directory contains actual images」 には test/**/goldens を指定しました。
今回は S3 のバケットは事前に作成済みのため、reg-suit の初期設定段階では作成していません。(作成する場合は、AWS CLI のセットアップが必要となります。)
これで reg-suit のセットアップが完了し、プロジェクトのルートに regconfig.json が作成されたことが確認できます。
CloudFront と S3 の設定
今回は、簡易的な認証機構として Basic 認証を設けるために CloudFront を使用しました。初期設定が完了したあと、以下の手順を参考に実施しました。
テストコードを書く
Flutter のテストは、基本的に test/ ディレクトリ配下に記述していきます。今回は、サンプルとして以下の Widget をテストしてみます。
まずは、 enabled の各状態(true/false)のスクリーンショットを生成してみます。
import 'package:flutter/material.dart';
class StadiumButton extends StatelessWidget {
const StadiumButton({
super.key,
required this.text,
required this.enabled,
this.onPressed,
});
final String text;
final bool enabled;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: enabled ? onPressed : null,
style: OutlinedButton.styleFrom(
primary: Colors.white,
shape: const StadiumBorder(),
side: BorderSide.none,
backgroundColor: enabled ? Colors.black : Colors.grey,
),
child: Text(
text,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
);
}
}
テストコードとしては以下のようになります。
具体的には、golden_toolkit で用意されている testGoldens でテストケースを作成していきます。テスト対象の Widget の準備ができたら、 screenMatchesGolden でスクリーンショットを生成します。
実際には、スクリーンショット撮影前に Widget の操作が入る場合が多いかと思いますが、Widget Test と同じ記法で操作することができるため、別途 Widget Test のドキュメントなどを参考にしてください。
import 'package:flutter/material.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:uuuo_sellers/ui/stadium_button.dart';
const _surfaceSize = Size(200, 100);
void main() {
testGoldens('有効な状態', (tester) async {
// フォントの読み込み
await loadAppFonts();
// テスト対象の Widget を描画する
await tester.pumpWidgetBuilder(
_testApp(const StadiumButton(text: 'Send', enabled: true)),
surfaceSize: _surfaceSize,
);
// "有効な状態.png" としてスクリーンショットを生成する
await screenMatchesGolden(tester, '有効な状態');
});
testGoldens('無効な状態', (tester) async {
await loadAppFonts();
await tester.pumpWidgetBuilder(
_testApp(const StadiumButton(text: 'Send', enabled: false)),
surfaceSize: _surfaceSize,
);
await screenMatchesGolden(tester, '無効な状態');
});
}
Widget _testApp(StadiumButton child) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Center(child: child),
);
}
スクリーンショットを生成する
テストケースが作成できたら、実際にスクリーンショットを生成します。
通常の Unit Test と同様に、 flutter test コマンドでテストを実行できます。--update-goldens オプションを付与することで、golden_toolkit での差分比較を実施せずに、スクリーンショットの生成のみを行います。また、既に Unit Test が存在する場合には、 --tags=golden オプションを付与することで、testGoldens によるテストケースのみを実行することができます。
$ flutter test --update-goldens --tags=golden
上記コマンドを実行すると、テストファイルのあるディレクトリに goldens/ ディレクトリが作成され、以下の2つの画像が生成されているのが確認できます。
テストレポートをアップロードする
ウーオでは、アプリ側のCIツールとして GitHub Actions を使用しており、push 時にテストレポートをアップロードするようにしています。次の手順でスクリーンショットの生成から各種ストレージへのアップロードが可能なため、お使いのCIツールに組み込んでください。
まずは、アップロードするためのスクリーンショットを生成します。
$ flutter test --update-goldens --tags=golden
次に、以下のコマンドを実行することで、差分検出とテストレポートの生成、各種ストレージへのアップロードができます。
$ yarn reg-suit run
テストレポートを確認する
テストレポートのアップロードが完了すると、各種ストレージから確認できるようになります。
yarn reg-suit run のアウトプットに含まれるテストレポートの URL をクリックすると以下のようなテストレポート画面を確認できます。
[reg-suit] info Report URL: https://<host>/<git hash>/index.html
今回はテストケースを新規に追加したため、差分としては何もありませんが、開発を進めていくと差分を見れるようになっていきます。
テスト用データの生成を楽にしておく
通常、テストコードを実装していく際には、API から取得したデータを表示した状態を検証したいことがほとんどかと思います。このとき、API のモックには mockito というパッケージを今回は使用しました。
モックは mockito を使えば容易に実現できるのですが、ドメインモデルのデータを生成するのは一苦労です。都度作成するにはテストコードの実装コストが高くなってしまうため、今回は自動で生成できるようにしています。テストコードの書きやすさはテストを書く上でも重要なポイントなので、必要に応じてサポートのユーティリティを作成しておくことをおすすめします。
ウーオでは、reflectable パッケージを用いることで、以下の1行でテストデータを生成できるようにしています。
final user = Arb.ofType<User>().next();
詰まったこと
日本語テキストを描画する
golden_toolkit では、何も設定せずに日本語フォントを表示しようとしても表示することができません。
これは、標準で Roboto フォントが読み込まれていますが、日本語の文字セットが含まれていないためです。そのため、日本語のテキストを正しく描画するには別途設定が必要になります。
これは、Theme に fontFamily を設定することで対応可能です。以下のように、アプリで読み込んでいるフォントをテスト側でも指定します。
MaterialApp(
theme: ThemeData(
fontFamily: 'NotoSansJP',
),
home: Center(child: child),
);
各テストケースでは忘れずにフォント読み込みを実行します。
testGoldensBy('example', (tester) async {
await loadAppFonts();
// ...
});
以上の対応により、無事日本語フォントが反映されました。
スクロール領域全てのスクリーンショット撮影する
スクリーンショットを撮影したい画面がスクロールが必要なほど長い場合には、スクロール領域全体を撮影したくなるかと思います。このとき、スクリーンショット撮影時には autoHeight というパラメータを付与することでスクロール領域全体の高さに調整してからスクリーンショットを生成してくれる機能があります。
screenMatchesGolden(
tester,
'description',
autoHeight: true, // <--
)
しかし、この機能を使うにあたっては Flutter のテスト上の制約を回避する必要があります。Flutter のテストでは Future.delayed を含む Timer の処理が実行されないため、スクロール領域の計算が正しく行われない問題がありました。(https://github.com/flutter/flutter/issues/24166)
そこで、高さ計算と描画待ちのタイミングをずらすことで対策をしました。具体的なコードは gist にアップロードしたので、必要に応じて参考にしていただければと思います。
導入してみて
まだ限定的な範囲での導入にはなりますが、導入した箇所においてはUIの差分を検知しやすくなり、デザイン上の変化を捉えやすくなったり、レビューの効率なども高まっったりしているのを実感しています。
また、あらゆる画面の各状態のスクリーンショットを持っておくことで、UIカタログ的に、今どういう画面が存在していて、それぞれどういう状態になりうるのかということを視覚的に把握できるようになりました。チームが拡大し、あらゆる施策が並行していくなかで、既存の仕様を把握していく上でも役に立つのではと思っています。
これからやっていくこと
導入したばかりということもあり、まだテストケースが少ない状況のため、今後テストケースを増やしていく必要があります。そもそも、Flutter アプリには自動テストが導入されていなかったこともあり、チーム全体としてアプリのテストを書いていくことへの意識を高めていくために、カバレッジレポートの導入を進めたいと考えています。
また、現在は golden_toolkit を使用していますが、代替パッケージとして alchemist というものがあることを知ったので、こちらも検証・比較を進めてみたいと考えています。
テストコード実装上の問題としては、CI とローカルで生成したテストレポートにはフォントのレンダリング部分に差異があり、ローカルでの差分検出がしにくい状態となっているため、早急に改善すべきポイントだと感じています。
そのほか、現在はテストレポートの保存と配信に Cloud Font + S3 という構成で Basic 認証をかけていますが、別途 GCP 上で GCS, GLB, IAP などを用いて Google アカウントによる認証ができるインフラを構築済みのため、こちらに載せ替える作業も進めております。
最後に
今回は、既存の Flutter アプリの運用面の課題に対して、画像回帰テスト(VRT) を導入していく決断と、その導入過程について書いてみました。
分量の都合上、枝葉で省略した部分はかなり多いので、ご興味がある方は別途 Meety などでお話できればと思います!また、その他に気になることも是非各メンバーとお話しいただけたらと思います 💁♂️
そして、ウーオでは水産流通を革新するため、プロダクトを通じてあらゆるアプローチをしています。ウーオの事業やプロダクト開発にご興味がある方は、以下をぜひご覧ください 👇
この記事が気に入ったらサポートをしてみませんか?