7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter標準だけで始めるVRT(Visual Regression Testing)導入

Last updated at Posted at 2025-12-09

この記事は、GENDA Advent Calendar 2025 シリーズ2 Day10 の記事です。

はじめに

Flutterアプリ開発において、UIの変更が意図しない影響を与えていないかを確認するのは、レビューの際に特に時間がかかる作業です。スクリーンショットを撮って比較したり、実機で動作確認したりと、手作業での確認には限界があります。

そこで、UIの変更を自動的に検出するVRT(Visual Regression Testing、ビジュアルリグレッションテスト)を導入することにしました。VRTを実現するための様々なパッケージやサービスがありますが、今回は新しい依存を追加せず、Flutter標準の flutter_test に含まれる matchesGoldenFile だけを使ってゴールデンテストを実装します。

ゴールデンテストの基本的な書き方

まず、Flutter標準の matchesGoldenFile を使った基本的なテストコードを書きます。

import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('My widget golden test', (tester) async {
    // テスト対象のウィジェットを表示
    await tester.pumpWidget(
      MaterialApp(
        home: MyWidget(),
      ),
    );

    // ゴールデンファイルと比較
    await expectLater(
      find.byType(MaterialApp),
      matchesGoldenFile('goldens/my_widget.png'),
    );
  });
}

このコードを実行すると、初回は goldens/ ディレクトリにスクリーンショットが保存され、次回以降のテストでそれと比較されます。

最初の実行で発生した問題

早速テストを書いて実行してみたところ、予想外の事態が発生しました。スクリーンショットに表示されるはずの日本語が、すべて□(豆腐文字)になってしまったのです。これは、テスト環境のデフォルトフォントに日本語のグリフ(字形データ)が含まれていないためでした。

全文字化け

フォント読み込みの実装

最初の対策として、テスト起動時に日本語フォント(NotoSansJP)を読み込む仕組みを導入しました。今回は pubspecassets 登録を変更せず、テストコード側で直接フォントファイルを読み込むことで、本番コードに影響を与えずにテスト環境だけでフォントを使えるようにしました。

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

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

Future<ByteData> _loadFontFile(String path) async {
  try {
    final bytes = await File(path).readAsBytes();
    return ByteData.sublistView(Uint8List.fromList(bytes));
  } catch (e) {
    throw Exception('Failed to load font file: $path. Error: $e');
  }
}

Future<void> _loadNotoSansJp() async {
  final loader = FontLoader('NotoSansJP')
    ..addFont(_loadFontFile('assets/fonts/NotoSansJP-Regular.ttf'))
    ..addFont(_loadFontFile('assets/fonts/NotoSansJP-Bold.ttf'));
  await loader.load();
}

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  TestWidgetsFlutterBinding.ensureInitialized();
  await _loadNotoSansJp(); // デフォルトフォントから日本語対応フォントへ差し替え
  await testMain();
}

ボタンスタイルのフォント適用

本文の文字化けは解消できたものの、新たな問題が発覚しました。ボタンのラベルだけが、まだ豆腐のままだったのです。調査の結果、アプリ側で直接指定している ButtonStyle には、先ほど読み込んだフォントが適用されていないことが判明しました。テーマ設定だけではカバーできない部分があったのです。

ボタンのみ文字化け

そこで、テスト時にボタンスタイルを差し替える仕組みを追加しました。

// test/helpers/golden_test_app.dart 抜粋
ButtonStyle _withFont(ButtonStyle style) {
  final resolved = style.textStyle?.resolve({}) ?? const TextStyle();
  return style.copyWith(
    textStyle: WidgetStateProperty.all(
      resolved.copyWith(fontFamily: 'NotoSansJP'),
    ),
  );
}

/// グローバル変数を保存・復元するためのクラス
class ButtonStyleBackup {
  ButtonStyleBackup({
    required this.primary,
    required this.accent,
    required this.secondary,
    required this.tertiary,
    required this.text,
    required this.outlined,
  });

  factory ButtonStyleBackup.save() {
    return ButtonStyleBackup(
      primary: styles.primaryButtonStyle,
      accent: styles.accentButtonStyle,
      secondary: styles.secondaryButtonStyle,
      tertiary: styles.tertiaryButtonStyle,
      text: styles.textButtonStyle,
      outlined: styles.outlinedButtonStyle,
    );
  }

  final ButtonStyle primary;
  final ButtonStyle accent;
  final ButtonStyle secondary;
  final ButtonStyle tertiary;
  final ButtonStyle text;
  final ButtonStyle outlined;

  void restore() {
    styles.primaryButtonStyle = primary;
    styles.accentButtonStyle = accent;
    styles.secondaryButtonStyle = secondary;
    styles.tertiaryButtonStyle = tertiary;
    styles.textButtonStyle = text;
    styles.outlinedButtonStyle = outlined;
  }

  void applyFontedStyles() {
    styles.primaryButtonStyle = _withFont(primary);
    styles.accentButtonStyle = _withFont(accent);
    styles.secondaryButtonStyle = _withFont(secondary);
    styles.tertiaryButtonStyle = _withFont(tertiary);
    styles.textButtonStyle = _withFont(text);
    styles.outlinedButtonStyle = _withFont(outlined);
  }
}

/// ボタンスタイルにフォントを適用し、元の状態を保存する(内部用)
///
/// この関数はグローバル変数(styles.*)を書き換えます。
/// 必ず tearDown で restore() を呼んで元に戻してください。
///
/// 注意: runGoldenTest() 関数を使う場合、この関数を直接呼ぶ必要はありません。
/// setUp/tearDown は runGoldenTest() が自動的に処理します。
ButtonStyleBackup setupGoldenTest() {
  final backup = ButtonStyleBackup.save()..applyFontedStyles();
  return backup;
}

Widget buildGoldenApp(Widget child, {Locale locale = const Locale('ja')}) {
  final fontedTextTheme = themeData.textTheme.apply(
    fontFamily: 'NotoSansJP',
    bodyColor: themeData.colorScheme.onSurface,
    displayColor: themeData.colorScheme.onSurface,
  );
  final fontedPrimaryTextTheme = themeData.primaryTextTheme.apply(
    fontFamily: 'NotoSansJP',
    bodyColor: themeData.colorScheme.onPrimary,
    displayColor: themeData.colorScheme.onPrimary,
  );

  return ProviderScope(
    child: MaterialApp(
      debugShowCheckedModeBanner: false,
      locale: locale,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      theme: themeData.copyWith(
        textTheme: fontedTextTheme,
        primaryTextTheme: fontedPrimaryTextTheme,
        filledButtonTheme: FilledButtonThemeData(
          style: _withFont(themeData.filledButtonTheme.style!),
        ),
        outlinedButtonTheme: OutlinedButtonThemeData(
          style: _withFont(themeData.outlinedButtonTheme.style!),
        ),
        textButtonTheme: TextButtonThemeData(
          style: _withFont(themeData.textButtonTheme.style!),
        ),
      ),
      builder: (context, appChild) => DefaultTextStyle.merge(
        style: const TextStyle(fontFamily: 'NotoSansJP'),
        child: appChild ?? const SizedBox.shrink(),
      ),
      home: child,
    ),
  );
}

テストコードの共通化

共通処理をヘルパー関数にまとめ、テストコードを簡潔にしました。

// test/helpers/golden_test_helper.dart
void runGoldenTest({
  required String fileName,
  required Widget child,
  GoldenTestSize size = GoldenTestSize.phone,
  Locale locale = const Locale('ja'),
  List<String> tags = const ['golden'],
}) {
  final goldenPath = 'goldens/$fileName.png';

  group(fileName, () {
    late ButtonStyleBackup backup;

    setUp(() {
      backup = setupGoldenTest();
    });

    tearDown(() {
      backup.restore();
    });

    testWidgets(
      'renders correctly',
      (tester) async {
        final surfaceSize = size.toSize();
        await tester.binding.setSurfaceSize(surfaceSize);
        addTearDown(() async {
          await tester.binding.setSurfaceSize(null);
        });

        final widgetUnderTest = buildGoldenApp(
          MediaQuery(
            data: MediaQueryData(size: surfaceSize),
            child: child,
          ),
          locale: locale,
        );

        await tester.pumpWidget(widgetUnderTest);
        await tester.pumpAndSettle();

        await expectLater(
          find.byType(ProviderScope),
          matchesGoldenFile(goldenPath),
        );
      },
      tags: tags,
    );
  });
}

完成したテストコードは次のとおりです。

// test/features/order_complete/order_complete_page_golden_test.dart
import 'package:sh_benefits_app/api/services/data/model/services_model.dart';
import 'package:sh_benefits_app/features/order_complete/data/order_complete_route_data.dart';
import 'package:sh_benefits_app/features/order_complete/presentation/view/order_complete_page.dart';
import '../../helpers/golden_test_helper.dart';

void main() {
  const productCompleteData = OrderCompleteData(
    remainingPoint: 9800,
    serviceType: ServiceType.kleiner,
  );

  runGoldenTest(
    fileName: 'order_complete_product',
    child: const OrderCompletePage(data: productCompleteData),
  );
}

更新フローとCIでの運用

ゴールデンテストが完成したら、次は運用フローを確立します。

ローカルでの更新
意図的にUIを変更した場合は、ローカル環境でゴールデンファイルを更新します。

flutter test --update-goldens

通常のテスト実行:
flutter test コマンドで、ユニットテストとゴールデンテストを同時に実行できます。

flutter test

テスト失敗時の確認
UIに意図しない変更があった場合、ゴールデンテストは失敗し、差分画像が test/features/*/failures/ ディレクトリに生成されます。

Golden "goldens/order_complete_product.png": Pixel test failed, 0.43%, 12973px diff detected.
Failure feedback can be found at test/features/order_complete/failures

ポイント数を変更した場合の差分検出例です。わずかな数字の変更でも、差分として検出されます。

元の画像(期待値)
*_masterImage.png
テスト結果
*_testImage.png
masterImage testImage
差分をハイライト表示
*_maskedDiff.png
差分のみを抽出
*_isolatedDiff.png
maskedDiff isolatedDiff

差分が発生した箇所がハイライト表示されるため、どこが変更されたのかを視覚的に確認できます。

CI/CDでの自動化

次にゴールデンテストをGitHub Actionsに統合し、PRレビュー時に自動的に差分を確認できるようにします。

テストの分離とタグ付け

通常のユニットテストとゴールデンテストを分離するため、tags を使用します。runGoldenTest() ヘルパー関数はデフォルトで ['golden'] タグを設定します。

runGoldenTest(
  fileName: 'order_complete_product',
  child: const OrderCompletePage(data: productData),
  tags: ['golden'],  // デフォルトで設定済み
);

これにより、以下のように実行を分けられます

# 通常のテストのみ実行
flutter test --exclude-tags golden

# ゴールデンテストのみ実行
flutter test --tags golden

GitHub Actionsでの実行

CI環境では、通常のテストとゴールデンテストを別々のジョブで実行します。

# .github/workflows/flutter_ci.yml (抜粋)
flutter-test:
  runs-on: ubuntu-latest
  steps:
    - run: flutter test --exclude-tags golden --reporter expanded

golden-test:
  uses: ./.github/workflows/golden-test-reusable.yml

ゴールデンテストは、フォントレンダリングの一貫性を保つため、開発環境と同じmacOS上で実行します。

# .github/workflows/golden-test-reusable.yml (抜粋)
golden-test:
  runs-on: macos-latest
  permissions:
    contents: read  # 最小権限の原則
  steps:
    - run: flutter test --tags golden --reporter expanded

reg-cliによるレポート生成

テストが失敗した場合、reg-cliを使用してインタラクティブなHTMLレポートを自動生成します。

- name: Install reg-cli
  run: npm install -g reg-cli

- name: Prepare images for reg-cli
  run: |
    mkdir -p .reg/actual .reg/expected .reg/diff
    # 失敗したテスト画像を配置
    for test_image in test/features/*/failures/*_testImage.png; do
      filename=$(basename "$test_image" _testImage.png)
      cp "$test_image" ".reg/actual/${filename}.png"
      # 対応する期待値画像もコピー
    done

- name: Generate reg-cli report
  run: |
    reg-cli .reg/actual .reg/expected .reg/diff \
      -R .reg/report/index.html \
      -J .reg/reg.json \
      -I

生成されたレポートはGitHub Pagesに自動デプロイされ、PRコメントにリンクが投稿されるようにしました。

# レポート生成ジョブ(テスト失敗時のみ実行)
golden-report:
  needs: golden-test
  runs-on: ubuntu-latest
  if: always() && needs.golden-test.outputs.has-failures == 'true'
  permissions:
    contents: read          # チェックアウト用
    pull-requests: write    # PRコメント用
    pages: write            # GitHub Pagesデプロイ用
    id-token: write         # GitHub Pagesデプロイ用
  steps:
    - name: Deploy to GitHub Pages
      uses: actions/deploy-pages@v4

    - name: Comment on PR with failure info
      uses: actions/github-script@v7
      with:
        script: |
          const reportUrl = '${{ steps.deployment.outputs.page_url }}report/index.html';
          const comment = `## ⚠️ ゴールデンテスト失敗 (${failures.length}件)

          ${failureList}

          **[📊 レポートを開く](${reportUrl})** | [📦 Artifacts](${artifactsUrl})`;

          await github.rest.issues.createComment({
            owner: context.repo.owner,
            repo: context.repo.repo,
            issue_number: prNumber,
            body: comment
          });

インタラクティブなレポート

reg-cliで生成されるレポートでは、以下の機能が利用できます

reg-cliレポート画面
reg-cliのレポート画面。左側に変更されたファイル一覧が表示される

レポートでは複数の表示モードで差分を確認できます

2-up表示(左右比較)

2up

Blend表示(オーバーレイ比較)

blend

Diff表示(差分のみ表示)

diff

Slider表示(スライダーで左右を切り替え)

slide

まとめ

最小構成でもVRTは十分に機能し、さらにCI/CDに統合することで強力な開発基盤を構築できました。

このシンプルな実装で、UI崩れの早期検知とレビュー効率化という目的を達成できました。新しい依存を追加せず、Flutter標準の機能だけで実用的なVRTを構築し、さらにCI/CDパイプラインに統合することで、チーム全体の開発効率を向上させることができたのは、大きな収穫だったと言えます。

まだゴールデンテストを導入していないプロジェクトがあれば、ぜひこの記事を参考に導入を検討してみてください:santa:

参考

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?