Flutter Alchemist で Golden Test / VRT を行う
はじめに
Flutter にて画面差分やデザインの崩れがないかを、 Golden Test / Visual Regression Test(以下、VRT)でテストする方法があります。
Flutter では golden_toolkit が有名ですが、2024/09/12 にメンテナンスされなくなったことが公開されました。
そこで、golden_toolkit にインスパイアされたAlchemistを導入してみました。
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 のREADME や Recommended 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 フォントに置き換えない)
-
ciGoldensConfig
でobscureText: false
を指定
-
- 影を表示
-
ciGoldensConfig
でrenderShadows: true
を指定
-
- CI のみを対象とし、個別のプラットフォームは無効
-
platformGoldensConfig
でenabled: 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
比較画像が作成されれば、以下のコマンドで画面差分やデザインの崩れがないことをテストできます。
flutter test --tags=golden
まとめ
Flutter Alchemist を導入し、Golden Test / VRT を行いました。
Flutter で Golden Test / VRT をしたい場合や golden_toolkit からの移行として Alchemist を使用してみてはいかがでしょうか。
Discussion