🧩

Alchemistを使ってGoldenTestを導入してみた

2024/12/22に公開1

はじめに

FlutterのVRT(Visual Regression Test)に興味があったので、GoldenTestを導入してみました。
従来ではgolden_toolkitの使用が主流でしたが、今年の9月にメンテナンスが中止されてしまったようです。
調べてみると、Flutter標準機能の他に、Alchemistというgolden_toolkitに影響を受けたパッケージがあったのでこれを使ってみます。

https://github.com/Betterment/alchemist?tab=readme-ov-file

https://pub.dev/packages/alchemist

導入

1. パッケージを追加

flutter pub add alchemist

2. 設定

まずは、こちらの説明に従って、推奨設定を行います。

https://github.com/Betterment/alchemist/blob/main/RECOMMENDED_SETUP_GUIDE.md

① flutter_test_config.dartを作成し、テスト環境の設定を行う

testディレクトリ配下に、flutter_test_config.dartを作成し、テスト環境の設定を行います。

test/flutter_test_config.dart
import 'dart:async';

import 'package:alchemist/alchemist.dart';
import 'package:custom_styling_package/custom_styling_package.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  // ignore: do_not_use_environment
  const isRunningInCi = bool.fromEnvironment('CI', defaultValue: false);

  return AlchemistConfig.runWithConfig(
    config: AlchemistConfig(
      theme: CustomTheme.light(),
      platformGoldensConfig: const PlatformGoldensConfig(
        enabled: !isRunningInCi,
      ),
    ),
    run: testMain,
  );
}

https://github.com/Betterment/alchemist/tree/main?tab=readme-ov-file#advanced-usage

を参考にすると、カスタムも可能です。デフォルトでは、テキストを色付きの四角形で表示されてしまう(obscureText)ので、調整すると良さそうです。

② .gitignoreに無視するファイルを追加する

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

③ dart_test.yamlを作成して、タグを追加する

タグを設定して、そのタグを付与したテストのみを実行できるようにします。

dart_test.yaml
tags:
  - golden

テスト

Flutterデフォルトのカウンターアプリでテストしていきます。

基本

まずはシンプルにテストを書いてみます。

test/golden_test.dart
import 'package:alchemist/alchemist.dart';
import 'package:alchemist_test/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('Golden Test', () {
  goldenTest('Default', fileName: 'default', builder: () {
    return GoldenTestGroup(
      children: [
        GoldenTestScenario.builder(name: 'Initial state', builder: (context) {
          return SizedBox(
            width: MediaQuery.of(context).size.width,
            height: MediaQuery.of(context).size.height,
            child: const MyHomePage(title: 'Flutter Demo Home Page'),
          );
        }),
      ],
    );
  });
});
}

次に以下のコマンドを実行して、Goldenファイルを生成します。

flutter test --update-goldens

All tests passed! と表示され、test/goldensに画像(GoldenImage)が生成されたら成功です。

比較するGoldenImageができたので、テストを実行してUIの差分をチェックすることが可能になりました!

flutter test --tags=golden

複数デバイスでのテスト

複数のデバイスサイズのテストを行う場合は、以下のissueを参考にしてください。

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

golden_toolkitのクラスを使用したウィジェットを作成することで、テスト可能になるようです。

test/golden_test_device_scenario.dart
import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';

import 'device.dart';

/// Wrapper for testing widgets (primarily screens) with device constraints
class GoldenTestDeviceScenario extends StatelessWidget {
  final String name;
  final Device device;
  final ValueGetter<Widget> builder;

  const GoldenTestDeviceScenario({
    required this.name,
    required this.builder,
    this.device = Device.iphone16,
    super.key,
  });

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

Deviceクラスは、デバイスサイズをAIに頼って修正を入れています。

test/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 = const EdgeInsets.all(0),
  });

  // iPhone 16 Pro Max
  static const Device iphone16ProMax = Device(
    name: 'iphone_16_pro_max',
    size: Size(430, 932),
    devicePixelRatio: 3.0,
    safeArea: EdgeInsets.only(top: 59, bottom: 34),
  );

  // iPhone 16 Pro
  static const Device iphone16Pro = Device(
    name: 'iphone_16_pro',
    size: Size(393, 852),
    devicePixelRatio: 3.0,
    safeArea: EdgeInsets.only(top: 59, bottom: 34),
  );

  // iPhone 16 Plus
  static const Device iphone16Plus = Device(
    name: 'iphone_16_plus',
    size: Size(430, 932),
    devicePixelRatio: 3.0,
    safeArea: EdgeInsets.only(top: 59, bottom: 34),
  );

  // iPhone 16
  static const Device iphone16 = Device(
    name: 'iphone_16',
    size: Size(393, 852),
    devicePixelRatio: 3.0,
    safeArea: EdgeInsets.only(top: 59, bottom: 34),
  );

  // iPad Pro 12.9-inch (6th generation)
  static const Device iPadPro12_9 = Device(
    name: 'ipad_pro_12_9',
    size: Size(1024, 1366),
    devicePixelRatio: 2.0,
    safeArea: EdgeInsets.only(top: 24, bottom: 20),
  );

  // iPad Pro 11-inch (4th generation)
  static const Device iPadPro11 = Device(
    name: 'ipad_pro_11',
    size: Size(834, 1194),
    devicePixelRatio: 2.0,
    safeArea: EdgeInsets.only(top: 24, bottom: 20),
  );

  // iPad Air (5th generation)
  static const Device iPadAir = Device(
    name: 'ipad_air',
    size: Size(820, 1180),
    devicePixelRatio: 2.0,
    safeArea: EdgeInsets.only(top: 24, bottom: 20),
  );
  // 横向きバージョン
  static Device landscape(Device device) {
    return Device(
      name: '${device.name}_landscape',
      size: Size(device.size.height, device.size.width),
      devicePixelRatio: device.devicePixelRatio,
      safeArea: EdgeInsets.only(
        left: device.safeArea.top,
        right: device.safeArea.bottom,
        top: device.safeArea.right,
        bottom: device.safeArea.left,
      ),
    );
  }

  /// [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));

  /// [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 ?? this.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';
  }
}

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

ここまで出来たら、テストを書きます。

test/golden_test.dart
import 'package:alchemist/alchemist.dart';
import 'package:alchemist_test/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'device.dart';
import 'golden_test_device_scenario.dart';

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

    final devices = [Device.iphoneSE, Device.iphone16, Device.iphone16ProMax, Device.iPadPro12_9];

    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,
      );
    });
  });
}

GoldenImageを生成すると、このようになります。

同じようにGoldenTestを実行可能になりました。

まとめ

状態別テストも書きたかったのですが、間に合わなかったので、後日更新したいです。
画面の差分チェックが自動化されると、かなり生産性が上がるはずなので、これから導入を検討している方は参考にしてみてください!

参考

https://zenn.dev/greendrop/articles/2024-09-29-a1fa614645ba96
https://zenn.dev/taisei_dev/articles/f88b46aad05dba

PLUGO TECH

Discussion