Testing strategy
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 testing-strategyAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 17 days oldThe repository was created 17 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 test doctrine where shape follows code not the pyramid: pushes logic into Flutter-free packages tested with pure package:test and an injected Clock; asserts invariants with seeded fuzz tests against an independent oracle plus round-trip and rounding goldens; prefers bare-implements fakes over mocktail for code you own; tests the data layer against a real NativeDatabase.memory Drift engine, never a mocked DAO; drives Notifiers headlessly with ProviderContainer overrides; guards one end-to-end acceptance gate and a runtime invariant tripwire; floors coverage on unrecoverable-bug files not a global percentage; and fixes the coverage-lies-upward gap. Use when writing tests under test/ or integration_test/, choosing unit vs widget vs integration, adding a fuzz or round-trip property, wiring a fake or ProviderContainer test, gating coverage, or triaging a flaky-suite failure.
SKILL.md
14.4 KB, ~3.4k tokens by cl100k_base, as published. Nobody here has run it
Testing Strategy
Tests are the only correctness instrument you control at build time: shape the suite
to the code, not to a decades-old ratio, and make each test assert behaviour a build
either passes or fails. Applies to every test under test/ and integration_test/.
Read the reference for the task at hand:
references/test-layers.md— per-layer harness, imports, teardown, edge tables (pure core, in-memory Drift, headless Notifier, integration).references/property-and-fakes.md— fakes-over-mocks, enum-driven fake state, seeded fuzz, independent oracles, absence-of-a-failure-class tests.references/coverage-and-budget.md— file-level floors, the coverage-lies-upward fix, the suite-time budget, and the manual-pass handoff.
Run scripts/check_test_hygiene.sh and scripts/run_tests.sh before a PR.
Golden, RTL, and a11y widget mechanics live in widget-golden-and-a11y-testing; this
skill governs everything below the pixel.
Non-negotiable rules
- Shape the suite to the code, not the pyramid. The 70/20/10 numbers trace to a
2011 test-size heuristic (its author said they were "pulled out of a hat") and
never described Flutter's unit/widget/integration taxonomy. Test at the cheapest
tier that can assert the behaviour: anything expressible as
f(input) -> outputis a unit or property test, never apumpWidget. Driving pure logic through the widget tree is slower, flakier, and hides which layer broke. - Put business rules in a Flutter-free package and inject a
Clock. Domain math lives in pure Dart with zero Flutter/plugin/IO imports, tested withpackage:test(notflutter_test). The one time type ispackage:clock'sClock— NEVERDateTime.now(), never a bespokeClockService. Purecorereads the ambientclock.now(), pinned in tests withwithClock(Clock.fixed(t), …); Riverpod/feature code reads the injectedclockProvider, overridden withclockProvider.overrideWithValue(Clock.fixed(t))(seam owned byvalue-objects-money-and-units). The ban is structural: a pure package declares no Flutter SDK constraint. - Assert invariants, not just examples. Every conversion has a round-trip
test (
decode(encode(x)) == x) and rounding goldens at half-way/boundary values; every universal claim is a seeded fuzz loop (for (var seed = 0; seed < N; seed++)) or property test, checked against an independent oracle — never the production function under test. Print the generated input inreason:so a failure is its own minimal repro.==on integers only;closeTo(x, 1e-9)on doubles. - Test the data layer against a real in-memory engine. Use
NativeDatabase.memory(), never a mocked DAO — a mocked DAO proves nothing about SQL, constraints, indexes, or migrations. Mock repositories only above the data layer.addTearDown(db.close)and close streams synchronously so reactive.watch()streams do not leak "Timer still pending". - Prefer bare-
implementsfakes over mocks for code you own. Aclass FakeX implements X(nonoSuchMethodsuperclass) makes an interface change a compile error, models the risk (state, not call-order) as a field, and doubles as the contract's documentation. Reservemocktailfor genuinely external dependencies; nevermockito/@GenerateMocks(codegen, no null-safety win). registerFallbackValuefor every custom type passed toany()/captureAny()insetUpAll. mocktail throws at runtime, not compile time, on a missing fallback — this is the single most common mocktail failure.- Drive Notifiers headlessly with
ProviderContainer. Override provider dependencies viaoverrideWith; never pump a widget to test state. Assert on the exposedAsyncValue/state, drive actions through.notifier, and dispose the container in teardown. - Guard one end-to-end acceptance gate. A single realistic scenario asserted to
the exact expected result, plus the conservation invariant (parts sum to the
whole). It is the test that proves the pieces compose. Back it with a runtime
assert tripwire inside the primitive itself (
assert(sum(result) == total)) — free in release, catches the bug the moment it happens. - Hold an unrecoverable-bug-files floor by diff-review, not a coverage gate.
There is no automated percentage gate — coverage is a published report
(
ci-pipeline-and-gatesowns that). Instead hold a 100% floor on the handful of files (migrations, the money/allocate primitive, the parser with wire-format traps) where a gap is silent data loss — enforced by reading the diff, not by counting lines. A directory or global percentage gate rewards vanity tests and exclusion churn. First fix the coverage-lies-upward gap:flutter test --coverageomits files no test imports, so the number overstates safety. - Never
pumpAndSettle()on an indefinite animation (splash, shimmer, spinner) — it hangs on a 10-minute timeout. Use timedpump(Duration)withfakeAsync. - Keep the suite fast, and hand structurally-untestable paths to a manual pass. A suite that costs minutes gets skipped, and a skipped suite is a distrusted one. Anything an emulator cannot reproduce (real audio, OEM device diversity, native surfaces without a Flutter engine) is enumerated in a named manual pre-release pass, not faked green — a green test that proves nothing is worse than an admitted gap.
Tier the test to the code (package:test vs flutter_test)
Pure logic runs under dart test with no widget binding; only UI and plugin-wrapper
tests need flutter_test.
// test/domain/money_test.dart — pure Dart, `dart test`, no flutter_test import.
import 'package:test/test.dart';
import 'package:app_core/app_core.dart';
void main() {
// Round-trip: encoding is lossless across the ISO-4217 exponent range.
test('minor-units round-trip for exponents 0, 2, 3', () {
for (final currency in [Currency.jpy, Currency.usd, Currency.kwd]) {
final money = Money(amountMinor: 12345, currency: currency);
expect(Money.parse(money.format(), currency), equals(money));
}
});
}
Inject the clock; pin it in tests
Production reads clock.now(); tests freeze it so time-dependent logic is
deterministic — no wall clock, no flake.
// Production: a Reminder is due when its target time has passed.
bool isDue(Reminder r) => !clock.now().isBefore(r.dueAt);
// test/domain/reminder_test.dart
test('reminder is due exactly at its due instant', () {
final due = DateTime.utc(2026, 1, 1, 9);
withClock(Clock.fixed(due), () {
expect(isDue(Reminder(dueAt: due)), isTrue);
});
});
For code behind a provider, inject clockProvider and freeze it in a container test
with clockProvider.overrideWithValue(Clock.fixed(due)) — same Clock, same fixed
instant, driven through Riverpod instead of the ambient zone.
Seeded fuzz against an independent oracle
Twenty lines of package:test give you what a stale property package would, with the
generated input as the built-in minimal repro. The oracle must be independent of the
function under test.
// test/domain/allocate_test.dart
test('allocate: parts always sum to the input (fuzz)', () {
final rng = Random(0xC0FFEE);
for (var seed = 0; seed < 500; seed++) {
final n = rng.nextInt(20) + 1;
final total = rng.nextInt(100000);
final weights = List.generate(n, (_) => rng.nextInt(500));
final parts = allocate(amountMinor: total, weights: weights);
expect(parts.fold(0, (a, b) => a + b), equals(total),
reason: 'seed=$seed total=$total weights=$weights'); // its own repro
}
});
Real in-memory Drift, never a mocked DAO
// test/data/order_dao_test.dart
void main() {
late AppDatabase db;
setUp(() => db = AppDatabase(NativeDatabase.memory()));
tearDown(() => db.close()); // reactive .watch() streams must not leak timers
test('CHECK constraint rejects a negative quantity', () {
expect(
() => db.orderDao.insertItem(const OrderItem(quantity: -1)),
throwsA(isA<SqliteException>()), // proves the real constraint, not a stub
);
});
}
Headless Notifier tests with ProviderContainer
// test/features/cart/cart_notifier_test.dart
void main() {
test('addItem publishes the new total', () async {
final container = ProviderContainer(
overrides: [orderRepositoryProvider.overrideWith((ref) => _FakeOrderRepo())],
);
addTearDown(container.dispose);
await container.read(cartNotifierProvider.future);
await container.read(cartNotifierProvider.notifier).addItem(_anItem);
final state = container.read(cartNotifierProvider);
expect(state, isA<AsyncData<CartState>>());
expect(state.requireValue.totalMinor, 1299);
});
}
ProviderContainer.test() (Riverpod 3.x) auto-disposes and is the preferred form when
available; otherwise addTearDown(container.dispose).
Drive timers with fakeAsync
test('debounced search fires once after the window', () {
fakeAsync((async) {
final calls = <String>[];
final debouncer = Debouncer(const Duration(milliseconds: 300));
debouncer.run(() => calls.add('a'));
async.elapse(const Duration(milliseconds: 299));
expect(calls, isEmpty);
async.elapse(const Duration(milliseconds: 1));
expect(calls, ['a']);
});
});
Anti-patterns
- Inverting the pyramid — driving pure
f(input) -> outputlogic throughpumpWidget. Slower, flakier, hides the broken layer. DateTime.now()/ ambientRandom()reachable from domain logic. Makes "identical inputs -> identical output" untestable; injectClockand a seed.flutter_testimported into a pure package. Breaks the structural boundary and slows the fastest tier; the package should declare no Flutter SDK constraint.- A mocked DAO standing in for the database. Proves nothing about SQL,
constraints, indexes, or migrations. Use
NativeDatabase.memory(). ==on doubles, or covering a universal claim with one lucky example instead of a fuzz loop over an independent oracle.- A fake that lies about its contract (a dependency that can never fail) — the test then reflects a happy-path stub, not real behaviour. Fakes must expose failure.
- Missing
registerFallbackValuefor a customany()argument — a runtime throw no compiler catches. pumpAndSettle()on an indefinite indicator — hangs on the 10-minute timeout.- A global/directory coverage-percentage gate — rewards vanity tests, and the raw number lies upward. Floor the unrecoverable files; publish the rest.
- A green test over a structurally-untestable path (real audio, native tile, device diversity) — worse than an admitted gap, because it stops anyone checking by hand.
- A test with no meaningful
expect, shared mutable state across tests, or a vague name liketest('works').
Definition of done
- Each test lives at the cheapest tier that can assert it; pure logic under
package:test, widget/integration only where the UI/plugin seam is the point. - Domain logic reads
clock.now(), neverDateTime.now(); tests pin it withwithClock. - Every conversion has a round-trip test and boundary rounding goldens; every
universal claim is a seeded fuzz/property test against an independent oracle,
with the input echoed in
reason:. - The data layer is tested against
NativeDatabase.memory(), not a mocked DAO;db.close()in teardown. - Owned collaborators are bare-
implementsfakes;mocktailis reserved for external deps withregisterFallbackValuefor every customany()type. - Notifiers are tested through
ProviderContaineroverrides, disposed in teardown; no widget pumped to test state. - One acceptance gate asserts a realistic scenario exactly and the conservation
invariant; the primitive carries a runtime
asserttripwire. - Coverage floors sit on the unrecoverable-bug files; the coverage-lies-upward gap is fixed (untested files included); generated files stripped.
- No
pumpAndSettle()on an indefinite animation; timers driven byfakeAsync. - The suite stays within its time budget; every structurally-untestable path is named in the manual pre-release pass, not faked green.
Related skills
- See
widget-golden-and-a11y-testingfor golden lanes, the overflow/RTL matrix, and honest a11y limits. - See
value-objects-money-and-unitsfor theallocate()primitive andClockinjection this skill tests. - See
persistence-driftfor the DAO/value-object seam the in-memory tests exercise. - See
run-migrationfor the forward-only migration ritual whose from→to paths get the 100% floor. - See
error-handling-typed-resultsfor the sealedResult/Failurethe exhaustive-switch tests assert on. - See
async-safetyfor the dropped-Future holes the silence-review checklist targets. - See
service-boundary-and-nativefor the injectable ports the fakes stand in for. - See
ci-pipeline-and-gatesfor how these lanes and greps are wired as gates.
References
- Flutter testing overview — https://docs.flutter.dev/testing/overview
- Riverpod testing — https://riverpod.dev/docs/essentials/testing
- Drift testing (NativeDatabase.memory) — https://drift.simonbinder.eu/docs/testing/
package:clock— https://pub.dev/packages/clockpackage:fake_async— https://pub.dev/packages/fake_asyncpackage:mocktail— https://pub.dev/packages/mocktail
Gives 0 of the 12 instructions most test skills give in ~3.4k tokens
Counted across 964 of the 1,571 authors here whose files we hold, read 2026-08-07
- close the browser when donein 55 of 964, across 12 files
- wait for network idle statein 51 of 964, across 6 files
- launch chromium in headless modein 49 of 964, across 6 files
- use descriptive selectors for elementsin 49 of 964, across 6 files
- run provided scripts with help flag firstin 49 of 964, across 6 files
- add appropriate explicit waitsin 48 of 964, across 5 files
- use bundled scripts as black boxesin 46 of 964, across 3 files
- do not read script source codein 46 of 964, across 3 files
- use sync playwright for scriptsin 46 of 964, across 3 files
- inspect dom before executing actionsin 46 of 964, across 3 files
- run the full test suitein 37 of 964
- write the failing test firstin 29 of 964, across 23 files
Said here and by no other author read
- shape the test suite to the code not the pyramid
- test business rules in a flutter-free package
- inject a clock and never use datetime.now
- assert invariants using seeded fuzz tests and independent oracles
- add round-trip tests and rounding goldens for conversions
- test the data layer against a real in-memory database
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.