A test written after the code tends to check what the code does, not what it was supposed to do. Test-driven development fixes that by flipping the order: write the test, watch it fail, then write the smallest piece of code that makes it pass. Because the test exists before the code it checks, it can’t quietly confirm whatever the code happens to return. Kent Beck formalized the loop as red, green, refactor in his 2002 book Test-Driven Development: By Example. The name stuck.
What the red-green-refactor cycle actually does
Each pass through the cycle has three steps, and skipping one turns it into something else.
Red. Write one test for a piece of behavior that doesn’t exist yet, then run it. It has to fail. If it passes immediately, either the behavior is already there or the test isn’t checking anything real. A red run is proof the test can catch the bug it’s meant to catch.
Green. Write the smallest amount of production code that makes the test pass. Not the best code. The least code. Hardcoding a return value is a legitimate green step, as long as you know that’s what you did and you don’t stop there.
Refactor. With the test passing, clean up what you just wrote: remove duplication, or extract a function if one is justified. The test has to stay green through all of it, because that’s how you know the behavior didn’t change, only the shape of the code.
Then pick the next piece of behavior and go again. One iteration takes minutes: one if branch, one edge case, never a whole feature.
A TDD example in Python: the FizzBuzz kata
FizzBuzz is small enough that you can watch the rhythm instead of the problem. Given a number, return "Fizz" if it’s divisible by 3, "Buzz" if divisible by 5, "FizzBuzz" if divisible by both, and the number itself otherwise.
Start with the case that is none of those:
| |
| |
Red, and you didn’t have to engineer the failure: fizzbuzz doesn’t exist yet, so pytest can’t even import the test module. That’s still a valid red — the fix is the same either way. The smallest thing that makes it pass:
| |
| |
Green. Next behavior: multiples of 3.
| |
That fails (assert '3' == 'Fizz'), and the minimal fix is one branch:
| |
Multiples of 5 follow the same shape, so add the test and the matching elif. Then write the case that catches carelessness in the first two steps: a number divisible by both.
| |
| |
15 % 3 == 0 is checked first, so the function returns "Fizz" before it ever looks at 15 % 5. Order matters here. The fix is a branch for 15 that runs before the other two:
| |
| |
Green again, and now the test file has something worth refactoring: four near-identical functions. Collapsing them into one parametrized test counts as a refactor just as much as anything you’d do to fizzbuzz.py, as long as green holds the whole time.
| |
The ordering bug only surfaced because the test for 15 existed before the fix. Write the code first and stop at “3 gives Fizz, 5 gives Buzz, looks done”, and fizzbuzz(15) returns "Fizz" with nothing in the suite to say otherwise.
Why test-first code ends up loosely coupled
TDD changes the design before any test runs, because you have to write the code so the test can call it. Take a class that builds its own database repository inside itself, the exact pattern What Is Dependency Injection opens with. You can’t hand it a fake in a test, because nothing in its signature lets you swap one in. Writing the test first pushes that dependency into a parameter or a constructor argument before the production code exists to resist it. You don’t add loose coupling afterward as a bonus. Without it, the test you’re about to write won’t run.
The same pressure pushes functions toward doing one thing. If a test has to set up three unrelated pieces of state to check one behavior, the function under test does three unrelated things. Split it, and the test gets shorter.
When not to use TDD
The cycle needs a few conditions: you know the behavior you want before you write it, the tests rerun in seconds, and the code has a seam where a test double can go in. Lose any of those and the cycle stops paying for itself. Even when they all hold, it has a blind spot.
You don’t know the shape of the thing yet. If you’re exploring an unfamiliar API or spiking an approach to see whether it’s feasible at all, write the throwaway code first. Learn what the right abstraction is, then write tests against the version you intend to keep. Tests for code you’re about to delete are effort spent twice.
The feedback loop is slow. TDD means rerunning tests after every small change. A suite that hits a real database or the network on every run turns each of those reruns into a wait. Unit vs Integration vs End-to-End Tests explains why unit-level speed comes from swapping those dependencies out. If the only tests you can write for a piece of code are slow integration tests, test-driving it means waiting, and people stop doing things that make them wait.
There’s no seam yet. Legacy code, with constructors that reach out to real infrastructure and functions calling each other directly, can’t be test-driven from where it stands. There’s nowhere to put a double. What Is Spaghetti Code covers the way in: a characterization test that pins the existing behavior first, written against the code as it is. Once that net exists, red-green-refactor works for the next change.
Over-mocking lets bad design through anyway. A test that mocks every collaborator can go green on a function that’s internally a mess, as long as its calls match what the mocks expect. TDD keeps your attention on the one unit in front of you. Every so often you still have to step back and ask whether the units are the right shape.
How to start with TDD: test-drive a bug fix
Pick a bug, not a feature, for your first attempt. Write a test that reproduces it: call the function with the input that breaks it, assert the output you actually want, and watch it fail for the right reason before you touch the fix. The fix becomes the green step, and the test stays in the suite so the bug can’t come back unnoticed. Commit once per pass. A trail of small, always-green commits is what makes git bisect fast the next time something breaks and nobody remembers where.