📷

Flutter Alchemist で Golden Test / VRT を行う

2024/09/29に公開

はじめに

Flutter にて画面差分やデザインの崩れがないかを、 Golden Test / Visual Regression Test(以下、VRT)でテストする方法があります。
Flutter では golden_toolkit が有名ですが、2024/09/12 にメンテナンスされなくなったことが公開されました。
そこで、golden_toolkit にインスパイアされたAlchemistを導入してみました。

Alchemist とは

https://github.com/Betterment/alchemist

Alchemist is a Flutter package that provides functions, extensions and documentation to support golden tests.

Heavily inspired by Ebay Motor's golden_toolkit package, Alchemist attempts to make writing and running golden tests in Flutter easier.

Alchemist は、ゴールデン テストをサポートする関数、拡張機能、ドキュメントを提供する Flutter パッケージです。

Ebay Motor の golden_toolkit パッケージに大きく影響を受けた Alchemist は、Flutter でのゴールデン テストの作成と実行を容易にすることを目的としています。

環境

  • flutter: 3.24.3
  • alchemist: 0.10.0

Alchemist を導入

flutter create で作成されるカウントアップを例に Alchemist を導入してみます。
詳しい導入方法は Alchemist のREADMERecommended Setup Guideを参照してください。

ここでは、以下のような内容を行っていきます。

  • ダークテーマ対応
  • Alchemist の導入・設定
  • デバイスサイズ向けコードの追加
  • テストコードの追加
  • テストの実行

ダークテーマ対応

Golden Test / VRT をダークテーマを含めてテストしたいため、ダークテーマの追加に対応します。
flutter create で作成された lib/main.dart を以下のように変更しました。

diff --git a/lib/main.dart b/lib/main.dart
index 8e94089..a8c39c0 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -28,8 +28,14 @@ class MyApp extends StatelessWidget {
         //
         // This works for code too, not just values: Most code changes can be
         // tested with just a hot reload.
-        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
         useMaterial3: true,
+        brightness: Brightness.light,
+        colorSchemeSeed: Colors.deepPurple,
+      ),
+      darkTheme: ThemeData(
+        useMaterial3: true,
+        brightness: Brightness.dark,
+        colorSchemeSeed: Colors.deepPurple,
       ),
       home: const MyHomePage(title: 'Flutter Demo Home Page'),
     );

Alchemist の導入・設定

alchemist パッケージを追加

以下のコマンドを実行し、alchemist パッケージを追加します。

flutter pub add dev:alchemist

flutter_test_config.dart を追加し、Alchemist を設定

test/flutter_test_config.dart ファイルを追加し、テスト実行時に Alchemist の設定が反映された状態にします。
実際の表示に近づけるため、Recommended Setup Guide とは異なる設定をしています。

test/flutter_test_config.dart

import 'dart:async';

import 'package:alchemist/alchemist.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  return AlchemistConfig.runWithConfig(
    config: const AlchemistConfig(
      ciGoldensConfig: CiGoldensConfig(
        obscureText: false,
        renderShadows: true,
      ),
      platformGoldensConfig: PlatformGoldensConfig(
        enabled: false,
      ),
    ),
    run: testMain,
  );
}

Recommended Setup Guide との差異は以下のとおりです。

  • テキストを四角形で隠さない(Ahem フォントに置き換えない)
    • ciGoldensConfigobscureText: false を指定
  • 影を表示
    • ciGoldensConfigrenderShadows: true を指定
  • CI のみを対象とし、個別のプラットフォームは無効
    • platformGoldensConfigenabled: false を指定

.gitignore に追記

.gitignore

+# Ignore non-CI golden files and failures
+test/**/goldens/**/*.*
+test/**/failures/**/*.*
+!test/**/goldens/ci/*.*

dart_test.yaml を追加

dart_test.yaml

tags:
  golden:

デバイスサイズ向けコードの追加

スマホやタブレットなどのデバイスによって画面サイズは異なります。
また、縦画面・横画面も切り替えられます。
そのため、デバイスごとの画面サイズや画面向きに対応したコードを追加します。

参考: https://github.com/Betterment/alchemist/issues/37

test/support/alchemist/device.dart

// Copied and adapted from https://github.com/eBay/flutter_glove_box/blob/master/packages/golden_toolkit/lib/src/device.dart

import 'package:flutter/material.dart';

/// This [Device] is a configuration for golden test.
class Device {
  /// This [Device] is a configuration for golden test.
  const Device({
    required this.size,
    required this.name,
    this.devicePixelRatio = 1.0,
    this.textScaleFactor = 1.0,
    this.brightness = Brightness.light,
    this.safeArea = EdgeInsets.zero,
  });

  /// [phoneLandscape] example of phone that in landscape mode
  static const Device phoneLandscape =
      Device(name: 'phone_landscape', size: Size(667, 375));

  /// [phonePortrait] example of phone that in portrait mode
  static const Device phonePortrait =
      Device(name: 'phone_portrait', size: Size(375, 667));

  /// [tabletLandscape] example of tablet that in landscape mode
  static const Device tabletLandscape =
      Device(name: 'tablet_landscape', size: Size(1366, 1024));

  /// [tabletPortrait] example of tablet that in portrait mode
  static const Device tabletPortrait =
      Device(name: 'tablet_portrait', size: Size(1024, 1366));

  static List<Device> all = [
    phonePortrait,
    phonePortrait.dark(),
    phoneLandscape,
    phoneLandscape.dark(),
    tabletPortrait,
    tabletPortrait.dark(),
    tabletLandscape,
    tabletLandscape.dark(),
  ];

  /// [name] specify device name. Ex: Phone, Tablet, Watch
  final String name;

  /// [size] specify device screen size. Ex: Size(1366, 1024))
  final Size size;

  /// [devicePixelRatio] specify device Pixel Ratio
  final double devicePixelRatio;

  /// [textScaleFactor] specify custom text scale factor
  final double textScaleFactor;

  /// [brightness] specify platform brightness
  final Brightness brightness;

  /// [safeArea] specify insets to define a safe area
  final EdgeInsets safeArea;

  /// [copyWith] convenience function for [Device] modification
  Device copyWith({
    Size? size,
    double? devicePixelRatio,
    String? name,
    double? textScale,
    Brightness? brightness,
    EdgeInsets? safeArea,
  }) {
    return Device(
      size: size ?? this.size,
      devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio,
      name: name ?? this.name,
      textScaleFactor: textScale ?? textScaleFactor,
      brightness: brightness ?? this.brightness,
      safeArea: safeArea ?? this.safeArea,
    );
  }

  /// [dark] convenience method to copy the current device and apply dark theme
  Device dark() {
    return Device(
      size: size,
      devicePixelRatio: devicePixelRatio,
      textScaleFactor: textScaleFactor,
      brightness: Brightness.dark,
      safeArea: safeArea,
      name: '${name}_dark',
    );
  }

  
  String toString() {
    return 'Device: $name, '
        '${size.width}x${size.height} @ $devicePixelRatio, '
        'text: $textScaleFactor, $brightness, safe: $safeArea';
  }
}

test/support/alchemist/golden_test_device_scenario.dart

import 'package:flutter/material.dart';

import 'device.dart';
export 'device.dart';

class GoldenTestDeviceScenario extends StatelessWidget {
  const GoldenTestDeviceScenario({
    super.key,
    required this.name,
    required this.device,
    required this.builder,
  });

  final String name;
  final Device device;
  final ValueGetter<Widget> builder;

  
  Widget build(BuildContext context) {
    return GoldenTestScenario(
      name: '$name (${device.name})',
      child: ClipRect(
        child: MediaQuery(
          data: MediaQuery.of(context).copyWith(
            size: device.size,
            padding: device.safeArea,
            platformBrightness: device.brightness,
            devicePixelRatio: device.devicePixelRatio,
            textScaler: TextScaler.linear(device.textScaleFactor),
          ),
          child: SizedBox(
            height: device.size.height,
            width: device.size.width,
            child: builder(),
          ),
        ),
      ),
    );
  }
}

テストコードの追加

test/widget_golden_test.dart

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';

void main() {
  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,
      );
    });
  });
}

テストの実行

初回は比較に使用する画像が作成されていないので、以下のコマンドを実行して画像を作成します。

flutter test --update-goldens --tags=golden

my_app_default

比較画像が作成されれば、以下のコマンドで画面差分やデザインの崩れがないことをテストできます。

flutter test --tags=golden

まとめ

Flutter Alchemist を導入し、Golden Test / VRT を行いました。
Flutter で Golden Test / VRT をしたい場合や golden_toolkit からの移行として Alchemist を使用してみてはいかがでしょうか。

Discussion