effidevFlutter・Cloudflareエッジ・クラウドコスト最適化

ゴールデンテストが自分のmacOSでは通るのにCIのLinuxでは落ち続ける理由

ゴールデンテストが自分のmacOSでは通るのにCIのLinuxでは落ち続ける理由

ゴールデンテストを初めて導入したチームは、ほぼ例外なく同じ順番で挫折する。ローカルでflutter testを回すとグリーンなのに、同じコミットをCIに上げるとゴールデンテストだけが真っ赤に失敗する。diff画像を開いてみるとピクセルが数個ずれている程度なので「とりあえず再実行すれば通るだろう」と流し始め、やがてゴールデンテストのジョブ全体がチームから信頼を失っていく。

この記事は「ゴールデンテストとは何か」という入門レベルの内容ではない。すでにゴールデンテストを導入していて、matchesGoldenFileがローカルのmacOSとCIのLinuxコンテナの間でなぜ異なる結果を出すのか、そしてそれを構造的にどう直すのかを扱う。

要点まとめ

なぜゴールデンテストで「自分のPCでは動くのに」が特に頻発するのか

一般的な単体テストは入力と出力が論理値なので、プラットフォームが違っても結果は変わらない。しかしゴールデンテストはウィジェットを実際にラスタライズ(rasterize)したピクセル画像を比較する。つまりテスト対象が「ロジックが正しいか」ではなく「絵が同じかどうか」であるため、描画エンジンやフォント、画面密度がわずかに違うだけで失敗してしまう。

FlutterのmatchesGoldenFileはデフォルトで完全なバイト単位の比較を行う。Flutterの公式ドキュメントにも「カスタムフォントはプラットフォームやFlutterのバージョンによって異なるレンダリング結果になり得る」と明記されており、Windowsで生成したゴールデンファイルは他のOSではほぼ確実に失敗するとまで警告している。つまりこれはバグではなく、ドキュメント化された既知の限界なのだ。

根本原因1: フォントが違う

FlutterのテストバインディングはデフォルトでAhemフォントしかロードしない。Ahemは文字ごとに黒い四角(正確にはグリフボックス)を描画するテスト専用フォントで、実フォントをロードしない限り、テキストを含むウィジェットのゴールデン画像は全て四角い枠で埋め尽くされる。

ここでチームがよくやってしまうミスは次の2パターンのどちらかだ。

どちらのケースも「ローカルでは通るのにCIでは通らない」というまさにその症状を作り出す。

根本原因2: DPR(デバイスピクセル比)とテキストスケールが違う

WidgetTesterが描画するゴールデン画像の実ピクセルサイズは、論理ピクセル(logical pixel) × DPRで決まる。ローカルのmacOS開発機のRetinaディスプレイ設定やIDEのデフォルトテストランナー設定がCIのデフォルト値と異なると、全く同じウィジェットツリーを描画しても画像の解像度自体が変わってしまいdiffが発生する。テキストスケールファクター(textScaleFactor)も同様で、システムのアクセシビリティ設定やテスト環境の初期値がわずかに違うだけで改行位置が変わり、画像がまるごと変わってしまうことがある。

根本原因3: プラットフォーム別のラスタライザの違い

フォントとDPRを完全に固定しても失敗するケースがある。これはテキストのアンチエイリアシング、サブピクセルレンダリング、ヒンティングのアルゴリズムがレンダリングバックエンドごとに異なるためだ。FlutterのGitHub issueトラッカーには「同じDockerイメージを使っていてもホストプラットフォーム(Windows DockerとmacOS Docker)によってゴールデンテストのピクセル差が1個から90個まで変動する」という報告すらあり、この問題はFlutterエンジンのテキストシェイピング・ラスタライズスタックの深いところで発生している。つまりDockerでコンテナを固定するだけでは100%解決しない可能性があるという前提で取り組む必要がある。

ローカルのmacOSとCIのLinuxコンテナが構造的に違う絵を描く理由

まとめると、次のような構造的な差が積み重なっている。

この4つが重なると、「コードは変わっていないのにゴールデンだけが壊れる」という典型的なフレーキーさが生まれる。

解決レシピ1: テスト環境にフォントをバンドルして固定する

最初にやるべきことは、どの環境でテストが実行されても常に同じフォントで描画されるように強制することだ。

flutter_test_config.dartでアプリのフォントをロードする

Flutterのテストフレームワークは、テストファイルが置かれたディレクトリから上位へ遡ってflutter_test_config.dartを探し、テスト実行前に適用する。ここでフォントをロードすれば、全てのテストに一括で適用される。

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

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  await loadAppFonts(); // Roboto + pubspecに登録されたカスタムフォントをロード
  return testMain();
}

loadAppFonts()はpubspec.yamlのfonts:セクションと、依存パッケージが持つフォントを自動的に読み取ってテストバインディングに注入してくれる。ただしいくつか落とし穴がある。

あえてAhemを積極的に活用する

逆説的ではあるが、「実フォントで美しくレンダリングする」ことを諦めてAhemをCI専用の検証手段として使う戦略もある。テキストの内容ではなくレイアウト(整列、サイズ、改行の有無)だけを検証したいのであれば、グリフの形がプラットフォームごとに変わりうる実フォントよりも、常に同じ四角を描くAhemの方がむしろ安定する。後述するalchemistはこの戦略を「CIゴールデン」という概念として公式にサポートしている。

golden_bricks: Ahemと実フォントの中間

Ahemの問題は、全ての文字が同じ四角になるため、キャレット位置、テキスト選択、改行のように文字幅が実際に異なって初めて検証できるケースをテストできない点にある。golden_bricksはこの隙間を埋めるパッケージで、文字ごとに大きさの異なる四角を描画し、「四角ではあるが幅は実テキストのように異なる」レンダリングを行う。

# pubspec.yaml (dev_dependencies)
golden_bricks: ^1.0.0
MaterialApp(
  theme: ThemeData(fontFamily: goldenBricks),
  home: const MyWidget(),
)

プラットフォーム依存のある実フォントの代わりに、こうした決定論的(deterministic)なフォントを標準として採用すれば、そもそもCoreText/FreeTypeの違いが結果に影響を与える余地自体をなくすことができる。

解決レシピ2: DPRとテキストスケールをテストコード側で強制的に固定する

WidgetTesterが提供するTestFlutterView(tester.view)を通じて、画面密度とサイズを明示的に固定できる。この値を指定しなければ、テストを実行するマシンのデフォルト値に暗黙のうちに依存することになる。

testWidgets('商品カードのゴールデンテスト', (tester) async {
  tester.view.physicalSize = const Size(1080, 2400);
  tester.view.devicePixelRatio = 3.0;

  // テスト終了後、次のテストに影響しないよう必ずリセットする
  addTearDown(tester.view.reset);

  await tester.pumpWidget(const MyApp(home: ProductCard()));
  await tester.pumpAndSettle();

  await expectLater(
    find.byType(ProductCard),
    matchesGoldenFile('goldens/product_card.png'),
  );
});

ポイントは次の通りだ。

このパターンを全てのゴールデンテストで繰り返す代わりに、共通のヘルパー関数(pumpGoldenのような名前)にまとめてチーム全体で同じ基準値を使うように強制することを推奨する。基準値がファイルごとにバラバラだと、「このファイルはDPR 2.0なのにあのファイルは3.0」といった別の不整合が生まれてしまう。

解決レシピ3: 許容誤差ベースのdiffツールを使う

フォントとDPRを全て固定しても、前述のラスタライザの違いによって完全に同一のバイトにはならないケースが現実には残る。この場合、完全一致(exact match)のバイト比較ではなく許容誤差(tolerance)ベースの比較に切り替えるのが実用的な選択だ。

golden_toolkit vs alchemist 比較

項目 golden_toolkit alchemist
メンテナンス状況 discontinued(pub.dev基準、最新バージョン0.15.0、数年更新なし) 活発にメンテナンスされている(Very Good Ventures + Betterment、最新0.14.0)
フォントロード loadAppFonts()を提供 flutter_test_config.dartで同様のパターンをサポート
ピクセル許容誤差 標準では提供されない(カスタムcomparatorを自前実装する必要がある) diffThresholdパラメータで0.0〜1.0の間の許容誤差割合を指定可能
プラットフォーム別/CI別ゴールデンの分離 非対応(自前でskipロジックを書く必要がある) platformゴールデンとciゴールデンをフォルダで自動的に分離 — CIゴールデンはAhemベースなのでプラットフォームの影響を受けない
複数画面サイズの同時テスト DeviceBuildermultiScreenGolden() GoldenTestGroup + GoldenTestScenarioでシナリオをグルーピング

新規プロジェクトであればgolden_toolkitよりもalchemistを優先的に検討するのが合理的だ。golden_toolkitはpub.dev上でdiscontinuedと表示されており、alchemistはこの記事が扱っている問題(プラットフォーム間のレンダリング差異)を正面から狙って設計されているためだ。

alchemistのdiffThreshold使用例

void main() {
  setUpAll(() {
    AlchemistConfig.current = AlchemistConfig(
      platformGoldensConfig: const PlatformGoldensConfig(
        enabled: true,
      ),
      ciGoldensConfig: const CiGoldensConfig(
        enabled: true,
      ),
    );
  });

  goldenTest(
    '商品カード',
    fileName: 'product_card',
    pixelDiffConfig: const GoldenTestPixelDiffConfig(threshold: 0.01),
    widget: const GoldenTestGroup(
      children: [ProductCard()],
    ),
  );
}

threshold: 0.01全ピクセルのうち1%未満の差であれば失敗として扱わないという意味だ。この値を大きく設定しすぎると実際のUIリグレッション(regression)まで通してしまう落とし穴があるので、最初は0.005〜0.01程度の小さい値から始め、チームの実際のフレーキーさの発生頻度を見ながら調整することを勧める。

カスタムcomparatorという選択肢

パッケージを追加したくない場合、LocalFileComparatorを継承してcompare()メソッドの中でピクセル差の割合を自前で計算するカスタムcomparatorをflutter_test_config.dartに登録するという手もある。ただしこの道は画像のデコード、リサイズ、アンチエイリアシング境界の処理を自分で実装する必要があるため、チーム規模が小さいならalchemistの導入の方がはるかにコスト効率が良い

解決レシピ4: CIパイプラインでゴールデンテストを別ジョブとして分離する

フォント・DPR・許容誤差を全て押さえても、CIパイプラインの設計自体が甘いとフレーキーさは再発する。次の原則を推奨する。

GitHub Actionsの例

name: golden-tests

on:
  pull_request:
    paths:
      - 'lib/**'
      - 'test/**'

jobs:
  golden:
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/your-org/flutter-ci:3.35.0  # SDKバージョンを固定したカスタムイメージ
    steps:
      - uses: actions/checkout@v4

      - name: Cache pub dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.pub-cache
            .dart_tool
          key: pub-${{ hashFiles('pubspec.lock') }}

      - run: flutter pub get

      - name: Run golden tests
        run: flutter test --tags golden

      - name: Upload golden diff on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: golden-failures
          path: |
            test/**/failures/*.png

それでも失敗する場合に確認すべきチェックリスト

まとめ

ゴールデンテストのフレーキーさは、多くの場合「テストコードが間違っているから」ではなく、テストが実行されるレンダリング環境が制御されていないから起きる。フォントをバンドルしてレンダラーの違いを取り除き、DPR・テキストスケールをコード側で明示的に固定し、それでも残るわずかな差は許容誤差ベースのdiffで吸収し、最後にCIパイプライン自体を再現可能な形で設計する。この4つのステップを順番に踏んでいけば、「自分のPCでは動くのにCIでだけ壊れる」ゴールデンテストの大半は姿を消すはずだ。