Skip to content
← All writing
March 29, 2026·6 min read

Testing Flutter Apps: Unit, Widget, and Integration

Flutter's testing story is genuinely good — fast, first-party, and layered in a way most mobile frameworks still envy. And yet a remarkable number of Flutter apps ship with almost no tests, not because their developers don't care, but because it's not obvious where to start: three test types, endless tutorial opinions, and a nagging feeling that "real" testing means aiming for some coverage percentage. It doesn't. The framework gives you three distinct tools, each answering a different question, and knowing which one to reach for is most of the battle. Here's how I think about each layer, and the order I'd add them to an untested app.

Unit tests: your logic, at full speed

Unit tests exercise plain Dart code — no widgets, no device, no emulator warming up. They run in milliseconds, which means you can have hundreds and run them on every save without noticing.

The prerequisite is that your logic is plain Dart. If parsing, validation, calculations, and state transitions live inside widgets — in onPressed closures and build methods — you can't unit test them, and that's a design smell independent of testing. Pull them into plain classes (whatever your state management flavor calls them — notifiers, cubits, services) and testing becomes trivial:

test('splits bill with service charge proportionally', () {
  final result = BillSplitter.split(items: items, serviceRate: 0.12);
  expect(result.perPerson['Ali'], closeTo(154.6, 0.01));
});

That example isn't hypothetical, by the way — my bill-splitting app's core math is covered exactly like this, because a rounding error in an app whose whole job is splitting money isn't a bug, it's a broken promise.

Where code depends on external services — an API client, a database, the clock — inject the dependency and substitute a fake in tests (mocktail is a pleasant way to do it). This is the layer where your business rules — the code that would embarrass you if it broke — should be covered exhaustively, including the ugly edge cases: empty lists, zero amounts, negative numbers, malformed input, the boundary values. Edge cases are cheap here and expensive everywhere else.

Widget tests: the UI, without a device

Widget tests pump a widget tree into a simulated test environment and let you interact with it — find widgets, enter text, tap buttons, assert on the result. No emulator, no rendering to a real screen, and still fast enough to run hundreds in CI.

testWidgets('shows error when email is invalid', (tester) async {
  await tester.pumpWidget(const MaterialApp(home: LoginScreen()));
  await tester.enterText(find.byKey(const Key('email')), 'not-an-email');
  await tester.tap(find.text('Sign in'));
  await tester.pump();
  expect(find.text('Enter a valid email'), findsOneWidget);
});

Two things will save you hours of confusion. First, pump() advances one frame while pumpAndSettle() runs frames until all animations finish — using the wrong one is the classic source of flaky widget tests, and pumpAndSettle on a screen with an infinite animation (a spinner, say) will hang until the timeout. Second, test behavior, not structure: assert "the error message appears," not "there is a Column with three children." Structural assertions shatter on every refactor and protect nothing a user would ever notice.

Widget tests earn their keep on screens with real conditional logic: forms with validation, lists with empty/loading/error states, anything that shows different things in different situations. Feed the screen each state (this is where injected fakes shine again) and assert what the user sees in each. A static settings page, by contrast, doesn't need one — a test that re-states the layout is maintenance without protection.

Integration tests: the whole app, for the critical paths

Integration tests (the integration_test package) run your actual app on a real device or emulator — real rendering, real plugins, real navigation, real startup. They're the only tests that catch problems living between the pieces: broken navigation flows, platform channel issues, plugin misconfigurations, the crash that only happens on a cold start.

They're also slow, occasionally flaky, and the most expensive to maintain — so be ruthless about scope. Cover the handful of journeys where a failure means the app is effectively down: sign in, the core flow that makes your app worth using, checkout or sync if you have them. Five solid integration tests protecting the money paths beat fifty brittle ones nobody trusts — a flaky suite is worse than a small one, because the day people start re-running failures without reading them, the suite is dead and nobody has admitted it yet.

Point them at a staging backend or a fake server, never production, and never with real user accounts. And expect to spend a little time on test infrastructure (seeding accounts, resetting state between runs) — that's normal, budget for it.

A shape that works

The classic pyramid holds up well in Flutter: many unit tests, a good layer of widget tests, a few integration tests. In practice, the priority order for an app with no tests today:

  1. Unit-test the logic that would silently corrupt data or money if it broke. Highest value per line of test code you'll ever write.
  2. Widget-test the screens with the most conditional behavior — forms and stateful lists first.
  3. Add integration tests for the two or three journeys you manually re-check before every release. That manual checklist is your gut telling you exactly what matters; automate it.
  4. Run all of it in CI on every pull request. Tests you only run locally decay in weeks — someone breaks one, nobody notices, and trust quietly evaporates.

One honest note on golden (screenshot) tests: they catch visual regressions well, but they fail on every intentional design tweak, and rendering differences across machines can make them disagree between your laptop and CI. Use them for stable, high-value components — a design system, a chart widget — rather than whole screens, and regenerate them deliberately, not reflexively.

What to skip

Part of a good testing strategy is knowing what not to test. Don't test the framework (Flutter's TextField works; you don't need to confirm it). Don't test trivial getters and constructors. Don't chase a coverage number — 60% coverage concentrated on logic and conditional UI protects you far better than 90% padded with tests that assert nothing meaningful. Coverage is a flashlight, not a goal: use it to find important untested code, then test that.

The takeaway

You don't need heroic coverage — you need the right tests in the right places. Plain-Dart logic under exhaustive unit tests, behavior-focused widget tests on your conditional screens, and a few integration tests guarding the critical journeys will catch the majority of real regressions for a fraction of the effort of testing everything. Do that, wire it into CI, and releases stop being a manual checklist and become a button you can press with confidence.

Further reading