devShakib

A Testing Strategy That Survives a Real Codebase

A practical Flutter testing strategy: unit, widget, golden and integration tests as a pyramid, plus fakes, an injected clock, and fast CI that survives real code.

A client once asked me to hit 90% test coverage before a funding round. We hit 94%. Two weeks later a null date crashed the checkout screen in production — inside a file reporting 100% coverage.

The tests exercised every line and asserted almost nothing worth asserting. They walked through the code the way a tourist walks through a museum: past everything, into nothing. That was the day coverage stopped being a number I trusted, and the day I started asking a different question about every test I write.

Coverage tells you which lines ran while your tests executed. It says nothing about whether those lines are correct, whether you asserted anything meaningful, or whether the one path that actually breaks in production was ever touched. It is a vanity metric wearing a quality metric's clothes. What survives contact with a real codebase — a codebase with deadlines, three engineers, and a CI bill — is a small, deliberate pyramid where every layer catches a specific class of bug you can name out loud. My rule, six years and a few painful outages in: if you can't say what a test protects you from, delete it.

This is the Flutter testing strategy I actually run on shipping apps: unit, widget, golden, and integration tests arranged as a pyramid, wired to a CI pipeline that stays fast and near-free. Everything below is battle-tested on real Dart code, not a toy sample app.

Why code coverage is a diagnostic, not a target

Before the mechanics, settle the philosophy, because it decides everything downstream. Coverage is a diagnostic: an uncovered catch block is a genuine flag worth a look. Coverage as a target is Goodhart's law in a git repo — the moment a number becomes the goal, engineers optimize for the number instead of the safety, and you get 94% coverage that ships a null date to production.

The failure mode is subtle. A test that pumps a widget, taps a button, and asserts nothing still counts every line it touched as "covered." Your coverage report lights up green while your assertions are empty. That's why I stopped asking "what's our coverage?" and started asking two better questions of every test in the suite:

Those two questions do more for real-world reliability than any coverage threshold I've ever enforced. Keep them in mind through everything that follows.

The Flutter test types, and what each is actually for

Flutter ships four practical testing tools. They are not interchangeable, and treating them as a coverage buffet is how you end up with a slow, flaky suite nobody runs.

| Type | Runs on | Speed | Catches |

|------|---------|-------|---------|

| Unit | Dart VM | ~1ms | Logic errors: math, parsing, state transitions, edge cases |

| Widget | Flutter test env (headless) | ~10-50ms | Rendering logic, interaction, conditional UI, layout wiring |

| Golden | Flutter test env | ~50ms | Visual regressions: spacing, color, unintended layout shifts |

| Integration | Real device / emulator | seconds | End-to-end flows, plugin behavior, platform channels |

The testing pyramid is not a suggestion. You want hundreds of unit tests, dozens of widget tests, a handful of golden tests on your most important screens, and a thin cap of integration tests covering the two or three flows that lose you money if they break.

Invert that — mostly integration tests "because they're realistic" — and your suite takes twenty minutes, flakes on CI for reasons unrelated to your code, and gets skipped the first week a deadline gets tight. A test that doesn't run protects nothing. I would rather have 300 fast unit tests people actually run than 30 "realistic" integration tests they route around.

One more framing that keeps the shape honest: match the layer to the bug, not to the feature. A single feature — say, checkout — might have twenty unit tests on its pricing math, three widget tests on its form states, one golden on the receipt screen, and one integration test proving the whole flow reaches "payment succeeded." You don't pick a layer per feature; you pick a layer per kind of failure that feature can produce.

Unit tests that test your logic, not the framework

The most common wasted test I see asserts that Flutter works. Don't. Assume setState sets state and List.map maps. Your unit tests exist to protect your logic — the pure functions and state machines where bugs actually hide.

The precondition for good unit tests is that your logic is reachable without a widget tree. If your date formatting or your pricing math lives inside a build method, the only way to test it is to pump a widget, which is slower, noisier, and couples a logic test to a layout. Pull the logic out into something a test can hold in one hand:

class InvoiceSummary {  const InvoiceSummary({required this.lineItems, this.discount = 0});  final List<LineItem> lineItems;  final double discount;  double get subtotal =>      lineItems.fold(0, (sum, item) => sum + item.price * item.quantity);  double get total {    final discounted = subtotal * (1 - discount);    return discounted < 0 ? 0 : discounted;  }}

Now the test is fast, boring, and aimed straight at the class of bug that costs real money — arithmetic on real invoices:

test('total never goes negative on an absurd discount', () {  final summary = InvoiceSummary(    lineItems: [LineItem(price: 10, quantity: 1)],    discount: 2.0, // 200% — a bad admin input, and it will happen  );  expect(summary.total, 0);});test('subtotal handles an empty cart', () {  const summary = InvoiceSummary(lineItems: []);  expect(summary.subtotal, 0);});

Notice what's under test: the edges. The happy path — one item, ten dollars — rarely breaks and I'll notice it manually anyway. The 200% discount, the empty list, the null date is where production lives. So I write the edge cases first and the happy path last. If I only have time for two tests on a function, both of them go to the edges.

When a function takes a handful of inputs that should all obey the same rule, don't copy-paste the test body — drive it with a table. It documents the contract and makes adding the next edge case a one-line change:

void main() {  const cases = <(double discount, double expected)>[    (0.0, 10),   // no discount    (0.5, 5),    // half off    (1.0, 0),    // free    (2.0, 0),    // clamps, never negative    (-1.0, 20),  // negative discount = surcharge? decide, then assert it  ];  for (final (discount, expected) in cases) {    test('total for discount $discount is $expected', () {      final summary = InvoiceSummary(        lineItems: [LineItem(price: 10, quantity: 1)],        discount: discount,      );      expect(summary.total, expected);    });  }}

That last row is the point of the exercise. A negative discount is nonsense, but something will happen when one arrives — and writing the test forces you to decide what, on purpose, instead of discovering it in a support ticket.

For state management — bloc, riverpod, a plain ChangeNotifier — the same rule holds. Test the transitions. Given this state and this event, do I land in the state I expect? That's a logic assertion, and it belongs in a unit test with no WidgetTester anywhere in sight. bloc_test exists precisely for this: you seed a starting state, act an event, and assert the emitted sequence.

blocTest<CheckoutBloc, CheckoutState>(  'emits [loading, failure] when payment is declined',  build: () => CheckoutBloc(payments: FakePayments.declining()),  act: (bloc) => bloc.add(const SubmitPayment()),  expect: () => const [CheckoutLoading(), CheckoutFailure('card declined')],);

If you can't test your state logic without pumping a widget, your state logic is living in the wrong place, and that's worth fixing before you write the test.

Widget tests: pumping, settling, and the trap in pumpAndSettle

Widget tests run a real (headless) render loop, so they catch a class of bug unit tests can't: "the button is disabled when it should be enabled," "the error text appears after a failed submit," "tapping this navigates there." They're your workhorse for UI logic — the wiring between a tap and a consequence.

The mechanics are simple; the traps are what bite. pump() advances one frame. pumpAndSettle() pumps frames until none are scheduled. That second one is a footgun.

testWidgets('shows an error when login fails', (tester) async {  await tester.pumpWidget(    MaterialApp(home: LoginScreen(auth: FakeAuth.failing())),  );  await tester.enterText(find.byKey(const Key('email')), 'x@y.com');  await tester.enterText(find.byKey(const Key('password')), 'wrong');  await tester.tap(find.byKey(const Key('submit')));  await tester.pump(); // fire the submit  await tester.pump(const Duration(milliseconds: 300)); // let the async settle  expect(find.text('Invalid credentials'), findsOneWidget);});

I deliberately used explicit pump(Duration) calls instead of pumpAndSettle(). Here's why. pumpAndSettle() waits for the frame schedule to drain, and anything that never drains — an infinite loading spinner, a repeating animation, a Timer.periodic — hangs the test until it times out and dies with a useless error. I once watched a CI job burn ten minutes because one screen shipped a looping shimmer placeholder, and every widget test that touched it sat there waiting for an animation that was designed never to stop. If you know roughly how long the async work takes, pump that duration explicitly. Reach for pumpAndSettle() only for finite animations you actually want to run to completion, like a route transition.

Two more habits that quietly save hours:

A third habit for anything that lays out differently at different sizes: pin the surface size. A widget test defaults to an 800x600 canvas, so a card that wraps at phone width may look fine in the test and overflow on a real device. When layout is the thing under test, set tester.view.physicalSize (or use a helper that does) so the test asserts the geometry you actually ship.

Test data you can trust: factories over hand-rolled fixtures

Here's a slow leak most suites never notice: the tests are fine, but the data they run against is a lie. Someone hand-builds a User with every field set to a tidy value, so the test passes while the real app chokes on the user whose avatarUrl is null and whose name is a 40-character emoji string.

Centralize test data behind factories with sensible defaults and cheap overrides. Then every test states only the field it cares about, and the noise stays out of the assertion.

User makeUser({  String id = 'u1',  String name = 'Test User',  String? avatarUrl, // null by default — because production users are messy  UserRole role = UserRole.member,}) {  return User(id: id, name: name, avatarUrl: avatarUrl, role: role);}test('admin badge shows only for admins', () {  final admin = makeUser(role: UserRole.admin);  final member = makeUser(); // one word tells the reader "nothing special here"  expect(admin.canModerate, isTrue);  expect(member.canModerate, isFalse);});

Two payoffs. When the User constructor gains a required field, you fix one factory instead of two hundred test call sites. And the default values become your definition of a "normal but realistic" object — so make the defaults slightly hostile. A null avatar and an empty list by default have caught more real bugs for me than any clever assertion, because they force every screen to survive the messy case without me remembering to write it each time.

One caution worth stating: keep factories dumb. The moment a factory grows conditional logic — "if role is admin, also set these three fields" — it becomes code that itself deserves a test, and your fixtures start hiding bugs instead of exposing them. A factory is a bag of defaults with overrides, nothing more.

Golden tests without the flakiness

Golden tests screenshot a widget and diff it against a committed reference image. They catch what nothing else does: the padding that quietly went from 16 to 12, the color that regressed after a theme refactor, the layout that shifted when someone bumped a dependency. When they work, they're the cheapest visual regression net you'll ever own. When they flake, teams rip them out within a month and never trust them again.

Golden test flakiness has three sources, and all three are fixable.

Fonts. By default the Flutter test environment renders text as boxes (the Ahem font). Your beautiful golden comes out looking like a redacted document. Load real fonts once in a flutter_test_config.dart at the root of your test folder, and it applies to every test automatically:

Future<void> testExecutable(FutureOr<void> Function() testMain) async {  await loadAppFonts();  return testMain();}

Platforms. A golden generated on my Mac will not byte-match one generated on the Linux CI runner — font rasterization differs by platform, down to the pixel. The fix is to generate goldens in one place only. I generate them on CI and never locally, so the reference always matches the environment that verifies it. flutter test --update-goldens runs as a manual CI job; developers review the resulting image diff in the PR like any other change. A golden nobody reviews is just a screenshot you're afraid to delete.

Tolerances. Sub-pixel anti-aliasing produces tiny diffs that aren't real regressions. Flutter's comparator (and packages like golden_toolkit and alchemist) let you set a tolerance so a 0.1% pixel difference passes but a real layout shift fails. Zero-tolerance golden tests are precisely the ones that flake.

Keep goldens scoped. I don't golden every widget — I golden the five or six screens where a visual regression would embarrass us in front of a customer, plus the tricky reusable components: empty states, error cards, anything with conditional layout. Golden everything and every legitimate design tweak becomes a 40-file diff nobody reviews properly, which is how you end up rubber-stamping a real regression. And prefer a multi-scenario golden — a single reference image showing a component in its loading, empty, populated, and error states side by side — over four separate files. One diff, four states, one review.

Integration tests and the honest cost of running on devices

Integration tests drive the real app on a real device or emulator through the integration_test package. They're the only tests that exercise actual plugins, platform channels, real navigation, and the wiring between every layer at once. They catch the "everything is unit-tested but the app is white-screened on launch" bug — the one that makes unit-test coverage look like a cruel joke.

They also cost real money and time. An emulator on CI is slow to boot, occasionally flaky for reasons that have nothing to do with your code, and a device farm bills by the minute. So I'm ruthless about scope. Integration tests cover only the flows where a break is a business emergency:

That's usually three to five tests. Not a mirror of the whole app — a smoke test for the arteries.

void main() {  IntegrationTestWidgetsFlutterBinding.ensureInitialized();  testWidgets('user can log in and reach home', (tester) async {    app.main();    await tester.pumpAndSettle();    await tester.enterText(find.byKey(const Key('email')), 'test@demo.com');    await tester.enterText(find.byKey(const Key('password')), 'password123');    await tester.tap(find.byKey(const Key('submit')));    await tester.pumpAndSettle();    expect(find.byKey(const Key('home_screen')), findsOneWidget);  });}

Note the one place pumpAndSettle() earns its keep: a finite login flow that genuinely settles. The moment an integration test touches a screen with a perpetual animation, swap back to explicit pumps or it will hang exactly the way the widget tests did.

Point integration tests at a dedicated test backend, not production. A throwaway Firebase project or an emulator suite keeps the tests deterministic and stops a smoke test from mutating real user data — the kind of mistake you only make once. On a keep-the-bill-near-zero setup, the Firebase Local Emulator Suite is the right home for this: real-behaving auth and Firestore, zero cloud spend.

One honest note on cost: I run integration tests on a nightly schedule and on release branches, not on every push. Unit and widget tests gate every PR because they're fast; integration tests gate the release because they're slow. Matching test speed to how often you run it is the whole game — and on a lean, keep-the-bill-near-zero setup, it's also the difference between a CI invoice you ignore and one you argue about.

Test doubles: faking Firebase, network, and time

You cannot write fast, deterministic tests against real Firebase, a real network, or the real clock. Each introduces latency and non-determinism — exactly the two ingredients that make a suite slow and flaky. The answer is test doubles, and the shape of the double matters more than most people think.

Prefer fakes over mocks. A mock records that a method was called and lets you assert the call happened; a fake is a working in-memory implementation you can seed and query. For most of what I test, fake_cloud_firestore and firebase_auth_mocks give me a real-behaving Firestore. My code doesn't know it's not talking to Google:

test('repository reads seeded admins', () async {  final firestore = FakeFirebaseFirestore();  await firestore.collection('users').add({'name': 'Shakib', 'role': 'admin'});  final repo = UserRepository(firestore);  final admins = await repo.admins();  expect(admins.single.name, 'Shakib');});

Mocks have their place — verifying that you didn't fire an analytics event twice, for instance — but reach for them second. Over-mocked tests assert the shape of your implementation, not its behavior, so they break on every refactor while catching almost nothing. A good rule: mock the thing you want to verify a call to (analytics, logging, a payment charge), fake the thing you want to read state back from (a database, a cache, a repository).

Network. Wrap your HTTP client behind an interface and inject a fake, or use a package that stubs responses (Dio's interceptors or http's MockClient both do this cleanly). The test then asserts your parsing and error handling — the class of bug a malformed 500 or a truncated JSON body triggers — without ever touching the wire or waiting on it. The 500 path and the empty-body path are where clients actually crash; test those before the 200.

Time is the one everyone forgets. Code that calls DateTime.now() directly is untestable at the edges: you can't test "this token is expired" if you can't control the clock. Inject it.

class TokenService {  TokenService({DateTime Function() now = DateTime.now}) : _now = now;  final DateTime Function() _now;  bool isExpired(Token token) => _now().isAfter(token.expiresAt);}test('a token past expiry is expired', () {  final service = TokenService(now: () => DateTime(2030));  expect(service.isExpired(Token(expiresAt: DateTime(2020))), isTrue);});

That one injection turns a whole family of time-dependent bugs — expiry, cooldowns, rate limits, "new since your last visit" — from untestable into a one-line assertion. The same discipline applies to any nondeterministic source: randomness, UUID generation, the device locale. Inject the source, and the edge becomes a plain assertion.

Making the Flutter test suite fast enough that people actually run it

A test suite has exactly one job beyond catching bugs: be fast enough that engineers run it without being nagged. The moment flutter test takes five minutes, people stop running it locally and start finding out about failures on CI — which is slower, more expensive, and always at the worst time. Speed is a feature, and it's the feature that keeps every other feature of the suite alive.

What actually keeps it fast:

Here's the shape of that fail-fast pipeline as a GitHub Actions job — cheap tests gate every PR, expensive tests only run when the cheap ones pass:

jobs:  fast-tests:            # gates every PR    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: subosito/flutter-action@v2      - run: flutter pub get      - run: flutter test --coverage   # unit + widget + golden  integration:           # only after fast-tests pass    needs: fast-tests    runs-on: macos-latest    steps:      - uses: actions/checkout@v4      - uses: subosito/flutter-action@v2      - run: flutter pub get      - run: flutter test integration_test

On a recent project we got the full unit-plus-widget suite — around 600 tests — running in under 40 seconds locally. That's the threshold where people run it before pushing without thinking about it. Above a couple of minutes, the habit dies, and a suite nobody runs is just documentation that lies with a straight face.

What not to test, and why chasing 100% coverage backfires

Here's the opinion that reliably gets me an argument: aiming for 100% coverage makes your codebase worse. To cover the last 15% you end up writing tests for generated code, for trivial getters, for toString, for exhaustive switch branches the compiler already guarantees. Those tests assert nothing but they still cost — every one is a thing that breaks and needs updating when you refactor. High coverage with weak assertions raises your maintenance bill while lowering your real safety, because now the untested-but-critical path looks tested.

Things I deliberately don't test:

What I do insist on: every branch in business logic, every error path (the catch block everyone forgets until it throws in production), every boundary condition, and every bug I've already fixed. That last one — a regression test for a fixed bug — is the highest-value test you can write, because it protects against a failure you have proof is possible. Everything else is a hypothesis; a regression test is a receipt.

If you want coverage to stop lying, exclude the noise from the report itself. Filtering generated files out of your lcov output means the number you see reflects code that actually has logic worth covering — a diagnostic you can trust instead of one padded by .g.dart files:

lcov --remove coverage/lcov.info \  '*.g.dart' '*.freezed.dart' '*/generated/*' \  -o coverage/lcov.info

Coverage as a diagnostic is genuinely useful — an uncovered catch block is a real flag worth a look. Coverage as a target is Goodhart's law in a git repo: the moment the number becomes the goal, engineers optimize for the number instead of the safety, and you get 94% coverage that ships a null date to production. Ask me how I know.

Key takeaways