🔎

Flutter Golden Test / VRT でしきい値を指定して比較する

2024/09/30に公開

はじめに

Flutter の Golden Test / VRT では、完全一致による比較になります。
ただ、テストを実行する環境によって差分が発生してしまうため、しきい値を設定して比較できるようにしてみました。

テスト環境による差分が発生する事例

  • Mac にて flutter test --update-goldens を実行し、比較用の画像を生成
  • CI(ubuntu)にて flutter test を実行し、差分が発生
      ══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
    The following assertion was thrown while running async test code:
    Golden "goldens/ci/setting_page_default.png": Pixel test failed, 0.38%, 36951px diff detected.
    Failure feedback can be found at
    
  • テキスト表示部分で差分が発生している
  • Ahem フォントにて、すべて四角形でテキスト表示しても差分が発生した

flutter issue

画像サンプル

masterImage
masterImage.png

testImage
testImage.png

maskedDiff
maskedDiff.png

isolatedDiff
isolatedDiff.png

しきい値を設定して比較

しきい値を設定して比較できるように以下のコードを追加します。

test/support/golden_test/local_file_comparator_with_threshold.dart

import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

class LocalFileComparatorWithThreshold extends LocalFileComparator {
  LocalFileComparatorWithThreshold(super.testFile, this.threshold)
      : assert(
          threshold >= 0 && threshold <= 1,
          'Threshold must be between 0 and 1! ',
        );

  final double threshold;

  
  Future<bool> compare(Uint8List imageBytes, Uri golden) async {
    final result = await GoldenFileComparator.compareLists(
      imageBytes,
      await getGoldenBytes(golden),
    );

    if (!result.passed && result.diffPercent <= threshold) {
      debugPrint(
        'A difference of ${result.diffPercent * 100}% was found, but it is '
        'acceptable since it is not greater than the threshold of '
        '${threshold * 100}%.',
      );

      return true;
    }

    if (!result.passed) {
      final error = await generateFailureOutput(result, golden, basedir);
      throw FlutterError(error);
    }

    return result.passed;
  }
}

参考: https://github.com/eBay/flutter_glove_box/issues/175

個別のテストコードでしきい値を設定して比較できるようにメソッドを追加します。

import 'package:flutter_test/flutter_test.dart';

import 'local_file_comparator_with_threshold.dart';

void prepareGoldenFileComparatorWithThreshold({
  double threshold = 0.01, // 1%
}) {
  if (goldenFileComparator is LocalFileComparator) {
    final testUrl = (goldenFileComparator as LocalFileComparator).basedir;

    goldenFileComparator = LocalFileComparatorWithThreshold(
      Uri.parse('$testUrl/test.dart'),
      threshold,
    );
  } else {
    throw Exception(
      'Expected `goldenFileComparator` to be of type '
      '`LocalFileComparator`, but it is of type '
      '`${goldenFileComparator.runtimeType}`.',
    );
  }
}

テストコードで prepareGoldenFileComparatorWithThreshold メソッドを呼び出し、しきい値を設定して比較できるようにします。
(テストコードはこちらの記事を参照ください)

import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_alchemist_sample/main.dart';

import 'support/alchemist/golden_test_device_scenario.dart';
import 'support/golden_test/prepare_golden_file_comparator_with_threshold.dart';

void main() {
  prepareGoldenFileComparatorWithThreshold(); // 1%(デフォルト)の差分は許容

  group('MyApp Golden Test', () {
    Widget buildMyApp() {
      return const MyApp();
    }

    final devices = Device.all;

    goldenTest('Default', fileName: 'my_app_default', builder: () {
      final children = <Widget>[];

      for (final device in devices) {
        children.add(GoldenTestDeviceScenario(
          name: device.name,
          device: device,
          builder: () => buildMyApp(),
        ));
      }

      return GoldenTestGroup(
        columns: devices.length,
        children: children,
      );
    });
  });
}

これで、しきい値を設定して比較できるようになりました。

A difference of 0.31220930232558136% was found, but it is acceptable since it is not greater than the threshold of 1.0%.

差分が発生する要因やしきい値に影響する要素を取り除く

上記内容でしきい値を指定した比較はできるようになりました。
ただ、差分が発生する要因やしきい値に影響する要素があれば取り除くことで、より正確に比較できるようになります。

こちらの記事のテストコードを変更し、差分が発生する要因やしきい値に影響する要素を取り除いていきます。

GoldenTestScenario/GoldenTestDeviceScenario name を空文字で指定

Alchemist は name 指定した内容が画像上部にテキスト表示されます。
テキスト表示があることで、どのような内容か画像で確認できますが、このテキストも差分が発生してます。
そのため、name に空文字を指定することで、テキスト表示しないようにします。

GoldenTestGroup を使用せず、ひとつずつ画像を比較

画面サイズや画面向きごとに比較できるように、GoldenTestGroup でひとつの画像にまとめて比較していました。
ただこの場合、画面サイズや画面向きによる余白が生まれ、その余白が比較のしきい値に影響します。
そのため、ひとつの画像にまとめず、ひとつずつの画像で比較するようにします。

変更したコード

こちらの記事のテストコードを変更した内容はこちらになります。

diff --git a/test/support/alchemist/golden_test_device_scenario.dart b/test/support/alchemist/golden_test_device_scenario.dart
index a853b5b..89d9ab0 100644
--- a/test/support/alchemist/golden_test_device_scenario.dart
+++ b/test/support/alchemist/golden_test_device_scenario.dart
@@ -19,7 +19,7 @@ class GoldenTestDeviceScenario extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return GoldenTestScenario(
-      name: '$name (${device.name})',
+      name: name,
       child: ClipRect(
         child: MediaQuery(
           data: MediaQuery.of(context).copyWith(
iff --git a/test/widget_golden_test.dart b/test/widget_golden_test.dart
index 2dd0d09..48e6a17 100644
--- a/test/widget_golden_test.dart
+++ b/test/widget_golden_test.dart
@@ -4,30 +4,30 @@ import 'package:flutter_test/flutter_test.dart';
 import 'package:flutter_alchemist_sample/main.dart';

 import 'support/alchemist/golden_test_device_scenario.dart';
+import 'support/golden_test/prepare_golden_file_comparator_with_threshold.dart';

 void main() {
+  prepareGoldenFileComparatorWithThreshold();
+
   group('MyApp Golden Test', () {
     Widget buildMyApp() {
       return const MyApp();
     }

-    final devices = Device.all;
-
-    goldenTest('Default', fileName: 'my_app_default', builder: () {
-      final children = <Widget>[];
-
-      for (final device in devices) {
-        children.add(GoldenTestDeviceScenario(
-          name: device.name,
-          device: device,
-          builder: () => buildMyApp(),
-        ));
-      }
-
-      return GoldenTestGroup(
-        columns: devices.length,
-        children: children,
-      );
-    });
+    for (final device in Device.all) {
+      group(device.name, () {
+        goldenTest(
+          'Default',
+          fileName: 'my_app_default_${device.name}',
+          builder: () {
+            return GoldenTestDeviceScenario(
+              name: '',
+              device: device,
+              builder: buildMyApp,
+            );
+          },
+        );
+      });
+    }
   });
 }

まとめ

Flutter Golden Test / VRT でしきい値を指定して比較できました。
環境による差分が発生する場合は、しきい値を設定して比較できるようにしてみてはいかがでしょうか。

備考: なぜ完全一致・比較からしきい値を設定した比較に置き換わっているのか

Alchemist は flutter_test で用意されている matchesGoldenFile を使用して画像を比較しています。
matchesGoldenFile は goldenFileComparator で指定された内容をもとに比較しています。
そのため、比較処理を goldenFileComparator で置き換えることにより、しきい値を設定した比較ができます。

Discussion