🍣

Flutter標準のGoldenTestでVRT(ビジュアルリグレッションテスト)を自動化する

2024/11/04に公開

はじめに

こんにちは!
株式会社アンドエーアイの荻野と申します!
今回は「GoldenTestでVRT(ビジュアルリグレッションテスト)を自動化する」と題して記事を書いていこうと思います!

モチベーション

2024年11月現在、GoldenTestについて検索すると、標準のflutter_testではなく、それを拡張したgolden_toolkitを使った方法が多くヒットします。
しかし、golden_toolkitは現在ではメンテナンスが途絶えてしまっているため、他の手段を検討する必要があります。

golden_toolkitにインスパイアされたalchemistといった他のパッケージもありますが、
ここでは、よりプリミティブにflutter標準の機能を使ってGoldenTestを導入します。

GoldenTestとは

アプリケーション開発をしていると、プロジェクトが進行するにつれて、新しい機能の追加やデザインの変更により、ビジュアルリグレッション(意図しないUI変更等)が発生することがあります。

そこで登場するのがGoldenTestです。GoldenTest は、UIコンポーネントの見た目が意図通りに保たれているかどうかを自動的に検証する手法です。

このテスト手法を導入することで、ビジュアルリグレッションを素早く検知し、デザインの品質を維持することができます。

実装

FlutterのカウンターアプリにGoldenTestを導入します。
まずはいつも通り以下を実行します。

flutter create my_golden_test

テストを作成する

プロジェクトルートのtest/内にテストコードを配置します。
test/内のディレクトリ構造、ファイル名規則および基本的な書き方はflutter_testと同様です。

一度、以下の通り必要最低限のテストコードを配置しました。

golden_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_golden_test/main.dart';

void main() {
  testWidgets('Golden Test', (WidgetTester tester) async {
    // 画面を描画する
    await tester.pumpWidget(
      MaterialApp(
        home: const MyHomePage(),
      ),
    );

    // 画面をゴールデンファイルと比較する
    await expectLater(find.byType(MyHomePage),
        matchesGoldenFile('golden_images/my_home_page_test.png'));
  });
}


注目したいのはexpectLater()にMatcherとしてmatchesGoldenFile('golden_images/my_home_page_test.png')を渡している部分です。

このようにすると、test実行時に--update-goldensオプションの有無によってゴールデンイメージの生成ゴールデンイメージとの比較ができるようになります。

// ゴールデンテストの生成
flutter test --update-goldens

// ゴールデンテスト実行
flutter test

ゴールデンイメージを生成する

テストが作成できたところでゴールデンイメージの生成を行ってみます。
すると以下のような画像がtestディレクトリ内に生成されました。

flutter test --update-goldens

実行結果↓

ゴールデンイメージは無事生成できたものの、
生成された画像を見ると、以下のような特徴が見て取れます。

  • 画像がPCウィンドウのようなサイズで生成される。
  • 文字とアイコンが黒い四角で描画される。
  • FABの周囲のelevationが黒一色で表示される。

このままでは実際の見た目がわからない上に、アイコンの変更なども検知できず、テストの効果が半減してしまいます。

文字・アイコンのロード

ゴールデンテスト下では、フォントを指定しない場合はAhemというテスト用のフォント(文字の大きさだけを表現したフォント)が使用されます。
また、アイコンについてもアイコンのアセットをロードしなければ、正しく表示されません。

この問題は、次のようなメソッドを作成し、テストの最初にフォントをロードすることで解決することができます。

※使用するフォントはあらかじめAssetsに追加しておきます。
日本向けのアプリの場合は、日本語対応のフォントであることを確認しましょう。

golden_test.dart
Future<void> loadFonts() async {
  // MaterialIcons
  final materialIcons = FontLoader('MaterialIcons')
    ..addFont(
      rootBundle.load('fonts/MaterialIcons-Regular.otf'),
    );

  // CupertinoIcons
  final cupertinoIcons = FontLoader('packages/cupertino_icons/CupertinoIcons')
    ..addFont(
      rootBundle.load('packages/cupertino_icons/assets/CupertinoIcons.ttf'),
    );

  // Roboto
  final roboto = FontLoader('Roboto')
    ..addFont(
      rootBundle.load('assets/fonts/Roboto/Roboto-Regular.ttf'),
    )
    ..addFont(
      rootBundle.load('assets/fonts/Roboto/Roboto-Bold.ttf'),
    );

  await materialIcons.load();
  await cupertinoIcons.load();
  await roboto.load();
}
helper.dart
testWidgets('Golden Test', (WidgetTester tester) async {
    // フォントとアイコンをロードする
    await loadFonts();

    // 画面を描画する
    await tester.pumpWidget(
      MaterialApp(
        // フォントを設定する
        theme: ThemeData(fontFamily: 'Roboto'),
        home: const MyHomePage(),
      ),
    );
    ...
}

画像サイズの変更

そのままだと、実行環境毎のデフォルトのサイズとなります。(今回の場合2400x1800px)
スマートフォンなど、他のスクリーンサイズに対応する場合は、ウィジェットを描画する前に、以下のようにして、View全体のサイズを変更します。

golden_test.dart
testWidgets('Golden Test', (WidgetTester tester) async {
    // 画面のサイズを設定する
    tester.view.physicalSize = const Size(1179, 2556);
    ...
}

Elevationの有効化

ウィジェットを描画する前に、debugDisableShadowsを無効化します。
テスト実施後は再び有効化する必要があります。

golden_test.dart
testWidgets('Golden Test', (WidgetTester tester) async {
  // フォントとアイコンをロードする
  await loadFonts();

  // 画面のサイズを設定する
  tester.view.physicalSize = const Size(1179, 2556);

  // シャドウを有効にする
  debugDisableShadows = false;

  // 画面を描画する
  await tester.pumpWidget(
    MaterialApp(
      // フォントを設定する
      theme: ThemeData(fontFamily: 'Roboto'),
      home: const MyHomePage(),
    ),
  );

  // 画面をゴールデンファイルと比較する
  await expectLater(find.byType(MyHomePage),
      matchesGoldenFile('golden_images/my_home_page_test.png'));

  // シャドウの有効化を解除する
  debugDisableShadows = true;
});

This is useful when writing golden file tests (see matchesGoldenFile) since the rendering of shadows is not guaranteed to be pixel-for-pixel identical from version to version (or even from run to run).
シャドウのレンダリングはバージョンごとに(あるいは実行ごとに)ピクセル単位で同一であることが保証されていないため、これはゴールデンファイルのテスト(matchesGoldenFileを参照)を書くときに便利である。

ゴールデンイメージと現在の実装を比較する

成功時

ゴールデンイメージの生成が成功したので、きちんと差分を検知できるかどうか試してみます。
まずは前述の実装を変えないまま、--update-goldensを外してテストを実行します。

flutter test
00:03 +1: All tests passed! 

無事に通りました。

失敗時

次は意図的にUIに変更を加えて、再度実行してみます。

flutter test
00:01 +0: /Users/user/Desktop/my_golden_test/test/golden_test.dart: Golden Test                              
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following assertion was thrown while running async test code:
Golden "golden_images/my_home_page_test.png": Pixel test failed, 7.20%, 24116px diff detected.
Failure feedback can be found at /Users/user/Desktop/my_golden_test/test/failures

When the exception was thrown, this was the stack:
#0      LocalFileComparator.compare (package:flutter_test/src/_goldens_io.dart:109: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      main.<anonymous closure> (file:///Users/user/Desktop/my_golden_test/test/golden_test.dart:28:5)
<asynchronous suspension>
#7      testWidgets.<anonymous closure>.<anonymous closure> (package:flutter_test/src/widget_tester.dart:189:15)
<asynchronous suspension>
#8      TestWidgetsFlutterBinding._runTestBody (package:flutter_test/src/binding.dart:1032:5)
<asynchronous suspension>
<asynchronous suspension>
(elided 3 frames from dart:async and package:stack_trace)

The exception was caught asynchronously.
════════════════════════════════════════════════════════════════════════════════════════════════════
00:01 +0 -1: /Users/user/Desktop/my_golden_test/test/golden_test.dart: Golden Test [E]                       
  Test failed. See exception logs above.
  The test description was: Golden Test
  

To run this test again: /Users/user/.asdf/installs/flutter/3.24.1/bin/cache/dart-sdk/bin/dart test /Users/user/Desktop/my_golden_test/test/golden_test.dart -p vm --plain-name 'Golden Test'
00:01 +0 -1: Some tests failed.     

エラーが出ました。
変更したのは3カ所ですが、人間の目で気付けるでしょうか?

マスターイメージ 変更後

失敗時に生成される画像

testディレクトリを確認するとfailuresというフォルダが作成されています。
中には4つの画像が格納されており、それぞれ以下のようになっています。

my_home_page_test_masterImage.png my_home_page_test_testImage.png my_home_page_test_maskedDiff.png my_home_page_test_isolatedDiff.png
ゴールデンイメージ 現実装の画像 差分を浮き彫りにした画像 差分のみを抽出した画像

変更された内容は、以下の3点です。

① アイコンが変わった。
② カウンター上の文字のフォントサイズが変わった。
③ AppBarの透明度が0.9になった。

①、②はすぐに気付くことができたかもしれませんが、③は難しかったと思います。

このようにゴールデンテストを取り入れると、気付きにくい変更を検知でき、「いつの間にかビジュアルリグレッションが起きていた」という事態を未然に防ぐことができるのです。

最後に

ここまで書いてきたとおり、ゴールデンテストはビジュアルリグレッションを防止するための強力なツールです。

また、CI/CDに組み込み、自動で差分を検知したりするような運用方法をとることも可能で、うまく機能すれば保守作業の大幅な負担軽減も実現できます。

この記事が少しでもVRTに興味のある方の助けになれたのであれば幸いです。

今回はFlutter_testのみの範囲の基本的な記述方法しか述べていませんが、alchemistなどのパッケージを使えば、状態別の検証や、複数端末サイズの一括検証などを楽に行うことができるので、そちらの記事も執筆予定です。

PR

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

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

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

アンドエーアイTechBlog

Discussion