+1 (415) 480-3939

A Flutter Test Suite You Can Ship On: Widget, Golden, and Patrol Tests in CI

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.

LayerCountRuntimeCatches
Unit / pure Darthundredssecondslogic, parsing, date and money maths, sync/conflict rules
Widget testsdozens–low hundredsa minute or twostate transitions, error/empty states, form validation, routing guards
Golden testsdozensa minuteunintended visual regressions, theme and layout drift
Integration (Patrol)5–15minutes, on deviceslaunch, 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. Use waitUntilVisible / waitUntilExists with 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:

  1. Pin the money path first. One Patrol test for the journey that generates revenue. It will find something on day one.
  2. Golden the design system. Buttons, cards, list rows, empty states. Cheap, high signal, and it makes theme upgrades safe.
  3. Widget-test the three screens that generate the most support tickets. Error and empty states especially — they are almost never exercised manually.
  4. Unit-test every bug you fix. A regression test attached to each fix builds the base layer for free over a quarter.
  5. 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
  • pumpAndSettle is 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.