← Articles

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.

Test-driven development in Flutter — ANN Tech