Every Flutter codebase we inherit has tests. Very few have a test suite — something a team trusts enough to let it block a release. The usual pattern: 400 unit tests over pure Dart helpers, three widget tests written during onboarding, one integration test that has been skip-ped since 2024, and a manual regression spreadsheet that takes two people a day and a half before every store submission.
This tutorial builds the suite we actually want on client engagements: fast unit tests, widget tests that pin behaviour rather than implementation, golden tests that survive Impeller and font changes, and a small number of end-to-end tests driven by Patrol that can tap real system dialogs. Then it wires all four layers into CI so a red build means something.
Stack assumed: Flutter 3.4x, Dart 3.x, flutter_test, mocktail, golden_toolkit-free (goldens are core now), patrol 3.x. State management is Riverpod in the examples, but nothing here depends on it.
The shape of a suite that pays for itself
Think in cost-per-bug-caught, not in coverage percentage.
| Layer | Count | Runtime | Catches |
|---|---|---|---|
| Unit / pure Dart | hundreds | seconds | logic, parsing, date and money maths, sync/conflict rules |
| Widget tests | dozens–low hundreds | a minute or two | state transitions, error/empty states, form validation, routing guards |
| Golden tests | dozens | a minute | unintended visual regressions, theme and layout drift |
| Integration (Patrol) | 5–15 | minutes, on devices | launch, login, permissions, deep links, payment/checkout, push |
The failure mode we see most often is an inverted pyramid: teams write end-to-end tests because they feel "real", then abandon them when a 40-minute flaky suite blocks a hotfix. Keep the top of the pyramid tiny and ruthless — only journeys that would cost money if they broke.
Layer 1: make the logic testable, then test it plainly
Most "untestable" Flutter code is untestable because it reaches out to DateTime.now(), Random(), SharedPreferences, or an HTTP client directly from a widget or notifier. Inject those.
// lib/core/clock.dart
abstract class Clock {
DateTime now();
}
class SystemClock implements Clock {
const SystemClock();
@override
DateTime now() => DateTime.now();
}
class FixedClock implements Clock {
FixedClock(this.instant);
DateTime instant;
@override
DateTime now() => instant;
}
Now a billing rule is a pure function of inputs:
// test/billing/grace_period_test.dart
import 'package:flutter_test/flutter_test.dart';
void main() {
group('subscription grace period', () {
final clock = FixedClock(DateTime.utc(2026, 3, 10, 12));
test('is active on the final day of grace', () {
final sub = Subscription(expiresAt: DateTime.utc(2026, 3, 7), graceDays: 3);
expect(sub.isActive(clock.now()), isTrue);
});
test('lapses one second after grace ends', () {
final sub = Subscription(expiresAt: DateTime.utc(2026, 3, 7), graceDays: 3);
clock.instant = DateTime.utc(2026, 3, 10, 0, 0, 1);
expect(sub.isActive(clock.now()), isFalse);
});
});
}
Two boundary tests here are worth fifty tests over getters. Aim your unit layer at edges: empty lists, timezone boundaries, rounding, retry limits, malformed JSON.
Layer 2: widget tests that pin behaviour, not implementation
A widget test that asserts on internal widget structure breaks every refactor and catches nothing. Assert what a user could observe: visible text, enabled/disabled controls, which screen is showing.
// test/features/login/login_screen_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mocktail/mocktail.dart';
class MockAuthRepo extends Mock implements AuthRepo {}
Widget wrap(Widget child, {List<Override> overrides = const []}) {
return ProviderScope(
overrides: overrides,
child: MaterialApp(home: child),
);
}
void main() {
late MockAuthRepo auth;
setUp(() {
auth = MockAuthRepo();
});
testWidgets('shows a field error and does not call the API for a bad email',
(tester) async {
await tester.pumpWidget(wrap(const LoginScreen(),
overrides: [authRepoProvider.overrideWithValue(auth)]));
await tester.enterText(find.byKey(const Key('login.email')), 'not-an-email');
await tester.enterText(find.byKey(const Key('login.password')), 'hunter2!');
await tester.tap(find.byKey(const Key('login.submit')));
await tester.pump();
expect(find.text('Enter a valid email address'), findsOneWidget);
verifyNever(() => auth.signIn(any(), any()));
});
testWidgets('surfaces a retryable message when sign-in times out',
(tester) async {
when(() => auth.signIn(any(), any()))
.thenThrow(const NetworkTimeout());
await tester.pumpWidget(wrap(const LoginScreen(),
overrides: [authRepoProvider.overrideWithValue(auth)]));
await tester.enterText(find.byKey(const Key('login.email')), 'sam@example.com');
await tester.enterText(find.byKey(const Key('login.password')), 'hunter2!');
await tester.tap(find.byKey(const Key('login.submit')));
await tester.pumpAndSettle();
expect(find.text('We couldn’t reach the server. Try again.'), findsOneWidget);
expect(find.byKey(const Key('login.submit')), findsOneWidget);
});
}
Three habits make this layer durable:
Key the interactive elements. Key('login.submit') is stable across copy changes and localisation; find.text('Sign in') is not.
Know when to pump and when to pumpAndSettle. pumpAndSettle returns when no frames are scheduled — it hangs (and eventually times out) on an infinite progress indicator or a looping animation. For loading states, pump a fixed duration instead:
await tester.pump(); // start the future
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pump(const Duration(milliseconds: 300));
Test the async gaps deliberately. Wrap slow fakes in fakeAsync-style control by returning a Completer you resolve inside the test, so you can assert the in-flight state before completion:
final completer = Completer<User>();
when(() => auth.signIn(any(), any())).thenAnswer((_) => completer.future);
// ... assert spinner is visible ...
completer.complete(User.stub());
await tester.pumpAndSettle();
Layer 3: golden tests that don't cry wolf
Golden (screenshot) tests are the cheapest defence against layout regressions — and the fastest way to make a team hate testing if they're set up wrong. Two rules.
Rule 1: load real fonts, or accept boxes. Without font loading, text renders as Ahem boxes and every text change is invisible to the golden. Load fonts once in flutter_test_config.dart, which the test runner picks up automatically:
// test/flutter_test_config.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
TestWidgetsFlutterBinding.ensureInitialized();
await _loadFont('Inter', ['assets/fonts/Inter-Regular.ttf',
'assets/fonts/Inter-SemiBold.ttf']);
await testMain();
}
Future<void> _loadFont(String family, List<String> paths) async {
final loader = FontLoader(family);
for (final p in paths) {
loader.addFont(File(p).readAsBytes().then(ByteData.sublistView));
}
await loader.load();
}
Rule 2: goldens are platform-specific — generate them in one place. Rendering differs subtly between macOS and Linux, and between engine versions. Generate and verify goldens in CI on a single Linux image; never commit goldens produced on a developer laptop. Guard the tests so local runs skip them:
// test/golden/order_card_golden_test.dart
void main() {
testWidgets('order card — all states', (tester) async {
await tester.pumpWidget(_gallery());
await expectLater(
find.byKey(const Key('order.gallery')),
matchesGoldenFile('goldens/order_card_gallery.png'),
);
}, skip: !Platform.environment.containsKey('CI'));
}
Prefer one gallery golden per component — default, loading, error, long text, and a large-text-scale variant side by side — over one file per state. Fewer files, faster review, and the diff shows you the whole component's behaviour at once.
Regenerate deliberately:
flutter test --update-goldens test/golden
Treat an unexplained golden diff in a pull request the way you'd treat a failing assertion: someone has to say why the pixels changed.
Layer 4: Patrol for the handful of journeys that matter
integration_test can drive your app, but it cannot tap a native permission dialog, an OS-level share sheet, or the system WebView on a payment redirect. Patrol can, which is why the top of the pyramid is worth having at all.
dev_dependencies:
patrol: ^3.13.0
patrol:
app_name: Acme Field
android:
package_name: com.acme.field
ios:
bundle_id: com.acme.field
// integration_test/checkout_test.dart
import 'package:patrol/patrol.dart';
void main() {
patrolTest('first-run: permission, login, checkout', ($) async {
await $.pumpWidgetAndSettle(const AcmeApp(env: Env.staging));
// Native permission dialog — the reason we use Patrol.
await $('Enable notifications').tap();
await $.native.grantPermissionWhenInUse();
await $(#loginEmail).enterText('qa+ci@acme.test');
await $(#loginPassword).enterText(const String.fromEnvironment('QA_PASSWORD'));
await $(#loginSubmit).tap();
await $('Catalogue').waitUntilVisible();
await $('Add to order').tap();
await $(#cartFab).tap();
await $('Confirm order').tap();
expect($('Order confirmed'), findsOneWidget);
});
}
Run it:
patrol test --target integration_test/checkout_test.dart --device emulator-5554
Rules that keep this layer alive:
- Point at a dedicated staging environment with seeded data. E2E tests that depend on whatever happens to be in a shared database are flake generators.
- No
sleep. UsewaitUntilVisible/waitUntilExistswith explicit timeouts. - Budget the suite, not the test. If the whole E2E run exceeds ~12 minutes, delete a journey rather than parallelising the problem.
- Quarantine, don't disable. A flaky test moves to a nightly job with an owner and a date, not to
skip:forever.
Wiring it into CI
Run the fast layers on every push, the device layer on merge to the main branch and nightly.
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
dart-and-widget:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.44.x', cache: true }
- run: flutter pub get
- run: dart analyze --fatal-infos
- run: flutter test --exclude-tags=golden --coverage --reporter github
- uses: actions/upload-artifact@v4
if: always()
with: { name: coverage, path: coverage/lcov.info }
goldens:
runs-on: ubuntu-latest # single canonical platform
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.44.x', cache: true }
- run: flutter pub get
- run: flutter test --tags=golden
- uses: actions/upload-artifact@v4
if: failure()
with: { name: golden-failures, path: '**/failures/**' }
Tag the golden tests so both jobs can select them:
testWidgets('order card — all states', (tester) async { /* ... */ },
tags: 'golden');
Uploading the failures/ directory matters more than it sounds: reviewers get the actual _masterImage, _testImage, and _isolatedDiff PNGs attached to the failed run instead of having to reproduce locally.
For the Patrol job, run on a merge queue or nightly schedule against an emulator (Android) and a simulator (iOS), and keep it out of the required-checks list for pull requests until it has been green for a fortnight.
Coverage: useful as a trend, useless as a target
Publish lcov.info and watch the direction of travel. Do not set a global gate at 80% — it drives people to test getters. If you want a gate, gate changed lines in the pull request, and exclude generated files:
dart pub global activate remove_from_coverage
remove_from_coverage -f coverage/lcov.info -r '\.g\.dart$' -r '\.freezed\.dart$'
A pragmatic retrofit order for an existing app
When we join a project with no meaningful suite, this is the order that produces trust fastest:
- Pin the money path first. One Patrol test for the journey that generates revenue. It will find something on day one.
- Golden the design system. Buttons, cards, list rows, empty states. Cheap, high signal, and it makes theme upgrades safe.
- Widget-test the three screens that generate the most support tickets. Error and empty states especially — they are almost never exercised manually.
- Unit-test every bug you fix. A regression test attached to each fix builds the base layer for free over a quarter.
- Only then talk about coverage numbers.
Six weeks of that beats a coverage mandate every time.
Checklist
- Time, randomness, storage, and network injected — no
DateTime.now()inside widgets or notifiers - Interactive widgets carry stable
Keys, and assertions read like user observations pumpAndSettleis never used on a screen with an indefinite animation- Fonts loaded via
flutter_test_config.dart; goldens generated on one canonical CI platform only - Golden tests tagged and their
failures/artifacts uploaded on failure - 5–15 Patrol journeys, seeded staging environment, no
sleep, quarantine policy written down - Fast layers on every push; device layer on merge and nightly
- Coverage tracked as a trend, gated (if at all) on changed lines
Need a Flutter test suite you can actually release on? AviaryApps' senior Flutter consultants retrofit test pyramids, golden and Patrol coverage, and CI gates into existing production apps — usually alongside the team that will own it afterwards. Get in touch with your repository's current state and release cadence.