Flutter Unit Testing: A Practical Guide with Examples
From your first test() to mocking dependencies and testing async logic — a hands-on guide to unit testing Flutter and Dart code.
Published on • October 15, 2026
AI Assistant

Automated tests let you keep shipping features without breaking the ones that already work. Unit tests are the cheapest layer of that safety net — and they are the layer most teams skip, usually because mocking and async code feel like a rabbit hole. This guide walks the whole path, from your first test() to mocking dependencies and testing async logic.
The three layers, and where unit tests fit
Flutter splits automated testing into three categories:
- A unit test verifies a single function, method, or class.
- A widget test (called a component test in other frameworks) verifies a single widget in a lightweight widget environment.
- An integration test verifies a complete app, or a large part of one, usually on a real device or emulator.
The trade-offs matter more than the definitions:
| Trade-off | Unit | Widget | Integration |
|---|---|---|---|
| Confidence | Low | Higher | Highest |
| Maintenance cost | Low | Higher | Highest |
| Dependencies | Few | More | Most |
| Execution speed | Quick | Quick | Slow |
The recommended shape is many unit and widget tests tracked by code coverage, plus enough integration tests for the important use cases. You buy confidence in bulk at the cheap end of the table and spend it sparingly at the expensive end.
Your first unit test
Unit tests live in test/, conventionally mirroring lib/. flutter_test ships with the SDK and is already in dev_dependencies for a generated project.
Say you have a small cart domain in lib/cart.dart:
class CartItem {
const CartItem({required this.price, required this.quantity});
final double price;
final int quantity;
}
class Cart {
final List<CartItem> _items = [];
void add(CartItem item) => _items.add(item);
double get subtotal =>
_items.fold(0, (sum, item) => sum + item.price * item.quantity);
double totalWithTax({required double taxRate}) {
if (taxRate < 0) {
throw ArgumentError.value(taxRate, 'taxRate', 'must be >= 0');
}
return subtotal * (1 + taxRate);
}
}
And the corresponding test in test/cart_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:shop/cart.dart';
void main() {
group('Cart', () {
late Cart cart;
setUp(() {
cart = Cart();
});
test('starts empty', () {
expect(cart.subtotal, 0);
});
test('sums price multiplied by quantity across items', () {
cart
..add(const CartItem(price: 10, quantity: 2))
..add(const CartItem(price: 5, quantity: 1));
expect(cart.subtotal, 25);
});
test('applies the tax rate to the subtotal', () {
cart.add(const CartItem(price: 100, quantity: 1));
expect(cart.totalWithTax(taxRate: 0.1), closeTo(110, 0.001));
});
test('rejects a negative tax rate', () {
cart.add(const CartItem(price: 100, quantity: 1));
expect(
() => cart.totalWithTax(taxRate: -0.1),
throwsA(isA<ArgumentError>()),
);
});
});
}
Three conventions worth internalising early:
main()is the entry point. Every test file callstest()(orgroup()) from insidemain().group()is for organizing, not nesting semantics. It gives you a sharedsetUpscope and readable failure output.late+setUpbeats a constructor. Rebuilding the subject before each test guarantees no state leaks between cases.
Note the closeTo matcher: comparing floating-point money with plain equals is a classic source of flaky tests, and closeTo(value, epsilon) says what you actually mean.
Matchers you will actually use
expect(actual, matcher) accepts both raw values and matcher objects. The ones that earn their keep in day-to-day work:
equals,isTrue,isFalse,isNull,isNotNull,isA<T>()closeTo(value, epsilon)for doublescontainsfor collections and substringsthrowsA(matcher)for expected exceptionsgreaterThan,lessThan,inInclusiveRange
For exceptions, always pass a closure rather than an already-invoked expression:
// Wrong: throws before expect() runs, so the failure message is useless.
// expect(cart.totalWithTax(taxRate: -1), throwsA(isA<ArgumentError>()));
// Right: expect() invokes the closure and catches the throw.
expect(
() => cart.totalWithTax(taxRate: -1),
throwsA(isA<ArgumentError>()),
);
Testing async Dart code
Make the test callback async and await the result. Use expectLater when the unit throws, so the matcher gets a chance to settle:
test('throws when the gateway declines the charge', () async {
final gateway = FakePaymentGateway(approved: false);
await expectLater(
gateway.charge(amount: 100),
throwsA(isA<PaymentDeclinedException>()),
);
});
Streams get their own matchers — emits, emitsDone, emitsError — which work with expectLater:
test('emits a progress event before completing', () async {
final repo = DownloadRepository(FakeClient());
await expectLater(
repo.download('file.zip'),
emitsInOrder(<Object>[isA<ProgressEvent>(), emitsDone]),
);
});
The underlying rule is simple but frequently broken: an unawaited Future in a test is a test that hasn’t run yet. If you deliberately fire-and-forget, keep a handle on it and await it in the test.
Mocking dependencies
Unit tests should verify the correctness of a unit of logic under a variety of conditions. External dependencies get mocked out, and the test itself should not read or write to disk, render to the screen, or take input from outside the test process.
The precondition for easy mocking is that your code depends on interfaces, not concrete classes. Then pick your tooling:
- Hand-written fakes — a small class implementing the interface with in-memory behaviour. Best for most cases: no code generation, and the behaviour is obvious when you read the test.
- Mockito with code generation —
@GenerateMocks([Inventory])plusdart run build_runner build, giving you typedwhen/verify. - Mocktail — the same
when/verifyAPI without code generation and better null-safety ergonomics.
A representative mock-based test:
class MockInventory extends Mock implements Inventory {}
void main() {
late MockInventory inventory;
late CheckoutService service;
setUp(() {
inventory = MockInventory();
service = CheckoutService(inventory);
});
test('marks the order paid when stock is reserved', () async {
when(() => inventory.reserve('sku-1', 2)).thenAnswer((_) async => true);
await service.checkout(sku: 'sku-1', quantity: 2);
verify(() => inventory.reserve('sku-1', 2)).called(1);
});
test('does not persist the order when stock runs out', () async {
when(() => inventory.reserve(any(), any())).thenAnswer((_) async => false);
await expectLater(
service.checkout(sku: 'sku-x', quantity: 1),
throwsA(isA<OutOfStockException>()),
);
verify(() => inventory.reserve('sku-x', 1)).called(1);
verifyNever(() => inventory.markPaid(any()));
});
}
verifyNever is the assertion most people forget. Asserting that the right thing happened is only half of it; catching regressions in error paths means asserting that the wrong thing did not.
Running the suite
$ flutter test
$ flutter test test/cart_test.dart --plain-name "sums price"
$ flutter test --coverage
flutter test runs on the Dart VM inside a headless Flutter environment — no emulator, no device, which is exactly why it is fast enough to run on every save. --coverage writes an LCOV report to coverage/ for whatever tooling your CI uses. On CI, run the suite on every push; the Flutter docs maintain guides for Codemagic, Bitrise, Appcircle, Cirrus, and others.
Pitfalls and gotchas
Awaiting the wrong thing. service.checkout() without await lets the assertion run before the work completes, so the test passes for the wrong reason. When in doubt, await expectLater(...).
Testing the implementation, not the behaviour. If renaming a private field breaks twenty tests, those tests are asserting structure. Assert on outputs, state changes, and interactions with collaborators.
Floating-point equality. Use closeTo for anything involving money, rates, or geometry.
Over-mocking. Mocking the class under test, or mocking three layers deep, produces tests that describe your current implementation rather than your contract. Prefer a hand-written fake for anything with real logic, and reserve mocks for true boundaries — network, disk, platform channels.
Platform channels in unit tests. If your unit touches plugin code, the call will fail because no host is listening. Stub the channel or, better, inject an abstraction above it. Flutter documents this case specifically.
Timer and clock handling. Code driven by Timer, debounce, or polling will not settle on its own in a unit test. Inject the clock or drive it with a fake-async package rather than sleeping in the test.
Confusing unit and widget tests. If you are calling testWidgets, tester.pump(), or find.byType, you are writing a widget test — a different tier with different cost and confidence characteristics.
Where to go next
Unit tests cover logic cheaply but say nothing about layout, gestures, or navigation. The next stop is the widget testing recipes — finders, simulated scrolling, tap and drag — then integration tests for the critical journeys. Flutter’s architecture case study covers testing each layer of a layered app.