TDD in Flutter: Test-Driven Development Workflow with Widget and Unit Tests
TDD in Flutter means writing a failing test first, then writing just enough code to make it pass, then refactoring. The same red-green-refactor loop works for pure Dart unit tests, Flutter widget tests, and state management layers. This tutorial walks through building a real feature with TDD from scratch.
Key Takeaways
Write the test before you write the widget. Describe what you want — then implement it.
Start with unit tests for business logic, then widget tests for UI. The unit tests run fast and drive your domain model design. Widget tests confirm the UI wires up correctly.
One failing test at a time. Don't write 10 tests and then implement. Write one, make it pass, write the next.
pumpAndSettle() is your async boundary in widget tests. After any async state change, call it to let the widget tree settle before asserting.
Refactor only on green. Never refactor when tests are failing — you won't know if your refactor broke something new or something already broken.
The Red-Green-Refactor Cycle
🔴 RED → Write a failing test
🟢 GREEN → Write minimum code to pass
🔵 REFACTOR → Clean up, tests still greenRepeat for every new behavior. The test is the spec — if it doesn't have a test, the behavior doesn't exist.
Feature: Shopping Cart
We'll build a shopping cart using TDD. Each behavior gets a failing test before any implementation.
Step 1: Start With the Domain Model
RED: Write the first test
// test/cart_test.dart
import 'package:test/test.dart';
void main() {
group('Cart', () {
test('is empty on creation', () {
final cart = Cart();
expect(cart.items, isEmpty);
expect(cart.total, 0.0);
});
});
}Run it:
flutter test test/cart_test.dartOutput: Error: Could not find 'Cart' — this is correct. Now implement.
GREEN: Minimum implementation
// lib/cart.dart
class Cart {
final List<CartItem> items = [];
double get total => 0.0;
}
class CartItem {
final String name;
final double price;
CartItem({required this.name, required this.price});
}Test passes. Now add the next behavior.
RED: Add item test
test('addItem increases item count', () {
final cart = Cart();
cart.addItem(CartItem(name: 'Book', price: 12.99));
expect(cart.items.length, 1);
});
test('total reflects added items', () {
final cart = Cart();
cart.addItem(CartItem(name: 'Book', price: 12.99));
cart.addItem(CartItem(name: 'Pen', price: 1.99));
expect(cart.total, closeTo(14.98, 0.01));
});GREEN: Implement addItem
class Cart {
final List<CartItem> _items = [];
List<CartItem> get items => List.unmodifiable(_items);
double get total => _items.fold(0.0, (sum, item) => sum + item.price);
void addItem(CartItem item) => _items.add(item);
}RED: Remove item test
test('removeItem removes the correct item', () {
final cart = Cart();
final book = CartItem(name: 'Book', price: 12.99);
final pen = CartItem(name: 'Pen', price: 1.99);
cart.addItem(book);
cart.addItem(pen);
cart.removeItem(book);
expect(cart.items.length, 1);
expect(cart.items.first.name, 'Pen');
});
test('removeItem on empty cart throws', () {
final cart = Cart();
expect(
() => cart.removeItem(CartItem(name: 'X', price: 1.0)),
throwsA(isA<StateError>()),
);
});GREEN:
void removeItem(CartItem item) {
if (!_items.contains(item)) {
throw StateError('Item not in cart');
}
_items.remove(item);
}All domain tests pass. Now move to the UI layer.
Step 2: Widget Tests for Cart UI
RED: Widget test for empty cart
// test/cart_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/cart.dart';
import 'package:my_app/cart_page.dart';
void main() {
group('CartPage', () {
testWidgets('shows empty message when cart is empty', (tester) async {
await tester.pumpWidget(
MaterialApp(home: CartPage(cart: Cart())),
);
expect(find.text('Your cart is empty'), findsOneWidget);
expect(find.byType(ListTile), findsNothing);
});
});
}CartPage doesn't exist yet — test fails. Implement:
GREEN:
// lib/cart_page.dart
import 'package:flutter/material.dart';
import 'cart.dart';
class CartPage extends StatelessWidget {
final Cart cart;
const CartPage({super.key, required this.cart});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Cart')),
body: cart.items.isEmpty
? const Center(child: Text('Your cart is empty'))
: ListView.builder(
itemCount: cart.items.length,
itemBuilder: (_, i) => ListTile(
title: Text(cart.items[i].name),
trailing: Text('\$${cart.items[i].price.toStringAsFixed(2)}'),
),
),
);
}
}RED: Items are displayed
testWidgets('shows item name and price', (tester) async {
final cart = Cart()
..addItem(CartItem(name: 'Book', price: 12.99))
..addItem(CartItem(name: 'Pen', price: 1.99));
await tester.pumpWidget(
MaterialApp(home: CartPage(cart: cart)),
);
expect(find.text('Book'), findsOneWidget);
expect(find.text('\$12.99'), findsOneWidget);
expect(find.text('Pen'), findsOneWidget);
expect(find.byType(ListTile), findsNWidgets(2));
});Test passes without changes — the implementation already handles this. Move on.
Step 3: TDD With State Management (Riverpod)
For a stateful cart that updates when items are added:
RED: Provider test
// test/cart_provider_test.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/cart_provider.dart';
void main() {
group('cartProvider', () {
test('starts empty', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(cartProvider).items, isEmpty);
});
test('addItem updates state', () {
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(cartProvider.notifier).addItem(
CartItem(name: 'Widget', price: 9.99),
);
expect(container.read(cartProvider).items.length, 1);
});
});
}GREEN: Implement the provider
// lib/cart_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'cart.dart';
class CartNotifier extends Notifier<Cart> {
@override
Cart build() => Cart();
void addItem(CartItem item) {
state = Cart()
..addAll([...state.items, item]);
}
}
final cartProvider = NotifierProvider<CartNotifier, Cart>(CartNotifier.new);RED: Widget test with Riverpod
testWidgets('UI updates when item is added', (tester) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(home: const CartPageWithProvider()),
),
);
expect(find.text('Your cart is empty'), findsOneWidget);
// Tap Add to Cart (simulated from outside)
// In real app: navigate from ProductPage and tap button
await tester.pumpAndSettle();
});Step 4: Async TDD
Features that load data from an API:
RED: Loading state
testWidgets('shows loading indicator while fetching', (tester) async {
final fakeApi = FakeProductApi(delay: const Duration(seconds: 1));
await tester.pumpWidget(
ProviderScope(
overrides: [productApiProvider.overrideWithValue(fakeApi)],
child: const MaterialApp(home: ProductListPage()),
),
);
// Before settling — loading state
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pumpAndSettle();
// After settling — data loaded
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.byType(ListTile), findsWidgets);
});RED: Error state
testWidgets('shows error message on failure', (tester) async {
final failingApi = FailingProductApi();
await tester.pumpWidget(
ProviderScope(
overrides: [productApiProvider.overrideWithValue(failingApi)],
child: const MaterialApp(home: ProductListPage()),
),
);
await tester.pumpAndSettle();
expect(find.text('Failed to load products'), findsOneWidget);
expect(find.byIcon(Icons.refresh), findsOneWidget);
});Write these tests, watch them fail, then implement the loading/error states in ProductListPage.
TDD Workflow Commands
# Watch mode — re-run on file change (using fvm or just flutter test --watch)
flutter test --watch
# Run specific test
flutter test test/cart_test.dart
# Run specific test by name
flutter test --name "addItem"
# Run all unit tests (fast)
flutter test test/
# Run widget tests
flutter test test/*_page_test.dart
# Check coverage
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.htmlTDD Anti-Patterns in Flutter
Writing tests after the code. You'll unconsciously test what you implemented, not what you intended. Tests become documentation of bugs, not specifications.
Giant tests with many steps. One test = one behavior. Long tests with 20 lines of setup hide which behavior actually failed.
Mocking too much. If your test requires 5 mocks, your class has too many dependencies. Let TDD drive better design — if it's hard to test, it's probably hard to maintain.
pumpAndSettle timeouts. If pumpAndSettle keeps timing out, you likely have an animation or Timer that never completes. Use pump(Duration) to manually advance the clock.
Skipping the refactor step. Green tests are permission to refactor. If you never refactor after going green, you accumulate technical debt test by test.
The TDD Mindset
TDD isn't just a technique — it changes how you think about features:
- You define behavior before implementation. What does "add to cart" mean? Define it in a test first.
- You get instant feedback. Every test run tells you exactly what works and what doesn't.
- You build confidence to refactor. Green tests mean you can restructure freely.
- You design better APIs. If your class is hard to test, it's telling you something about the design.
Start small. Pick one new feature, write one failing test, make it pass. The habit builds naturally from there.