Testing Flutter Applications

Test-Driven Development (TDD) in Flutter

16 min Lesson 12 of 12

Test-Driven Development (TDD) in Flutter

Test-Driven Development (TDD) is a software development discipline where you write a failing test before you write any production code. Only once a test exists and fails for the right reason do you write the minimum code to make it pass — and then refactor freely, protected by the test. This cycle is known as Red → Green → Refactor, and it is one of the most reliable techniques for building correct, maintainable Flutter features.

Why TDD? TDD forces you to think about the contract of your code before its implementation. The result is smaller, more focused units, fewer bugs reaching production, and a test suite that grows organically alongside the feature itself.

The Red-Green-Refactor Cycle

Every TDD iteration follows exactly three steps:

  • Red — Write a test that describes the desired behaviour. Run it. It must fail (the feature does not exist yet). A test that passes immediately without any code is not a valid TDD test.
  • Green — Write the minimum production code required to make the failing test pass. Do not over-engineer. Ugly code is fine here; correctness is the only goal.
  • Refactor — Clean up the production code (and, if needed, the test) without changing observable behaviour. The green test suite acts as a safety net: if it turns red again, you broke something.
Tip: Keep each red-green-refactor loop very small — ideally under five minutes. Long cycles are a sign that the requirement was too large; split it into smaller, independently testable slices.

Building a Feature with TDD: A Complete Walkthrough

We will build a simple Counter class — not a widget — from scratch using TDD. Plain Dart unit tests run instantly and give the fastest feedback loop, making them ideal for learning the cycle.

Step 1 — Red: Create the test file first. The class does not exist yet, so the test will not even compile, which counts as failing.

test/counter_test.dart — first failing test

import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/counter.dart'; // does not exist yet

void main() {
  group('Counter', () {
    test('starts at zero', () {
      final counter = Counter();
      expect(counter.value, equals(0));
    });
  });
}

Run flutter test. It fails: "Target of URI doesn't exist: 'counter.dart'". That is the red phase — the test correctly reports a missing behaviour.

Step 2 — Green: Write just enough production code to satisfy the test.

lib/counter.dart — minimum passing implementation

class Counter {
  int value = 0;
}

Run flutter test again. The test passes. We are green.

Step 3 — Refactor: The class is trivial so far, but as we add more tests, there will be opportunities to improve naming, encapsulation, and structure. Let us add two more behaviours, each driven by a failing test first.

test/counter_test.dart — adding increment and decrement

import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/counter.dart';

void main() {
  group('Counter', () {
    late Counter counter;

    setUp(() {
      counter = Counter(); // fresh instance for every test
    });

    test('starts at zero', () {
      expect(counter.value, equals(0));
    });

    test('increment increases value by 1', () {
      counter.increment();
      expect(counter.value, equals(1));
    });

    test('decrement decreases value by 1', () {
      counter.increment();
      counter.decrement();
      expect(counter.value, equals(0));
    });

    test('value cannot go below zero', () {
      counter.decrement(); // already at 0
      expect(counter.value, equals(0)); // must stay at 0
    });
  });
}

Run the tests after adding each test one at a time. After adding increment test: red. Implement increment(): green. After adding decrement test: red. Implement decrement(): green. After adding the floor test: red. Add the guard: green. Finally, refactor:

lib/counter.dart — after full TDD cycle and refactor

class Counter {
  int _value = 0; // private after refactor

  int get value => _value;

  void increment() => _value++;

  void decrement() {
    if (_value > 0) _value--;
  }
}

All four tests remain green after the refactor. The field is now properly encapsulated with a getter, and decrement enforces the business rule that the counter cannot go negative. We achieved this safely because the tests caught any regressions instantly.

TDD for Widgets with WidgetTester

The same cycle applies to Flutter widget tests. Write a test that pumps a widget and asserts on the rendered output, watch it fail, implement the widget, go green, then refactor the widget without fear.

Common mistake: Writing all tests first and then implementing everything at once is not TDD — it is test-after with a twist. TDD strictly means one failing test, then the minimum code, then the next test. One test at a time.

Organising TDD Tests in Flutter Projects

Keep the following conventions when practising TDD in a Flutter project:

  • Mirror your lib/ structure inside test/: if you have lib/models/cart.dart, the test lives at test/models/cart_test.dart.
  • Use setUp() and tearDown() for shared setup rather than duplicating construction in every test.
  • Name each test as a sentence: "returns empty list when no items are added". Good names are documentation.
  • One assertion per test is a good default; a test that checks many unrelated things is hard to diagnose when it fails.

Summary

TDD in Flutter is not about having tests — every professional project should have tests. It is about letting the tests drive the design. By writing the test first you commit to a public API before writing a line of implementation. The Red-Green-Refactor loop keeps changes small and safe, the growing test suite documents intent, and the refactor phase keeps the codebase clean without risk. Start small: pick one class or one widget, write one failing test, make it pass, and repeat.