🎭

golden_testを使用してVRTをやってみよう

に公開

概要

golden_testというライブラリを使用して、今回はVRTと呼ばれるWidgetTestをして画面に正しくテキストやボタンが表示されているか検証するだけでなくスクリーンショットを撮るテストをやってみました。

  • 記事の対象者
    • Flutterでテストコードを書いたことがある人
    • VRTなるものに興味がある

そもそもVRTとは?
Visual Regression Test (VRT)とはアプリケーションの外観の変化を自動検出するリグレッションテストの手法です。 意図しない変更が加わっていないことを外観ベースで確認できます。 UIオートメーションによってアプリケーションを操作し、比較元のキャプチャ(ベースライン)との画素の差分を検出します。

今回は、Claude CodeDart and Flutter MCP serverを活用して割と簡単に実装することできました。

詰まったところがあった💦
それがどうやらFlutterからフォントを呼び出す設定をしないとスクリーンショットを撮った時に文字が表示されない問題がありました。。。

回避策もご紹介しておきますので参考までに見てみてください。

ではやってみよう!

このプロジェクトでは、画面遷移を含むFlutterアプリのビジュアルリグレッションテストを実装しています。ライトモードとダークモードの両方でスクリーンショットを比較し、UIの変更を検出します。

アプリ構成

完成品はこちらにあるので参考にしてみてください。

Flutterのバージョン管理ツールのFVMを使用して今回のプロジェクは作成されました。

  • MyHomePage: ホーム画面(「Next Page」ボタンで次画面へ遷移)
  • NextPage: 遷移先画面(「Welcome to the Next Page」テキストと戻るボタン)

注意点

  • FLUTTER_ROOT 環境変数が設定されていない場合は、Flutter SDKのパスを直接指定する必要があります
  • FVMを使用している場合は、該当バージョンのパスを _findFlutterRoot() に追加してください
  • path パッケージを dev_dependencies に追加する必要があります

ディレクトリ構成

golden_test_app/
├── lib/
│   └── main.dart                    # アプリのメインコード
├── test/
│   ├── flutter_test_config.dart     # フォントローダー設定
│   ├── navigation_golden_test.dart  # ゴールデンテスト
│   └── goldens/                     # 生成されるゴールデン画像
│       └── en/
│           ├── light/               # ライトモード
│           │   ├── HomePage - 初期表示.png
│           │   └── NextPage - 遷移後の表示.png
│           └── dark/                # ダークモード
│               ├── HomePage - 初期表示.png
│               └── NextPage - 遷移後の表示.png
└── pubspec.yaml

依存パッケージ

dependencies:
  golden_test: ^0.1.6

dev_dependencies:
  flutter_test:
    sdk: flutter
  path: ^1.9.1

テストケース

テストケース 説明 テーマ
HomePage - 初期表示 ホーム画面のスクリーンショット比較 Light / Dark
NextPage - 遷移後の表示 遷移先画面のスクリーンショット比較 Light / Dark
ボタンタップで画面遷移 ボタンタップ → 画面遷移 → 表示確認 -

実行コマンド

# 依存関係のインストール
fvm flutter pub get

# ゴールデンテストの実行(比較モード)
fvm flutter test test/navigation_golden_test.dart

# ゴールデン画像の更新(基準画像を再生成)
fvm flutter test test/navigation_golden_test.dart --update-goldens

フォント表示の設定

Flutterのゴールデンテストでは、デフォルトでテスト用の「Ahem」フォントが使用されるため、テキストが黒い四角形で表示されてしまいます。

実際のフォント(Roboto)を表示するには、test/flutter_test_config.dart でフォントをロードする必要があります。

設定ファイル

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

import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as path;

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  TestWidgetsFlutterBinding.ensureInitialized();
  await loadAppFonts();
  await testMain();
}

Future<void> loadAppFonts() async {
  final flutterRoot = _findFlutterRoot();
  if (flutterRoot != null) {
    await _loadFont('Roboto', [
      path.join(flutterRoot, 'bin', 'cache', 'artifacts', 'material_fonts', 'Roboto-Regular.ttf'),
    ]);
    await _loadFont('MaterialIcons', [
      path.join(flutterRoot, 'bin', 'cache', 'artifacts', 'material_fonts', 'MaterialIcons-Regular.otf'),
    ]);
  }
}

String? _findFlutterRoot() {
  final possiblePaths = [
    Platform.environment['FLUTTER_ROOT'],
    // FVMを使用している場合はパスを追加
    // '/path/to/fvm/versions/x.x.x',
  ];

  for (final p in possiblePaths) {
    if (p != null && Directory(p).existsSync()) {
      return p;
    }
  }
  return null;
}

Future<void> _loadFont(String fontFamily, List<String> fontPaths) async {
  final fontLoader = FontLoader(fontFamily);
  for (final fontPath in fontPaths) {
    final file = File(fontPath);
    if (await file.exists()) {
      final bytes = await file.readAsBytes();
      fontLoader.addFont(Future.value(ByteData.view(Uint8List.fromList(bytes).buffer)));
    }
  }
  await fontLoader.load();
}

UIを作成

main.dartに全部書いてますが、これで画面遷移に必要なソースコードは用意できました。

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

void main() {
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Home Page'),
      ),
      body: Center(
        child: ElevatedButton(onPressed: () {
          Navigator.of( context).push(
            MaterialPageRoute(builder: (context) => const NextPage()),
          );
        }, child: const Text("Next Page")),
      ),
    );
  }
}

class NextPage extends StatelessWidget {
  const NextPage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Home Page'),
      ),
      body: Center(
        child: Column(
          spacing: 10.0,
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            const Text("Welcome to the Next Page", style: TextStyle(fontSize: 24, color: Colors.blue)),
            ElevatedButton(onPressed: () {
              Navigator.of(context).pop();
            }, child: const Text("Next Page")),
          ],
        ),
      ),
    );
  }
}

test/navigation_golden_test.dartにWidgetTestのソースコードを作成します。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_test/golden_test.dart';
import 'package:golden_test_app/main.dart';

void main() {
  // ライトモードとダークモード両方でテスト
  goldenTestSupportedThemes = [Brightness.light, Brightness.dark];
  goldenTestThemeInTests = ThemeData.light();
  goldenTestDarkThemeInTests = ThemeData.dark();

  group('画面遷移のVRT', () {
    // ホームページの表示テスト
    goldenTest(
      name: 'HomePage - 初期表示',
      builder: (_) => const MyHomePage(),
    );

    // NextPageの表示テスト
    goldenTest(
      name: 'NextPage - 遷移後の表示',
      builder: (_) => const NextPage(),
    );

    // 画面遷移のテスト(ボタンタップ → 画面遷移 → 次ページの表示確認)
    testWidgets('ボタンタップで画面遷移し、NextPageが表示される', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          theme: ThemeData.light(),
          darkTheme: ThemeData.dark(),
          home: const MyHomePage(),
        ),
      );

      // ホームページが表示されていることを確認
      expect(find.text('My Home Page'), findsOneWidget);
      expect(find.text('Next Page'), findsOneWidget);

      // ボタンをタップ
      await tester.tap(find.widgetWithText(ElevatedButton, 'Next Page'));
      await tester.pumpAndSettle();

      // NextPageが表示されていることを確認
      expect(find.text('Welcome to the Next Page'), findsOneWidget);
      expect(find.widgetWithText(ElevatedButton, 'Next Page'), findsOneWidget);
    });
  });
}

これで準備完了です。

Screen Shot

Darkモードでテスト

画面遷移前。

画面遷移後。

Lightモードでテスト

画面遷移前。

画面遷移後。

感想

今回は、FlutterでVRTをやってみました。昔からこんな風にUIテストしてスクリーンショットを撮るのはあったみたいですが、そもそも私は、「VRT」なるものを知りませんでした(^_^;)
時代に取り残されてそうと思い勉強してみた。参画先の案件では、WebのチームだけPlaywrightを使用してVRTを実施していたので、ああやってみて〜と思いプライベートな時間でやってみる。

参考リンク

Discussion