Test-Driven Development (TDD) in Flutter
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.
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.
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.
Organising TDD Tests in Flutter Projects
Keep the following conventions when practising TDD in a Flutter project:
- Mirror your
lib/structure insidetest/: if you havelib/models/cart.dart, the test lives attest/models/cart_test.dart. - Use
setUp()andtearDown()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.