Alchemistを使ってGoldenTestを導入してみた
はじめに
FlutterのVRT(Visual Regression Test)に興味があったので、GoldenTestを導入してみました。
従来ではgolden_toolkitの使用が主流でしたが、今年の9月にメンテナンスが中止されてしまったようです。
調べてみると、Flutter標準機能の他に、Alchemistというgolden_toolkitに影響を受けたパッケージがあったのでこれを使ってみます。
導入
1. パッケージを追加
flutter pub add alchemist
2. 設定
まずは、こちらの説明に従って、推奨設定を行います。
① 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,
);
}
を参考にすると、カスタムも可能です。デフォルトでは、テキストを色付きの四角形で表示されてしまう(obscureText)ので、調整すると良さそうです。
② .gitignoreに無視するファイルを追加する
# Ignore non-CI golden files and failures
test/**/goldens/**/*.*
test/**/failures/**/*.*
!test/**/goldens/ci/*.*
③ dart_test.yamlを作成して、タグを追加する
タグを設定して、そのタグを付与したテストのみを実行できるようにします。
tags:
- golden
テスト
Flutterデフォルトのカウンターアプリでテストしていきます。
基本
まずはシンプルにテストを書いてみます。
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を参考にしてください。
golden_toolkitのクラスを使用したウィジェットを作成することで、テスト可能になるようです。
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に頼って修正を入れています。
// 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';
}
}
ここまで出来たら、テストを書きます。
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を実行可能になりました。
まとめ
状態別テストも書きたかったのですが、間に合わなかったので、後日更新したいです。
画面の差分チェックが自動化されると、かなり生産性が上がるはずなので、これから導入を検討している方は参考にしてみてください!
参考
Discussion
👏Nice Blog