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

ゴールデンテストを初めて導入したチームは、ほぼ例外なく同じ順番で挫折する。ローカルでflutter testを回すとグリーンなのに、同じコミットをCIに上げるとゴールデンテストだけが真っ赤に失敗する。diff画像を開いてみるとピクセルが数個ずれている程度なので「とりあえず再実行すれば通るだろう」と流し始め、やがてゴールデンテストのジョブ全体がチームから信頼を失っていく。
この記事は「ゴールデンテストとは何か」という入門レベルの内容ではない。すでにゴールデンテストを導入していて、matchesGoldenFileがローカルのmacOSとCIのLinuxコンテナの間でなぜ異なる結果を出すのか、そしてそれを構造的にどう直すのかを扱う。
要点まとめ
- ゴールデンテストのフレーキーさ(flakiness)の根本原因は、ほぼ常に次の3つのいずれかである: フォント、DPR(デバイスピクセル比)、プラットフォーム別のラスタライザ。 コードのロジックの問題ではなく、レンダリング環境の問題だ。
- ローカルのmacOSはCoreText、CIのLinuxコンテナはFreeType/fontconfigでテキストを描画する。 同じフォントファイルを使っていても、アンチエイリアシングやヒンティングのアルゴリズムが異なるため、ピクセル単位で違う画像になる。
- 解決策は大きく3つある。(1) テスト環境にフォントをバンドルしてレンダラー依存をなくす、(2) DPR・テキストスケールをテストコード側で強制的に固定する、(3) 完全一致のバイト比較ではなく許容誤差ベースのdiffを使う。
- CI設計の観点では、ゴールデンテストを別ジョブとして分離し、レンダリング環境(Dockerイメージ)をバージョン固定し、失敗時にdiff画像をアーティファクトとして残すパイプラインが必要になる。
なぜゴールデンテストで「自分のPCでは動くのに」が特に頻発するのか
一般的な単体テストは入力と出力が論理値なので、プラットフォームが違っても結果は変わらない。しかしゴールデンテストはウィジェットを実際にラスタライズ(rasterize)したピクセル画像を比較する。つまりテスト対象が「ロジックが正しいか」ではなく「絵が同じかどうか」であるため、描画エンジンやフォント、画面密度がわずかに違うだけで失敗してしまう。
FlutterのmatchesGoldenFileはデフォルトで完全なバイト単位の比較を行う。Flutterの公式ドキュメントにも「カスタムフォントはプラットフォームやFlutterのバージョンによって異なるレンダリング結果になり得る」と明記されており、Windowsで生成したゴールデンファイルは他のOSではほぼ確実に失敗するとまで警告している。つまりこれはバグではなく、ドキュメント化された既知の限界なのだ。
根本原因1: フォントが違う
FlutterのテストバインディングはデフォルトでAhemフォントしかロードしない。Ahemは文字ごとに黒い四角(正確にはグリフボックス)を描画するテスト専用フォントで、実フォントをロードしない限り、テキストを含むウィジェットのゴールデン画像は全て四角い枠で埋め尽くされる。
ここでチームがよくやってしまうミスは次の2パターンのどちらかだ。
- ローカルではたまたまシステムフォント(macOSのヒラギノやSan Franciscoなど)がインストールされているためそれらしいゴールデン画像が生成され、それをそのままコミットしてしまう。
- アプリのフォントは一応ロードしているものの、CIコンテナにそのフォントファイルが存在しない、あるいはライセンスの都合でバンドルできず、フォールバックフォントで描画されてしまう。
どちらのケースも「ローカルでは通るのに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コンテナが構造的に違う絵を描く理由
まとめると、次のような構造的な差が積み重なっている。
- テキストレンダリングスタック: macOSはCoreText、Linuxは大半がFreeType + fontconfigの組み合わせを使う。同じTTFファイルを渡してもヒンティングとアンチエイリアシングのアルゴリズムが違うため、ピクセル値が異なる。
- フォントの可用性: ローカルのmacOSにはシステムフォントが豊富にインストールされているが、CIの最小限のLinuxコンテナ(
ubuntu-latestのGitHub Actionsランナーや、Flutter CIのDockerイメージなど)には指定していないフォントがそもそも存在せず、フォールバックチェーンが変わってしまう。 - GPU/ソフトウェアレンダラーの違い: CIはほとんどがヘッドレス環境なのでソフトウェアラスタライザを使う一方、ローカルのmacOSではハードウェアアクセラレーションのパスが使われることがある。
- Flutter/Skiaエンジンのバージョン不一致: ローカルのSDKとCIにキャッシュされたSDKのバージョンが違うと、Skia自体のレンダリング結果が変わる。
この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:セクションと、依存パッケージが持つフォントを自動的に読み取ってテストバインディングに注入してくれる。ただしいくつか落とし穴がある。
- Flutterのテスト環境はフォントファミリーごとに1つのウェイト(.ttf)しかサポートしないため、Bold/Regularを混在させるウィジェットは実際のアプリと微妙に違って見える可能性がある。
- Materialアイコンを使うには、pubspecに
uses-material-design: trueがないとアイコンフォントがロードされない。 - デバッグバナーのようなフレームワーク内部のテキストは依然としてAhemで描画されることがあり、完全には制御しきれない部分が残る。
あえて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'),
);
});
ポイントは次の通りだ。
physicalSizeとdevicePixelRatioを明示的に指定すれば、ローカルマシンのディスプレイ設定やCIランナーのデフォルト解像度が結果に影響しなくなる。addTearDown(tester.view.reset)を必ず呼ぶこと。これを怠ると、同一テストファイル内で後から実行されるテストが前のテストの残したDPR値をそのまま引き継いでしまう、実行順序に依存する別のフレーキーさが生まれる。- テキストスケールも同じ原理で、
tester.platformDispatcher.textScaleFactorTestValueで固定するか、MediaQueryをラップしてtextScaler: TextScaler.noScalingのように明示的にオーバーライドするのが安全だ。
このパターンを全てのゴールデンテストで繰り返す代わりに、共通のヘルパー関数(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ベースなのでプラットフォームの影響を受けない |
| 複数画面サイズの同時テスト | DeviceBuilder、multiScreenGolden() |
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パイプラインの設計自体が甘いとフレーキーさは再発する。次の原則を推奨する。
- ゴールデンテストを通常の単体テストとは別のjobに分離する。 ゴールデンテストはレンダリング環境に敏感なので、失敗時の再試行ポリシーやアーティファクトのアップロード設定を単体テストとは別建てにすべきだ。
- Flutter SDKのバージョンとDockerイメージを正確に固定する(
flutter --versionを明示するか、SHA固定されたDockerイメージを使う)。SDKのマイナーバージョンが変わるとSkiaのレンダリング結果が変わりゴールデンを全て作り直さなければならない状況は避けられないが、少なくとも「自分のローカルSDKとCIのSDKが違う」という原因は排除できる。 - 失敗時にdiff画像をアーティファクトとしてアップロードする。 レビュアーがログだけを見て「何ピクセル違うのか」を推測するのではなく、実際の画像を目で見て比較できるようにする。
- ゴールデンの更新は必ずCIと同じ環境(Docker)で行う。 ローカルのmacOSで
flutter test --update-goldensによって更新したファイルをそのままコミットすることは、この記事全体で説明してきた問題を自ら再生産することに等しい。
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
--tags goldenでゴールデンテストだけを別タグとして実行し、実行時間を短縮しつつ失敗原因を絞り込む(テストファイルの先頭に@Tags(['golden'])アノテーションを付ける)。- pubキャッシュはキーで管理して依存関係のインストール時間を短縮する一方、レンダリング結果に影響するSDKイメージ自体はキャッシュせず明示的にバージョン固定する。 キャッシュされたイメージが知らぬ間に更新されると、「昨日まで通っていたゴールデンが今日突然壊れる」という新種のフレーキーさが生まれる。
- ローカルでCIと全く同じ条件で再現したいなら、CIで使っているものと同じDockerイメージをローカルでそのまま実行してゴールデンを更新するのが最も信頼できる方法だ。ただし前述のGitHub issueのように、ホストOSが違うと同じイメージでも完全に同じピクセルになるとは限らないという点は踏まえておく必要がある。この場合の最後の手段はdiffThresholdを少し上げることになる。
それでも失敗する場合に確認すべきチェックリスト
flutter_test_config.dartが実際に実行されるディレクトリの範囲に置かれているか(モノレポでパッケージごとに漏れているケースがよくある)- ゴールデンファイルを生成したFlutter SDKのバージョンとCIのSDKバージョンが正確に一致しているか(
flutter --versionを比較) tester.view.reset()またはaddTearDownを書き忘れて、前のテストのDPR/テキストスケールが漏れ込んでいないか- pubspecのフォントライセンスファイルが実際にリポジトリにコミットされていて、CIからもアクセス可能か(一部の商用フォントはローカルの開発者マシンにだけインストールされていて、リポジトリには含まれていないことがある)
- alchemistを使っているなら、
ciGoldensConfigとplatformGoldensConfigのうちCIで実際に検証したい方が正しく有効化されているか
まとめ
ゴールデンテストのフレーキーさは、多くの場合「テストコードが間違っているから」ではなく、テストが実行されるレンダリング環境が制御されていないから起きる。フォントをバンドルしてレンダラーの違いを取り除き、DPR・テキストスケールをコード側で明示的に固定し、それでも残るわずかな差は許容誤差ベースのdiffで吸収し、最後にCIパイプライン自体を再現可能な形で設計する。この4つのステップを順番に踏んでいけば、「自分のPCでは動くのにCIでだけ壊れる」ゴールデンテストの大半は姿を消すはずだ。