Skip to content
FlutterLearn

Advanced · Lesson 5 · 14 min read

A Testing Strategy That Catches Real Bugs

Unit, widget, golden and integration tests — what each is good at, how to write them fast, and how to keep the suite trustworthy.

Updated July 31, 2026

What you will learn

  • Write fast unit tests for business logic
  • Test widgets with pump, finders and matchers
  • Catch visual regressions with golden tests
  • Run end-to-end integration tests on real devices

Flutter gives you four test types. They are not interchangeable, and the most common failure mode is writing a slow integration test for something a five-millisecond unit test would have caught.

TypeSpeedCatchesWrite these for
Unit~1msLogic errors, edge casesValidators, mappers, calculations, controllers
Widget~50msWiring, rendering, interactionScreens, forms, custom widgets
Golden~100msUnintended visual changeDesign-system components, themed widgets
IntegrationsecondsReal end-to-end failuresCritical flows: sign-in, checkout

Aim for many unit tests, a solid layer of widget tests over your screens, goldens on shared components, and a small number of integration tests covering only flows where a failure loses money or data.

Unit tests

test/validation/validators_test.dart
import 'package:flutter_test/flutter_test.dart';

void main() {
  group('validateEmail', () {
    test('accepts a well-formed address', () {
      expect(validateEmail('ada@example.com'), isNull);
    });

    test('rejects missing @', () {
      expect(validateEmail('ada.example.com'), 'Enter a valid email');
    });

    test('rejects empty and whitespace-only input', () {
      expect(validateEmail(''), isNotNull);
      expect(validateEmail('   '), isNotNull);
      expect(validateEmail(null), isNotNull);
    });
  });

  group('CartModel', () {
    late CartModel cart;
    setUp(() => cart = CartModel());

    test('total sums item prices', () {
      cart.add(const Product(id: '1', price: 9.99));
      cart.add(const Product(id: '2', price: 5.01));
      expect(cart.total, closeTo(15.00, 0.001));
    });

    test('notifies listeners on add', () {
      var notifications = 0;
      cart.addListener(() => notifications++);
      cart.add(const Product(id: '1', price: 1));
      expect(notifications, 1);
    });
  });
}

Widget tests

test/screens/login_screen_test.dart
void main() {
  testWidgets('shows validation error for a bad email', (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();   // one frame, so the error renders

    expect(find.text('Enter a valid email'), findsOneWidget);
  });

  testWidgets('submits and navigates on success', (tester) async {
    final auth = FakeAuth(succeeds: true);

    await tester.pumpWidget(
      MaterialApp(
        home: Provider<Auth>.value(value: auth, child: const LoginScreen()),
      ),
    );

    await tester.enterText(find.byKey(const Key('email')), 'ada@example.com');
    await tester.enterText(find.byKey(const Key('password')), 'sup3rsecret');
    await tester.tap(find.text('Sign in'));

    // pumpAndSettle waits for animations and pending frames to finish
    await tester.pumpAndSettle();

    expect(find.byType(HomeScreen), findsOneWidget);
    expect(auth.signInCalls, 1);
  });

  testWidgets('disables the button while submitting', (tester) async {
    await tester.pumpWidget(/* ... slow fake auth ... */);

    await tester.tap(find.text('Sign in'));
    await tester.pump();   // start the async work, do not settle

    final button = tester.widget<FilledButton>(find.byType(FilledButton));
    expect(button.onPressed, isNull);
  });
}
Dart
// Useful finders
find.text('Sign in');
find.byKey(const Key('email'));
find.byType(FilledButton);
find.byIcon(Icons.search);
find.widgetWithText(FilledButton, 'Save');
find.descendant(of: find.byType(AppBar), matching: find.byType(IconButton));

// Useful matchers
expect(find.text('Hello'), findsOneWidget);
expect(find.byType(ErrorView), findsNothing);
expect(find.byType(ListTile), findsNWidgets(3));
expect(find.byType(Card), findsAtLeastNWidgets(1));

// Interactions
await tester.tap(finder);
await tester.enterText(finder, 'text');
await tester.drag(finder, const Offset(0, -300));
await tester.fling(finder, const Offset(0, -400), 1000);
await tester.longPress(finder);

Faking dependencies

Dart
// A hand-written fake is often clearer than a mocking framework
class FakeLessonRepository implements LessonRepository {
  FakeLessonRepository({this.lessons = const [], this.failure});

  final List<Lesson> lessons;
  final Failure? failure;
  int getLessonsCalls = 0;

  @override
  Future<Result<List<Lesson>>> getLessons({Level? level}) async {
    getLessonsCalls++;
    if (failure != null) return Err(failure!);
    return Ok(
      level == null ? lessons : lessons.where((l) => l.level == level).toList(),
    );
  }

  @override
  Future<Result<Lesson?>> getLesson(String id) async =>
      Ok(lessons.where((l) => l.id == id).firstOrNull);

  @override
  Stream<List<Lesson>> watchBookmarked() => Stream.value(const []);
}

Also fake the network at the HTTP layer when you want to exercise real parsing code, and fake plugins with the binary messenger so widget tests never touch a real platform channel.

Dart
TestWidgetsFlutterBinding.ensureInitialized();

TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
    .setMockMethodCallHandler(
  const MethodChannel('plugins.flutter.io/path_provider'),
  (call) async => '/tmp/test',
);

Golden tests

Dart
testWidgets('LessonCard matches golden', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      theme: buildTheme(Brightness.light),
      home: Scaffold(
        body: Center(
          child: SizedBox(
            width: 360,
            child: LessonCard(lesson: sampleLesson),
          ),
        ),
      ),
    ),
  );

  await expectLater(
    find.byType(LessonCard),
    matchesGoldenFile('goldens/lesson_card_light.png'),
  );
});
Terminal
# Create or update the reference images after an intentional change
flutter test --update-goldens

# Font rendering differs across platforms — pin goldens to one CI OS
flutter test --tags golden

Integration tests

integration_test/checkout_flow_test.dart
import 'package:integration_test/integration_test.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('user can complete checkout', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    await tester.tap(find.text('Flutter in Practice'));
    await tester.pumpAndSettle();

    await tester.tap(find.text('Add to cart'));
    await tester.pumpAndSettle();

    await tester.tap(find.byIcon(Icons.shopping_cart));
    await tester.pumpAndSettle();

    expect(find.text('1 item'), findsOneWidget);

    await tester.tap(find.text('Checkout'));
    await tester.pumpAndSettle();

    expect(find.text('Order confirmed'), findsOneWidget);
  });
}
Terminal
flutter test integration_test/checkout_flow_test.dart -d <device-id>

Keeping the suite trustworthy

  • Delete flaky tests or fix them immediately. A suite people ignore is worse than no suite, because it costs CI time and provides false confidence.
  • Never depend on real time. Use fakeAsync or inject a clock; Future.delayed in tests makes them slow and non-deterministic.
  • Keep tests independent. Shared mutable state between tests produces failures that depend on execution order.
  • Chase meaningful coverage, not a number. 100% coverage of getters proves nothing; covering every branch of your payment logic proves a lot.
Terminal
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html

Key takeaways

  • Many unit tests, solid widget tests, targeted goldens, few integration tests.
  • pumpAndSettle hangs on infinite animations — use pump(Duration(...)) there.
  • Hand-written fakes are usually clearer and more maintainable than heavy mocking.
  • Generate goldens on the same OS as CI, and fix flakes the day they appear.

Practice

Cover a feature properly

Pick one feature and write the full pyramid: unit tests for its validators and mappers, widget tests for loading/error/empty/data states, a golden for its main card component, and one integration test for the happy path. Then run coverage and check whether the uncovered lines matter.

Show hints
  • Write the error-state widget test first — it is the one usually missing.
  • If a widget is hard to test, it is usually doing too much; split it.