Skip to main content

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

KindRunsSpeedUse for
UnitDart VM, no Flutter bindingMillisecondsLogic, parsing, state transitions, repositories
WidgetFlutter test binding, no deviceTens of millisecondsA widget or screen in isolation, including interaction
IntegrationReal device or emulatorSeconds to minutesWhole 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 (for Directionality, theme, and localisations), providers, and a Scaffold if 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.dart so 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 alchemist that 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

TestSkip
Business rules, pricing, validationFramework behaviour — Flutter is already tested
State transitions, including error pathsTrivial getters and constructors
JSON parsing, especially optional and malformed fieldsGenerated code
Repository mapping from transport errors to domain failuresThird-party packages
Widgets with conditional rendering or interactionPure layout with no logic
The one or two flows that must never breakEvery 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.