大規模テストの実践:FlutterのテストインフラとCIパイプライン、品質ゲート
前提知識
- ›第1〜3回:レイヤー階層とバインディングアーキテクチャ(テストバインディングを理解するために必須)
- ›Flutterのウィジェットテストを書いた基本的な経験
- ›CI/CDの概念に関する基礎知識
大規模テストの実践:FlutterのテストインフラとCIパイプライン、品質ゲート
160万行のコード、200人以上のコントリビューター、数百万の本番アプリを動かすリリースを抱えるコードベースで、どうやって品質を維持するのか。Flutterの答えは、フレームワーク本体と同様に、アーキテクチャとして非常に興味深いテストインフラです。
このシリーズを通じて学んできたことと深く結びついている核心的な洞察があります。それは、第3回で解説したバインディングアーキテクチャこそが、Flutterのテスト戦略を可能にしているという点です。WidgetsFlutterBinding を TestWidgetsFlutterBinding に切り替えることで、テストフレームワークはフレームのタイミング、レンダリング、プラットフォームサービスを完全にコントロールできます。しかもフレームワークのコードを1行も変更せずに。
flutter_test:WidgetTester、Finder、Matcher
packages/flutter_test/ パッケージは、Flutter開発者が日常的に使うテスト用APIを提供しています。packages/flutter_test/lib/src/ 以下のソースファイルは次のとおりです。
| ファイル | 役割 |
|---|---|
widget_tester.dart |
testWidgets()、WidgetTester |
binding.dart |
TestWidgetsFlutterBinding |
controller.dart |
入力シミュレーション(タップ、ドラッグ、スクロール) |
finders.dart |
ウィジェット/エレメントツリーへのクエリ |
matchers.dart |
Flutter固有のテストマッチャー |
goldens.dart |
ゴールデンイメージ(スクリーンショット)テスト |
エントリーポイントは、widget_tester.dart の148行目にある testWidgets() です。
packages/flutter_test/lib/src/widget_tester.dart#L148-L164
@isTest
void testWidgets(
String description,
WidgetTesterCallback callback, {
bool? skip,
test_package.Timeout? timeout,
bool semanticsEnabled = true,
TestVariant<Object?> variant = const DefaultTestVariant(),
// ...
}) {
final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized();
final tester = WidgetTester._(binding);
最初に行っているのは TestWidgetsFlutterBinding.ensureInitialized() です。これにより、本番用のバインディングではなくテスト用のバインディングが確実にインストールされます。第3回で学んだとおり、バインディングはシングルトンなので、最初に初期化された方が有効になります。テストファイルは、本番コードが実行される前に TestWidgetsFlutterBinding を初期化します。
WidgetTester はその後、おなじみのテスト用APIを提供します。
await tester.pumpWidget(const MyApp()); // Build and render
await tester.tap(find.byType(FloatingActionButton)); // Simulate input
await tester.pump(); // Process one frame
await tester.pumpAndSettle(); // Process until no more frames needed
expect(find.text('1'), findsOneWidget); // Assert
pump() を呼び出すたびに、フレームが1つ手動でトリガーされます。実際のアプリでは60/120 fpsでエンジンがフレームを駆動しますが、テストでは時間を明示的にコントロールできます。
TestWidgetsFlutterBinding:ランタイムの切り替え
TestWidgetsFlutterBinding は、packages/flutter_test/lib/src/binding.dart の1060行目で宣言されています。
abstract class TestWidgetsFlutterBinding extends BindingBase
with
SchedulerBinding,
ServicesBinding,
GestureBinding,
SemanticsBinding,
RendererBinding,
PaintingBinding,
WidgetsBinding,
TestDefaultBinaryMessengerBinding {
第3回で紹介した WidgetsFlutterBinding と比べてみましょう。
class WidgetsFlutterBinding extends BindingBase
with
GestureBinding,
SchedulerBinding,
ServicesBinding,
PaintingBinding,
SemanticsBinding,
RendererBinding,
WidgetsBinding {
使われているミックスインは同じですが、TestWidgetsFlutterBinding にはプラットフォームチャンネルを傍受するための TestDefaultBinaryMessengerBinding も追加されており、一部のミックスインの順序も異なります。具体的なサブクラスである AutomatedTestWidgetsFlutterBinding(2186行目)は、主要な振る舞いをオーバーライドしています。
classDiagram
BindingBase <|-- WidgetsFlutterBinding : Production
BindingBase <|-- TestWidgetsFlutterBinding : Testing
TestWidgetsFlutterBinding <|-- AutomatedTestWidgetsFlutterBinding : Unit tests
TestWidgetsFlutterBinding <|-- LiveTestWidgetsFlutterBinding : Integration tests
class WidgetsFlutterBinding {
+Real frame scheduling
+Real rendering
+Real platform channels
}
class AutomatedTestWidgetsFlutterBinding {
+FakeAsync time control
+No real rendering
+Mock platform channels
+Golden image comparison
}
class LiveTestWidgetsFlutterBinding {
+Real frame scheduling
+Real rendering
+For on-device tests
}
AutomatedTestWidgetsFlutterBinding は実際の非同期処理を FakeAsync に置き換えることで、タイマー、マイクロタスク、フレームスケジューリングをテストが完全にコントロールできるようにします。tester.pump(const Duration(seconds: 1)) を呼んでも、実際に1秒が経過するわけではなく、フェイクのクロックが即座に進むだけです。
ヒント: ウィジェットテストがハングする場合、ほとんどのケースは
pumpAndSettle()が永久に落ち着かないことが原因です。アニメーションが無限ループしていないか確認し、必要に応じてpumpAndSettle()の代わりに明示的な時間を指定したpump(duration)を使いましょう。
ゴールデンテスト:スクリーンショットの比較
Flutterのゴールデンテストインフラは、レンダリング結果を画像としてキャプチャし、リファレンスとなる「ゴールデン」ファイルと比較します。ウィジェットが実際に正しく見えているかどうか、視覚的な正確性を検証するための仕組みです。
ゴールデンテストの流れも同じバインディングの切り替えを活用しています。TestWidgetsFlutterBinding が(第3回でトレースした)レンダリングパイプラインに介入し、フレームバッファの内容をキャプチャします。その後、matchesGoldenFile マッチャーがキャプチャした画像をリファレンスファイルとピクセル単位で比較します。
await tester.pumpWidget(const MyWidget());
await expectLater(
find.byType(MyWidget),
matchesGoldenFile('goldens/my_widget.png'),
);
packages/flutter_test/lib/src/ にある goldens.dart と _goldens_io.dart が比較ロジックを担っており、プラットフォームごとの微細なレンダリング差異に対する許容範囲の調整も含まれています。
DeviceLabとインテグレーションテスト
testWidgets() によるユニットテストはシミュレートされた環境で動作します。しかし、実際のGPUレンダリングを伴う実機でのテストも必要です。そこで活躍するのが dev/devicelab/ です。
DeviceLabは物理デバイス(Pixel、iPhone、Linuxワークステーションなど)のファームで、ベンチマークとインテグレーションテストを実行します。dev/devicelab/ ディレクトリには、フレーム時間、メモリ使用量、起動レイテンシなどを計測するベンチマークタスクの定義が含まれています。
packages/integration_test/ パッケージは IntegrationTestWidgetsFlutterBinding を提供します。これは(第3回のパターンに従った)さらに別のバインディングのバリアントで、実際のレンダリングを伴う実機上でテストを実行しながら、テストのライフサイクル管理と結果のレポートを付け加えたものです。
flowchart TD
subgraph Unit["Unit Tests (fast, simulated)"]
TW["testWidgets()"]
AB["AutomatedTestWidgetsFlutterBinding"]
FA["FakeAsync + No GPU"]
end
subgraph Integration["Integration Tests (device)"]
IT["integration_test package"]
IB["IntegrationTestWidgetsFlutterBinding"]
RD["Real device + Real GPU"]
end
subgraph DevLab["DeviceLab Benchmarks"]
BM["Benchmark tasks"]
PD["Physical devices"]
PM["Performance metrics"]
end
TW --> AB --> FA
IT --> IB --> RD
BM --> PD --> PM
CIパイプライン:大規模な設定ファイル
FlutterのCIは .ci.yaml で定義されています。7,000行以上のYAMLファイルで、継続的インテグレーションパイプラインのすべてのビルダーを定義しています。次のようなあらゆる組み合わせごとに個別のビルダーを定義しているため、ファイルが大規模になっています。
- プラットフォーム(Linux、macOS、Windows)
- テストシャード(framework、tool、web、devicelab)
- ターゲットデバイス(エミュレーター、実機、ブラウザ)
- ビルドモード(debug、profile、release)
dev/bots/ ディレクトリには、CIビルダーが実行するDartスクリプトが格納されています。主なスクリプトは次のとおりです。
analyze.dart—analysis_options.yamlの厳格な設定でdart analyzeを実行するtest.dart— テストのシャーディングと実行をオーケストレーションするcheck_code_samples.dart— ドキュメント内のすべてのコードサンプルがコンパイルして動作することを検証する
TESTOWNERS ファイルは、DeviceLabのすべてのタスクをチームとオーナーに紐付けています。
/dev/devicelab/bin/tasks/analyzer_benchmark.dart @bkonyi @flutter/tool
/dev/devicelab/bin/tasks/android_choreographer_do_frame_test.dart @reidbaker @flutter/engine
テストが不安定になると、担当オーナーが自動的にバグにタグ付けされます。数百人のコントリビューターが参加するプロジェクトでは、このオーナーシップモデルが不可欠です。これがなければ、不安定なテストはじわじわと腐敗していくでしょう。
静的解析:最初の品質ゲート
テストが実行される前に、第1回で確認した analysis_options.yaml の厳格な設定が解析パイプラインによって強制されます。
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
150以上の有効化されたlintルールにより、バグ全体のカテゴリーをテスト前に検出できます。dev/bots/analyze.dart スクリプトはモノレポ全体でこの解析を実行し、一貫性を担保します。
flowchart LR
PR["Pull Request"] --> A["Analysis\n(strict-casts, 150+ lints)"]
A --> UT["Unit Tests\n(testWidgets)"]
UT --> GT["Golden Tests\n(screenshot diff)"]
GT --> IT["Integration Tests\n(real devices)"]
IT --> BM["Benchmarks\n(performance gates)"]
BM --> MR["Merge"]
style A fill:#e8f5e9
style MR fill:#e8f5e9
ヒント: Flutterリポジトリの解析ルールは、自分のプロジェクトを始める際の優れた出発点になります。
analysis_options.yamlをコピーしてコメントを読むと、あえて有効化しなかったルールとその理由も把握できます。
テストアーキテクチャという設計の勝利
少し引いた視点で見ると、Flutterのテスト戦略はこのシリーズを通じて探ってきたアーキテクチャ上の決断の直接的な産物です。
-
レイヤー階層(第1回)により、各レイヤーを独立してテストできます。ウィジェットなしでレンダリングをテストしたり、Material Designなしでウィジェットをテストしたりすることが可能です。
-
3ツリーアーキテクチャ(第2回)は関心をきれいに分離しているため、レンダリングを気にせずウィジェットの再ビルドを検証したり、ウィジェットを気にせずレイアウトを検証したりできます。
-
バインディングアーキテクチャ(第3回)は、テストインフラがフックを差し込む接合点を提供しています。
WidgetsFlutterBindingをTestWidgetsFlutterBindingに切り替えられるのは、ミックスインベースの設計による直接的な恩恵です。 -
Zoneベースの依存性注入(第4回)により、CLIツールのテストが本番コードを変更せずにファイルシステム、プロセスランナー、プラットフォームサービスを差し替えられます。
-
エンジンの分離(第5回)により、
dart:uiの境界でエンジン全体をモック化できます。テストバインディングはGPUを必要としません。
これは偶然の産物ではありません。バインディングアーキテクチャはテスタビリティを主要な目標として設計されました。ミックスインチェーンが存在するのは、異なる具象バインディングが同じ機能を異なる実装で組み合わせられるようにするためです。「インターフェースに対してプログラムする」という原則をフレームワークスケールで適用した、最良の事例の一つです。
シリーズを終えるにあたって
この6回のシリーズを通じて、最高レベルのMaterial Designウィジェットから、GPUコマンド、そしてCLIツールとテストインフラまで、Flutterのアーキテクチャ全体をたどってきました。一貫して見えてきたテーマをまとめます。
- 厳格なレイヤリングがスパゲッティな依存関係を防ぎ、独立した進化を可能にする
- 3ツリーが安価な設定(ウィジェット)と高コストなリソース(レンダーオブジェクト)を分離する
- バインディングのミックスインがレイヤーを実行時に接続し、テストの接合点を提供する
- Zoneベースの依存性注入が(技術的負債を抱えつつも)CLIのサービスアーキテクチャを支える
- Impellerのオフラインコンパイルが作業をビルド時に移すことでシェーダーのジャンクを排除する
- バインディングの切り替えがフレームワーク内部をモック化せずにテスト可能にする
Flutterのソースコードは、丁寧に読む価値があります。アーキテクチャは意図的に階層化され、(特にdocコメントで)丁寧にドキュメント化されており、読んで理解できるように設計されています。examples/layers/ を起点に、バインディングチェーンをたどってみましょう。コード自身がその物語を語ってくれるはずです。