Testing
Unit, widget, golden, and integration tests: what each one is for, how to write them, and how to run them in CI.
Contents
- The three kinds
- Layout and setup
- Unit tests
- Test doubles
- Testing async code
- Widget tests
- Golden tests
- Integration tests
- Coverage
- Running tests well
- What to test
The three kinds
| Kind | Runs | Speed | Use for |
|---|---|---|---|
| Unit | Dart VM, no Flutter binding | Milliseconds | Logic, parsing, state transitions, repositories |
| Widget | Flutter test binding, no device | Tens of milliseconds | A widget or screen in isolation, including interaction |
| Integration | Real device or emulator | Seconds to minutes | Whole flows, plugins, real platform behaviour |
Write mostly unit tests, a solid layer of widget tests, and a small number of integration tests covering the flows you cannot afford to break.
Layout and setup
test/
├── unit/
│ ├── product_repository_test.dart
│ └── cart_model_test.dart
├── widget/
│ └── product_tile_test.dart
└── golden/
└── product_card_golden_test.dart
integration_test/
└── checkout_flow_test.dart
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
mocktail: <pinned version>
Test files must end in _test.dart or the runner will not pick them up.
Unit tests
import 'package:flutter_test/flutter_test.dart';
void main() {
group('CartModel', () {
late CartModel cart;
setUp(() => cart = CartModel());
test('starts empty', () {
expect(cart.items, isEmpty);
expect(cart.total, 0);
});
test('sums prices', () {
cart
..add(const Product(id: '1', name: 'A', price: 10.5))
..add(const Product(id: '2', name: 'B', price: 4.5));
expect(cart.items, hasLength(2));
expect(cart.total, closeTo(15.0, 0.001));
});
test('rejects duplicates', () {
const product = Product(id: '1', name: 'A', price: 1);
cart.add(product);
expect(() => cart.add(product), throwsA(isA<StateError>()));
});
});
}
Use closeTo for floating point. Useful matchers: equals, isA, isNull,
contains, hasLength, isEmpty, throwsA, predicate, same,
orderedEquals.
Test doubles
mocktail needs no code generation and no when on real types:
class MockProductApi extends Mock implements ProductApi {}
void main() {
late MockProductApi api;
late ProductRepository repository;
setUp(() {
api = MockProductApi();
repository = ProductRepository(api);
});
test('maps API failure to a domain failure', () async {
when(() => api.fetchProducts())
.thenThrow(const SocketException('offline'));
final result = await repository.loadProducts();
expect(result, isA<Err<List<Product>>>());
verify(() => api.fetchProducts()).called(1);
});
}
Register fallback values for any custom type used in an any() matcher:
setUpAll(() => registerFallbackValue(const Product(id: '', name: '', price: 0)));
Prefer a hand-written fake when the double has behaviour — a FakeProductApi
holding a list is clearer than five when calls, and does not break when the
interface changes.
Testing async code
test('emits loading then data', () {
expect(
bloc.stream,
emitsInOrder([
isA<SearchLoading>(),
isA<SearchResults>(),
]),
);
bloc.add(QueryChanged('espresso'));
});
fake_async gives control over time without waiting for it:
test('debounces input', () {
fakeAsync((async) {
controller.onQueryChanged('a');
controller.onQueryChanged('ab');
async.elapse(const Duration(milliseconds: 300));
verify(() => api.search('ab')).called(1);
});
});
Widget tests
testWidgets('tapping add puts the product in the cart', (tester) async {
final cart = CartModel();
await tester.pumpWidget(
MaterialApp(
home: ChangeNotifierProvider.value(
value: cart,
child: const ProductTile(product: _espresso),
),
),
);
expect(find.text('Espresso Machine'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add_shopping_cart));
await tester.pump(); // process the setState
expect(cart.items, hasLength(1));
expect(find.text('1'), findsOneWidget);
});
Key points:
- Always wrap the widget under test in whatever it needs from the tree —
MaterialApp(forDirectionality, theme, and localisations), providers, and aScaffoldif it uses one. pump()advances one frame.pump(duration)advances by that much.pumpAndSettle()pumps until no frames are scheduled — it hangs on an infinite animation, so prefer explicit pumps when one is running.- Finders:
find.text,find.byType,find.byKey,find.byIcon,find.widgetWithText,find.byWidgetPredicate. - Interactions:
tap,enterText,drag,fling,longPress, then pump.
Set a surface size when a layout depends on it:
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
Golden tests
Golden tests compare a rendered widget against a reference image, which catches visual regressions that assertions never would.
testWidgets('product card matches golden', (tester) async {
await tester.pumpWidget(const _TestApp(child: ProductCard(product: _espresso)));
await expectLater(
find.byType(ProductCard),
matchesGoldenFile('goldens/product_card.png'),
);
});
flutter test --update-goldens
Two things make or break golden tests:
- Fonts. The default test font renders as boxes. Load your real fonts in
flutter_test_config.dartso goldens are readable and stable. - Platform differences. Text rendering differs between operating systems, so
goldens generated on Windows will fail on a Linux CI runner. Pick one platform
for goldens — usually the CI one — and generate there, or use a package such
as
alchemistthat manages this.
Keep goldens for a handful of representative components, not for every screen; they are otherwise a maintenance tax that people learn to ignore.
Integration tests
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('checkout flow', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.tap(find.text('Espresso Machine'));
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('Total: 499.00'), findsOneWidget);
});
}
flutter test integration_test
flutter test integration_test/checkout_flow_test.dart -d <device-id>
These run against the real app on a real device, so plugins, permissions, and platform channels are exercised for real. They are slow and more fragile than widget tests — cover the two or three flows whose failure would be an incident, and no more.
Coverage
flutter test --coverage
Produces coverage/lcov.info. Render it locally with genhtml, or upload it in
CI.
Exclude generated files, or the number is meaningless:
lcov --remove coverage/lcov.info \
'*/*.g.dart' '*/*.freezed.dart' '*/generated/*' \
-o coverage/lcov.info
Treat coverage as a signal about untested areas, not a target. Ninety percent coverage of getters proves nothing; sixty percent that covers every state transition and error path is worth more.
Running tests well
flutter test # everything
flutter test test/unit # a directory
flutter test --name "sums prices" # by name
flutter test --tags golden
flutter test --test-randomize-ordering-seed random
flutter test --total-shards 4 --shard-index 0 # parallel CI
--test-randomize-ordering-seed random is worth enabling permanently: it
exposes tests that only pass because a previous test left state behind. Sharding
splits a slow suite across CI machines.
In CI, run the full gate:
flutter analyze
dart format --set-exit-if-changed .
flutter test --coverage
What to test
| Test | Skip |
|---|---|
| Business rules, pricing, validation | Framework behaviour — Flutter is already tested |
| State transitions, including error paths | Trivial getters and constructors |
| JSON parsing, especially optional and malformed fields | Generated code |
| Repository mapping from transport errors to domain failures | Third-party packages |
| Widgets with conditional rendering or interaction | Pure layout with no logic |
| The one or two flows that must never break | Every permutation of a screen |
Write the test that would have caught the last bug you shipped. That heuristic produces a better suite than any coverage target.
Next: Build and release.