Test-driven development in Flutter
By John · 4 March 2025
Test-driven development (TDD) means writing a failing test before writing the implementation. The cycle is red → green → refactor: write a test that fails, write the minimum code to make it pass, then clean up. In Flutter, this discipline produces code that's easy to test because it was designed to be tested from the start.
Why TDD in Flutter
Flutter's widget and unit testing infrastructure makes TDD practical. The benefits:
- Business logic is testable by design (no hidden coupling to UI or platform)
- Refactoring is safe because tests catch regressions
- Edge cases are handled upfront (you write tests for them before you write the code)
- Documentation is always up-to-date (tests show intended behaviour)
The red-green-refactor cycle
Example: cart total calculation
Step 1: Write the failing test (Red)
// test/cart/cart_test.dart
test('calculates total with no discount', () {
final cart = Cart(items: [
CartItem(productId: 'p1', quantity: 2, unitPrice: 10.0),
CartItem(productId: 'p2', quantity: 1, unitPrice: 25.0),
]);
expect(cart.total, 45.0);
});
test('applies percentage discount', () {
final cart = Cart(
items: [CartItem(productId: 'p1', quantity: 1, unitPrice: 100.0)],
discountPercent: 20,
);
expect(cart.total, 80.0);
});
test('total is zero for empty cart', () {
expect(const Cart(items: []).total, 0.0);
});
Run the tests — they fail because Cart doesn't exist yet.
Step 2: Write minimum code (Green)
// lib/cart/cart.dart
class Cart {
const Cart({
required this.items,
this.discountPercent = 0,
});
final List<CartItem> items;
final int discountPercent;
double get total {
final subtotal = items.fold(
0.0,
(sum, item) => sum + item.quantity * item.unitPrice,
);
return subtotal * (1 - discountPercent / 100);
}
}
All three tests pass. Now refactor if needed.
Step 3: Refactor (still Green)
Add edge cases, improve readability, extract helpers — but run tests after each change to stay green.
TDD for BLoC
// Write tests first
group('OrdersBloc', () {
late OrdersBloc bloc;
late MockOrderRepository mockRepo;
setUp(() {
mockRepo = MockOrderRepository();
bloc = OrdersBloc(mockRepo);
});
tearDown(() => bloc.close());
test('initial state is OrdersInitial', () {
expect(bloc.state, const OrdersInitial());
});
blocTest<OrdersBloc, OrdersState>(
'emits [Loading, Loaded] when LoadOrders succeeds',
build: () {
when(() => mockRepo.getOrders()).thenAnswer((_) async => [order1, order2]);
return OrdersBloc(mockRepo);
},
act: (bloc) => bloc.add(const LoadOrders()),
expect: () => [
const OrdersLoading(),
OrdersLoaded(orders: [order1, order2]),
],
);
blocTest<OrdersBloc, OrdersState>(
'emits [Loading, Error] when LoadOrders fails',
build: () {
when(() => mockRepo.getOrders()).thenThrow(Exception('Network error'));
return OrdersBloc(mockRepo);
},
act: (bloc) => bloc.add(const LoadOrders()),
expect: () => [
const OrdersLoading(),
const OrdersError('Network error'),
],
);
});
Then implement OrdersBloc to make these tests pass.
TDD for repositories
group('OrderRepository', () {
late MockFirestore mockFirestore;
late OrderRepository repo;
setUp(() {
mockFirestore = MockFirestore();
repo = FirestoreOrderRepository(firestore: mockFirestore);
});
test('getOrders returns list of orders', () async {
// Arrange
when(() => mockFirestore.collection('orders').get()).thenAnswer(
(_) async => FakeQuerySnapshot(docs: [fakeOrderDoc]),
);
// Act
final orders = await repo.getOrders();
// Assert
expect(orders.length, 1);
expect(orders.first.id, 'order-1');
});
});
Widget TDD
// Test UI behaviour before implementing
testWidgets('shows empty state when cart has no items', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
cartProvider.overrideWith((ref) => const Cart(items: [])),
],
child: const MaterialApp(home: CartScreen()),
),
);
expect(find.text('Your cart is empty'), findsOneWidget);
expect(find.text('Add items to get started'), findsOneWidget);
expect(find.byType(CartItemCard), findsNothing);
});
testWidgets('shows items when cart is not empty', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
cartProvider.overrideWith((ref) => Cart(items: [item1, item2])),
],
child: const MaterialApp(home: CartScreen()),
),
);
expect(find.byType(CartItemCard), findsNWidgets(2));
expect(find.text('\$45.00'), findsOneWidget); // Total
});
What to test first
Not everything needs TDD. Apply it where it has the highest ROI:
- Business logic (pricing, validation, state machines)
- BLoC event handlers
- Repository data mapping
- Complex conditional UI
Skip TDD for:
- Trivial pass-through code
- UI that requires visual review
- Glue code that just wires things together
Running tests
# Run all tests
flutter test
# Run tests in a specific file
flutter test test/cart/cart_test.dart
# Run tests matching a pattern
flutter test --name='calculates total'
# Watch mode (re-run on save)
flutter test --watch
Common pitfalls
Writing tests after the code. Tests written after implementation tend to test what the code does, not what it should do. Writing tests first forces you to think about the API and edge cases.
Testing implementation, not behaviour. If your test breaks when you rename an internal variable, you're testing implementation. Tests should describe behaviour: "given X, when Y happens, then Z is the result."
100% coverage as the goal. Coverage measures lines executed, not scenarios tested. A test that calls every line once but doesn't assert anything is worse than no test. Focus on testing meaningful behaviour.
Sign in to like, dislike, or report.