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.
| Type | Speed | Catches | Write these for |
|---|---|---|---|
| Unit | ~1ms | Logic errors, edge cases | Validators, mappers, calculations, controllers |
| Widget | ~50ms | Wiring, rendering, interaction | Screens, forms, custom widgets |
| Golden | ~100ms | Unintended visual change | Design-system components, themed widgets |
| Integration | seconds | Real end-to-end failures | Critical 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
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
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);
});
}// 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
// 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.
TestWidgetsFlutterBinding.ensureInitialized();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/test',
);Golden tests
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'),
);
});# 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 goldenIntegration tests
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);
});
}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
fakeAsyncor inject a clock;Future.delayedin 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.
flutter test --coverage
genhtml coverage/lcov.info -o coverage/htmlKey takeaways
- Many unit tests, solid widget tests, targeted goldens, few integration tests.
pumpAndSettlehangs on infinite animations — usepump(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.