widget-golden-and-a11y-testing について
Flutter 開発者向けのウィジェット、golden、アクセシビリティテストのスキルです。端末サイズを固定し、実際のウィジェットツリーのジオメトリでレイアウトを検証します。golden は字形とミラーリングの確認に限定します。
主な機能
- 01端末固定のテストハーネス
- 02文字拡大・太字のオーバーフロー行列
- 03ジオメトリに基づくレイアウト検証
- 04セマンティクスとタップ領域の検査
- 05純粋な Dart によるコントラスト検証
- 06管理された 2 系統の golden テスト
活用シーン
- 01端末サイズと文字倍率ごとのレイアウト確認
- 02オーバーフローを報告しない文字切れの検出
- 03コントロールのセマンティクスとタップ領域の確認
- 04RTL の字形とミラーリングの確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill widget-golden-and-a11y-testingインストール後、Claude Code で/widget-golden-and-a11y-testingを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/a11y_test.dartharness.dartoverflow_matrix_test.dartreferences/a11y-guidelines-and-limits.mdgolden-two-lanes.mdharness-and-mediaquery.mdoverflow-and-textscale.mdscripts/check-test-hygiene.sh詳細
ウィジェット、ゴールデン、アクセシビリティのテスト
1つのハーネスで画面表示を固定します。レイアウトとアクセシビリティは、承認済みのピクセル画像ではなく、実際のウィジェットツリー上の計算されたジオメトリで検証します。ゴールデンは、グリフのシェーピングとミラーリングに対する限定的で誠実な安全策であり、レイアウトの合否判定には決して使いません。このスキルではウィジェット層を扱います。純粋なコア/Drift/Notifier 層については、testing-strategy を参照してください。
作業内容に応じて、次のリファレンスを確認してください。
references/harness-and-mediaquery.md— pumpApp、Device プリセット、重要な4行、MediaQuery フラグの設定方法、ファインダーの方針。references/overflow-and-textscale.md— 2種類のオーバーフロー、3つの落とし穴、マトリクス、フィットのアサーション、4つの誤った修正方法。references/a11y-guidelines-and-limits.md— 4つの組み込みガイドラインとその欠陥、セマンティクス/トラバーサルのゲート、純粋なDartでのコントラスト検証、自動化では本当にカバーできないこと。references/golden-two-lanes.md— ゴールデンを使わない理由、Ahemと実フォントの2つのレーン、RTLゴールデン、誤った承認を防ぐ方法。
PR の前に scripts/check-test-hygiene.sh を実行してください。
譲れないルール
- すべてのレイアウト/ジオメトリテストでデバイスを固定します。 デフォルトのウィジェットサーフェスは論理サイズで800x600です。どのスマートフォンよりも幅広です。固定しないと、コンテンツの幅が約2倍になり、すべて収まってテストスイートは成功する一方で、リリースしたスマートフォン向けアプリは壊れています。まず
useDevice(...)、次にpumpApp(...)の順で呼び出します。 physicalSizeは物理ピクセル単位です。必ずDPRを掛けます。 デフォルトのDPR 3.0でview.physicalSize = Size(320, 640)とすると、スマートフォンではなく論理サイズ107x213のサーフェスになります。devicePixelRatioを設定し、physicalSize = logical * dprとしてから、addTearDown(view.reset)を呼び出します。サイズが残ると、ファイル内の後続テストすべてを汚染します。.copyWithで構築したMediaQueryをMaterialAppの上に配置します。MaterialAppは独自のMediaQueryを挿入しません。最も近いのは、pumpWidgetのViewが提供するMediaQueryです。引数なしのMediaQueryData()は、デバイス固定時に設定されたView由来のサイズをゼロにします。その結果、テストは0x0の画面を計測して成功してしまいます。- アニメーションの待機には
pumpAndSettle()ではなく、必ずpump()を使います。pumpAndSettleには10分のタイムアウトがあり、スタックトレースは途中で切り詰められます。また、無限に続くスプラッシュ/シマー/スピナーで永久に停止します。状態の変化にはpump()、タイマー駆動の非同期処理にはpump(duration)またはfakeAsyncを使います。pump()ではフェイククロックは進みません。 - オーバーフローを決して抑制しないでください。
RenderFlexのオーバーフローは、すでにウィジェットテストを失敗させます(FlutterError.reportError経由で報告され、テスト終了時にバインディングが再スローします)。これを握りつぶすためにtakeException()を呼び出したり、FlutterError.onErrorを設定したり、ignoreOverflowErrorsをコピーしたりしないでください。また、グローバルなtearDownでtakeException()を呼び出さないでください。どれも監視網全体を無効にしてしまいます。 - (デバイス、スケール、太字)の組み合わせごとに
testWidgetsを1つ用意し、テスト内でループしないでください。 オーバーフローは RenderObjectごとに1回だけ報告されます(フラグがリセットされるのはreassemble()のときだけです)。そのため、1つのテスト内でスケールをループすると、最初以降の各スケールの問題が報告されないままになります。testWidgets呼び出しの外側でループしてください。 - オーバーフローがないことだけでなく、収まっていることをアサートします。 クリップされた
Textは何も報告しません。RenderParagraphにはオーバーフローを示すインジケーターがありません。takeException(), isNullは必要ですが、それだけでは不十分です。ラベルが計算されたセル内に収まることをgetSize/getRectでアサートしてください。 - レイアウトではゴールデン画像より計算済みのジオメトリを優先します。 行内のセルの
topが同じであること、列内のセルのleftが同じであることをアサートします(moreOrLessEquals、epsilon: 0.5)。タップ対象はgetSizeのループでアサートします。これらのテストは、人が対処できる説明とともに失敗します。ゴールデン画像では何もアサートできません。クリップされて読めないテキストの画面を正解として保存すれば、いつまでもテストに通ってしまいます。 - コントラストはピクセルではなく、純粋なDartで色の値に対してアサートします。
meetsGuideline(textContrastGuideline)はレイヤーをスクリーンショットに撮ってヒストグラムを作成します。白いテキストと#FAFAFAの組み合わせでも合格します(Flutterの未解決不具合です)。テーマの色を使った純粋なDartのWCAG+APCAテストなら、誤って合格することはありません。色差だけで区別される状態の組み合わせ(同じ輝度の選択状態とサーフェス)では、wcag(theme.selected, theme.surface)を直接アサートします。グレースケールモードのユーザーが知覚するのはまさにその輝度差なので、グレースケール用の個別の経路は不要です。 meetsGuidelineにはawait expectLater(...)を使います。これはAsyncMatcherを返します。 通常のexpect()は正しく見えても、何もアサートしません。組み込みの4つのガイドラインは、あくまで参考用の警告として残してください。合否を決めるのはジオメトリテストと純粋なDartのコントラストテストです。- ゴールデンテストは2つのレーンを用意し、どちらも
loadAppFonts()を呼び出します。意図しない--update-goldensを防いでください。 Ahemの四角形はOS間でバイト単位の安定性があり、ジオメトリやミラーリングを検証できますが、グリフのシェーピングは検証できません。実フォントを使う狭い範囲のレーンを1つ用意し、OSを固定して、文字の連結や数字のグリフを検証します。すべてのゴールデンに@Tags(['golden'])を付け、正解ファイルは固定した1つの環境でのみ生成してください。 TextScalerを決してクランプしないでください。withClampedTextScaling、textScaleFactor、FittedBoxはコントラストやタップ対象のテストを成功させたままテストマトリクスを無効化し、ユーザー自身のOS設定を上書きします。テキストではなく、レイアウトを修正してください。
ハーネス — すべてのテストからインポートする1つのファイル
test/support/harness.dart には、Device 値型と pumpApp 拡張機能があります。
オーバーライドされていない間はシームが例外をスローするため、オーバーライドされていない依存関係があれば、テスト内で実サービスをひそかに構築するのではなく、明確に失敗します。
references/harness-and-mediaquery.md と examples/harness.dart を参照してください。
class Device {
const Device(this.name, this.logicalSize, this.dpr);
final String name;
final Size logicalSize;
final double dpr;
// Neutral presets; name them by measured size, not a marketing model.
static const compact = Device('compact_320', Size(320, 640), 2.0);
static const small = Device('small_360', Size(360, 800), 3.0);
static const medium = Device('medium_412', Size(412, 915), 2.625);
static const all = <Device>[compact, small, medium];
}
extension TestHarness on WidgetTester {
void useDevice(Device d) {
view.devicePixelRatio = d.dpr;
view.physicalSize = d.logicalSize * d.dpr; // physical px — multiply by DPR
addTearDown(view.reset); // one call; nothing forgotten
}
Future<void> pumpApp({
List<Override> overrides = const <Override>[],
TextScaler textScaler = TextScaler.noScaling,
bool boldText = false,
bool accessibleNavigation = false,
}) async {
await pumpWidget(
ProviderScope(
overrides: overrides,
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith( // copyWith, NOT MediaQueryData()
textScaler: textScaler,
boldText: boldText,
accessibleNavigation: accessibleNavigation,
),
child: const App(),
),
),
),
);
await pump(); // one frame; never pumpAndSettle
}
}
オーバーフローとフィットのマトリックス
Device.all x [1.0, 1.3, 1.5, 2.0, 3.0] x [false, true] bold の組み合わせごとに、1つの
testWidgets を実行します。TextScaler.linear は意図的に広めの近似です
(Android 14 以降では、大きなテキストのスケーリングは より小さく なります)。保守的ではありますが、実機の動作を忠実に再現するものではありません。1.3
と1.5をリストに含めているのは、デバイスでの非線形スケーリングでは、中間範囲こそが見落としやすい部分だからです。boldText は字送り幅を広げますが、実際のプロポーショナルフォントが読み込まれている場合に限ります。デフォルトの Ahem テストフォントでは、すべてのグリフがウェイトに関係なく固定の em 正方形になるため、マトリックスが最初に loadAppFonts() を呼び出さない限り、bold 軸は何の効果もありません。実際のフォントを読み込むと、太字でなければ通るコンテンツがオーバーフローし、em 正方形では見えない実際の文字幅が検証されます。
// Real proportional fonts, or the bold + character-width axes are inert under Ahem.
setUpAll(loadAppFonts);
for (final device in Device.all) {
for (final scale in const <double>[1.0, 1.3, 1.5, 2.0, 3.0]) {
for (final bold in const <bool>[false, true]) {
testWidgets('no overflow @ ${device.name} x$scale${bold ? ' bold' : ''}',
(tester) async {
tester.useDevice(device);
await tester.pumpApp(textScaler: TextScaler.linear(scale), boldText: bold);
// Explicit for a readable message; the binding also rethrows at test end.
expect(tester.takeException(), isNull,
reason: 'content overflowed at ${device.name} x$scale');
});
}
}
}
フィットのアサーションこそが実際のゲートです。クリップされた Text では報告されない、気づきにくい 問題を検出します。references/overflow-and-textscale.md と
examples/overflow_matrix_test.dart を参照してください。
アクセシビリティのゲート
testWidgets ではセマンティクスがデフォルトで有効です。非推奨の containsSemantics ではなく isSemantics でノードのロールとラベルを直接アサートし、明示的な getSize ループでタップターゲットのサイズを測定します(組み込みのガイドラインでは、ビューの端に接しているノードはすべて除外されます)。また、純粋な Dart で色の値のコントラストをアサートします。
testWidgets('each item exposes a button labelled by its display name',
(tester) async {
await tester.pumpApp();
final node = tester.getSemantics(find.byKey(const ValueKey('item_0')));
expect(node, isSemantics(
label: 'First item',
isButton: true,
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
));
});
正直に言いましょう。Flutter に搭載されている機械的にチェック可能なガイドラインは4つ(うち1つは既知の不具合があります)で、実際のアクセシビリティのごく一部しか対象にしていません。テストスイートが「アクセシビリティをテストする」と主張してはいけません。Switch Access / Switch Control は自動テストではまったく検証できません。references/a11y-guidelines-and-limits.md と examples/a11y_test.dart を参照してください。
ゴールデンテストの領域
レイアウトは計算されたジオメトリで検証するため、ゴールデンテストの対象は限定的です。ジオメトリでは確認できないグリフのシェーピング、ミラーリング、数字の描画を検証します。2つの領域のどちらでも loadAppFonts() を呼び出します。RTL のゴールデンテストは Directionality のもとでポンプします。references/golden-two-lanes.md を参照してください。
@Tags(['golden'])
library;
testWidgets('card mirrors correctly in RTL', (tester) async {
await tester.pumpWidget(const Directionality(
textDirection: TextDirection.rtl,
child: _CardHarness(),
));
await expectLater(find.byType(ItemCard),
matchesGoldenFile('goldens/item_card_rtl.png'));
});
アンチパターン
- レイアウトテストでサイズを固定しない。 800x600では通っても、360dpのスマートフォンでは壊れた状態でリリースされます。ルール1。
- 1つの
testWidgets内で、スケールを変えるforループを使う。 オーバーフローは RenderObject ごとに1回しか報告されないため、スケール2以降はひそかにチェックされません。ルール6。 - グローバルな
tearDownでtakeException()を使う。 バインディングが例外を再スローする前に_pendingExceptionDetailsをクリアするため、オーバーフロー検出の仕組み全体が機能しなくなります。 - レイアウトテストで
ignoreOverflowErrors/FlutterError.onError = ...を使う。 すでに存在する検出の仕組みを無効にする、よく使われているヘルパーです。 - 赤くなったマトリックスを緑にするために
FittedBox/TextOverflow.ellipsis/withClampedTextScalingを使う。 最も複雑なラベルを最小にし、ユーザーの TextScaler を無効にしたうえで、製品が読めない状態でもテストに通ってしまいます。レイアウトを修正してください。 await expectLaterを使わずにexpect(tester, meetsGuideline(...))を使う。 マッチャーは非同期なので、何もアサートされません。- タップターゲットに関する主張を
meetsGuidelineだけに頼る。 ビューの端に接しているノードはすべて除外されるため、明示的なgetSizeループを追加してください。 - コントラストに関する主張を
textContrastGuidelineに頼る。 変化の少ない背景では既知の偽陰性が発生するため、純粋な Dart で色の比率をアサートしてください。 - 動作やジオメトリの確認に
find.byType(SomeWidget)を使う。 テストがクラス階層に依存するため、名前を変更しただけで何も変わっていないのにテストスイートが失敗します。動作にはfind.bySemanticsLabel、ジオメトリにはfind.byKeyを使ってください。 - レイアウトのゴールデンテストをゲートにする。 クリップされたテキストも含め、リリースされた状態をそのまま正しいと判定し、フォントを異なる方法でラスタライズするホストでは失敗します。
完了の定義
- すべてのレイアウト/ジオメトリテスト、およびサイズや位置を測定するアクセシビリティテストでは、
pumpApp(...)の前にuseDevice(...)を呼び出します。純粋なセマンティクステスト(ロール/ラベル、トラバーサル)ではデバイスの指定は不要です。 - オーバーフローマトリックスは、(デバイス、スケール、太字)の各組み合わせにつき
testWidgetsを1つ設けます。test/内で抑制を使用しません。 - オーバーフローマトリックスには、計算されたセルの範囲内に収まることを確認するアサーション(
getSize/getRect)を設けます。レイアウトのゴールデンテストはジオメトリ不変条件に置き換えます。 - タップターゲットは明示的な
getSizeループで測定します。meetsGuidelineは参考としてのみ使用し、必ずawait expectLater経由で呼び出します。 - セマンティクスは
isSemanticsで検証します。ラベルには内部データではなく表示名を設定します。意図的な設計上の決定である場合は、トラバーサル順序も検証します。 - コントラストは、テーマの色値を対象に、純粋な Dart(WCAG + APCA)で検証し、すべてのテーマについてループします。色相のみが異なる状態の組み合わせでは、2つの状態色の間で
wcagを直接検証します。 - ゴールデンテストがある場合は
@Tags(['golden'])を付けます。どちらのレーンでもloadAppFontsを呼び出し、RTL のゴールデンテストを用意し、CI で--update-goldensを禁止します。 scripts/check-test-hygiene.shが成功します。
関連スキル
- このウィジェット層の基盤となるテスト方針(純粋でクロックを注入可能なコア、モックよりフェイク、実際のインメモリ DB、カバレッジポリシー)については、
testing-strategyを参照してください。 - これらのテストで検証する Semantics/ロール/ラベルの作成方法については
accessibility-as-codeを、RTL のゴールデンテストで検証する方向依存ジオメトリについてはi18n-rtl-l10nを参照してください。 - 純粋な Dart のコントラストゲートが参照するテーマ/ColorScheme については
design-system-structureを、レイアウトテストで再検証すべきではない const/再ビルドルールについてはflutter-performanceを参照してください。 pumpAppが使用するProviderScope/オーバーライドの接合点についてはstate-management-riverpodを、ゴールデンテストのレーンと grep を CI に組み込む方法についてはci-pipeline-and-gatesを参照してください。- コミット済みのゴールデン画像を上書きすることが許可された唯一の手順については
run-goldens-rebaselineを、各ゴールデンテストおよびウィジェットテストでポンプする4つの画面状態についてはui-states-and-feedbackを参照してください。
参考資料
- Flutter — ウィジェットテストの概要:https://docs.flutter.dev/cookbook/testing/widget/introduction
- Flutter API —
matchesGoldenFile(OS/フォント/バージョン依存性、--update-goldens):https://api.flutter.dev/flutter/flutter_test/matchesGoldenFile.html - Flutter API —
WidgetTester/TestFlutterView:https://api.flutter.dev/flutter/flutter_test/WidgetTester-class.html - Flutter — アクセシビリティガイドライン/
meetsGuideline:https://api.flutter.dev/flutter/flutter_test/AccessibilityGuideline-class.html - Flutter — Semantics マッチャー(
isSemantics、matchesSemantics):https://api.flutter.dev/flutter/flutter_test/matchesSemantics.html alchemist— ゴールデンテスト(loadAppFonts、CI とプラットフォームのレーン):https://pub.dev/packages/alchemist- WCAG 2.2 のコントラスト(1.4.3/1.4.6)および APCA:https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html