Widget golden and a11y testing
Skill zakariaf/Flutter-Skills/skills/widget-golden-and-a11y-testing
General, reusable Flutter engineering skills for AI coding agents — architecture, Riverpod 3.x, testing, persistence, i18n/RTL, accessibility, navigation & more. Agent Skills open standard; works with Claude Code, Cursor, Codex & 70+ agents.
npx -y skills add zakariaf/Flutter-Skills --skill widget-golden-and-a11y-testingAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 16 days oldThe repository was created 16 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Enforces a disciplined widget/layout/golden/a11y test surface — one pumpApp harness that pins tester.view.physicalSize x devicePixelRatio and layers MediaQuery (textScaler/boldText/accessibleNavigation) above MaterialApp, an overflow net that never suppresses (one testWidgets per device x scale x bold tuple because overflow reports once per RenderObject), a computed getSize/getRect fit-and-geometry gate instead of goldens, two golden lanes (Ahem geometry + one pinned-OS real-font) with loadAppFonts and blocked --update-goldens, RTL goldens under Directionality, pure-Dart WCAG/APCA contrast on colour VALUES, and honest limits on meetsGuideline. Use when writing test/support/harness.dart, calling pumpWidget/pumpApp, chasing an "overflowed by N pixels" failure, reaching for takeException/ignoreOverflowErrors/FittedBox/withClampedTextScaling, adding matchesGoldenFile, or writing a11y_test.dart with isSemantics/simulatedAccessibilityTraversal/meetsGuideline.
SKILL.md
15.2 KB, as published. Nobody here has run it
Widget, golden and accessibility testing
One harness pins the surface; layout and accessibility are asserted by computed
geometry on the real widget tree, not by blessed pixels. Goldens are a narrow,
honest safety net for glyph shaping and mirroring — never the layout gate. This
skill covers the widget tier; for the pure-core / Drift / Notifier tiers see
testing-strategy.
Read the reference for the task at hand:
references/harness-and-mediaquery.md— pumpApp, Device presets, the four load-bearing lines, driving MediaQuery flags, finder policy.references/overflow-and-textscale.md— the two overflow classes, the three traps, the matrix, the fit assertion, and the four wrong fixes.references/a11y-guidelines-and-limits.md— the four built-in guidelines and their defects, the semantics/traversal gate, pure-Dart contrast, and what automation genuinely cannot cover.references/golden-two-lanes.md— the golden-refusal argument, the Ahem-vs-real-font lanes, RTL goldens, and blocking accidental blessing.
Run scripts/check-test-hygiene.sh before a PR.
Non-negotiable rules
- Pin the device on every layout/geometry test. The default widget surface
is 800x600 logical — wider than any phone. Unpinned, content is ~2x too
wide, everything fits, the suite is green, and the shipped phone is broken.
useDevice(...)first,pumpApp(...)second. physicalSizeis in PHYSICAL pixels — always multiply by DPR.view.physicalSize = Size(320, 640)at the default DPR 3.0 is a 107x213 logical surface, not a phone. SetdevicePixelRatioandphysicalSize = logical * dpr, thenaddTearDown(view.reset)— a leaked size poisons every later test in the file.- Layer
MediaQueryABOVEMaterialApp, built from.copyWith.MaterialAppinserts no MediaQuery of its own; the one frompumpWidget'sViewis nearest. A bareMediaQueryData()zeroes the view-derived size the device just pinned — the test then measures a 0x0 screen and passes. pump(), neverpumpAndSettle()as an animation wait.pumpAndSettlecarries a 10-minute timeout and truncates its stack trace, and hangs forever on an infinite splash/shimmer/spinner. Usepump()for state changes andpump(duration)/fakeAsyncfor timer-driven async —pump()does not advance the fake clock.- Never suppress overflow. A
RenderFlexoverflow already fails a widget test (it routes throughFlutterError.reportErrorand the binding rethrows at test end). Never calltakeException()to swallow it, never assignFlutterError.onError, never copyignoreOverflowErrors, and nevertakeException()in a globaltearDown— each disarms the whole net. - One
testWidgetsper (device, scale, bold) tuple — never a loop inside a test. Overflow is reported once per RenderObject (the flag resets only onreassemble()), so looping scales inside one test silently under-reports every scale after the first. Loop around thetestWidgetscall. - Assert the fit, not just absence of overflow. A clipped
Textreports nothing —RenderParagraphhas no overflow indicator.takeException(), isNullis necessary but not sufficient; add agetSize/getRectassertion that the label fits inside its computed cell. - Prefer computed geometry over goldens for layout. Assert cells in a row
share a
topand cells in a column share aleft(moreOrLessEquals,epsilon: 0.5); assert tap targets with agetSizeloop. These fail with a sentence a human can act on. Goldens cannot assert anything — a blessed screen of clipped, unreadable text passes forever. - Assert contrast on colour VALUES in pure Dart, never on pixels.
meetsGuideline(textContrastGuideline)screenshots and histograms the layer — white text on#FAFAFApasses (an open Flutter defect). A pure-Dart WCAG + APCA test on the theme's colours cannot false-pass. For a state pair distinguished only by chroma (selected vs surface at equal luminance), assertwcag(theme.selected, theme.surface)directly — a grayscale-mode user perceives exactly that luminance gap, so no separate grayscale channel is needed. await expectLater(...)formeetsGuideline— it returns anAsyncMatcher. A plainexpect()looks right and asserts nothing. Keep the four built-in guidelines only as advisory tripwires; the geometry and pure-Dart contrast tests are the gate.- Two golden lanes, both
loadAppFonts(); block accidental--update-goldens. Ahem squares are byte-stable cross-OS and prove geometry/mirroring but not glyph shaping; one narrow real-font lane on a pinned OS proves script joining and numeral glyphs. Tag every golden@Tags(['golden'])and generate blessed files in one pinned environment only. - Never clamp
TextScaler.withClampedTextScaling,textScaleFactor, andFittedBoxdefeat the matrix while contrast and tap-target stay green, and override the user's own OS setting. Fix the layout, not the text.
The harness — one file, imported by every test
test/support/harness.dart holds a Device value type and a pumpApp extension.
Seams throw until overridden, so an un-overridden dependency fails loudly instead
of quietly constructing a live service in a test. See
references/harness-and-mediaquery.md and 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
}
}
The overflow + fit matrix
Device.all x [1.0, 1.3, 1.5, 2.0, 3.0] x [false, true] bold = one
testWidgets per tuple. TextScaler.linear is a deliberate over-approximation
(Android 14+ scales large text less) — conservative, not device-faithful. 1.3
and 1.5 are in the list precisely because nonlinear device scaling makes the
mid-range the non-obvious part. boldText widens advance widths — but only when
a real proportional font is loaded. Under the default Ahem test font every glyph
is a fixed em-square regardless of weight, so the bold axis is a no-op unless the
matrix calls loadAppFonts() first; with real fonts loaded it overflows content
that passes unbolded, and stresses real character widths the em-square hides.
// 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');
});
}
}
}
The fit assertion is the real gate — it catches the silent class a clipped
Text never reports. See references/overflow-and-textscale.md and
examples/overflow_matrix_test.dart.
The accessibility gate
Semantics is ON by default in testWidgets. Assert the node's role and label
directly with isSemantics (not the deprecated containsSemantics), measure tap
targets with an explicit getSize loop (the built-in guideline skips every node
flush with the view edge), and assert contrast on colour values in pure 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,
));
});
Be honest: Flutter ships four machine-checkable guidelines (one known-broken),
covering a small minority of real accessibility. Never claim a suite "tests
accessibility". Switch Access / Switch Control cannot be tested automatically at
all. See references/a11y-guidelines-and-limits.md and examples/a11y_test.dart.
Golden lanes
Layout is proven by computed geometry, so goldens are narrow: glyph shaping,
mirroring, and numeral rendering that geometry cannot see. Two lanes, both call
loadAppFonts(); RTL goldens pump under Directionality. See
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'));
});
Anti-patterns
- Unpinned layout test. Passes on 800x600, ships a broken 360dp phone. Rule 1.
forloop over scales inside onetestWidgets. Overflow reports once per RenderObject; scales 2..n are silently unchecked. Rule 6.takeException()in a globaltearDown. Clears_pendingExceptionDetailsbefore the binding rethrows — turns the entire overflow net into a no-op.ignoreOverflowErrors/FlutterError.onError = ...in a layout test. The popular helper that loses the net that already exists.FittedBox/TextOverflow.ellipsis/withClampedTextScalingto green a red matrix. Makes the most complex label the smallest, cancels the user's TextScaler, and passes while the product is unreadable. Fix the layout.expect(tester, meetsGuideline(...))withoutawait expectLater. Asserts nothing — the matcher is async.- Tap-target claim resting on
meetsGuidelinealone. It skips every node flush with the view edge; add the explicitgetSizeloop. - Contrast claim resting on
textContrastGuideline. Open false-negative on low-variance backgrounds; assert the colour ratio in pure Dart. find.byType(SomeWidget)for behaviour or geometry. Couples the test to the class hierarchy; a rename reds the suite for nothing. Usefind.bySemanticsLabelfor behaviour,find.byKeyfor geometry.- A layout golden as the gate. It blesses whatever shipped, including clipped text, and reds on any host that rasterizes fonts differently.
Definition of done
- Every layout/geometry test — and any a11y test that measures size or position —
calls
useDevice(...)beforepumpApp(...). Pure-semantics tests (role/label, traversal) need no device. - Overflow matrix exists as one
testWidgetsper (device, scale, bold) tuple; no suppression anywhere intest/. - A fit assertion (
getSize/getRectinside the computed cell) backs the overflow matrix; a geometry invariant replaces the layout golden. - Tap targets measured with an explicit
getSizeloop;meetsGuidelineused only as advisory, always viaawait expectLater. - Semantics asserted with
isSemantics; label carries the display name, not internal data; traversal order asserted if it is a deliberate design decision. - Contrast asserted in pure Dart (WCAG + APCA) over theme colour values, looped
over every theme; chroma-only state pairs assert
wcagbetween the two state colours directly. - Goldens (if any) are tagged
@Tags(['golden']), both lanes callloadAppFonts, RTL goldens exist, and CI blocks--update-goldens. scripts/check-test-hygiene.shpasses.
Related skills
- See
testing-strategyfor the overall test doctrine (pure clock-injected core, fakes-over-mocks, real in-memory DB, coverage policy) this widget tier sits on. - See
accessibility-as-codefor authoring the Semantics/roles/labels these tests assert, andi18n-rtl-l10nfor the Directional geometry the RTL goldens verify. - See
design-system-structurefor the theme/ColorScheme the pure-Dart contrast gate reads, andflutter-performancefor the const/rebuild rules layout tests should not try to re-prove. - See
state-management-riverpodfor theProviderScope/override seampumpAppuses, andci-pipeline-and-gatesfor wiring the golden lanes and greps into CI.
References
- Flutter — Widget testing introduction: https://docs.flutter.dev/cookbook/testing/widget/introduction
- Flutter API —
matchesGoldenFile(OS/font/version sensitivity,--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 — Accessibility guidelines /
meetsGuideline: https://api.flutter.dev/flutter/flutter_test/AccessibilityGuideline-class.html - Flutter — Semantics matchers (
isSemantics,matchesSemantics): https://api.flutter.dev/flutter/flutter_test/matchesSemantics.html alchemist— golden testing (loadAppFonts, CI vs platform lanes): https://pub.dev/packages/alchemist- WCAG 2.2 contrast (1.4.3 / 1.4.6) and APCA: https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html