Test-Driven Development: Why the Test Comes Before the Code
Test-driven development is a way of writing code where you write a failing test before you write the code that makes it pass, then clean up the result once it does. The cycle has a name: red, green, refactor. Red is a test that fails because the behavior does not exist yet. Green is the smallest change that makes it pass. Refactor is cleaning up the working code without changing what it does, protected by the test you just wrote.
The point is not discipline for its own sake. A test written after the code it tests can pass for the wrong reason: a typo in an assertion, a mock that never gets exercised, a condition that always evaluates true. You cannot tell a real check apart from a broken one by reading it, because both pass. Write the test first and watch it fail before you write the implementation, and you have proof the test can actually fail. Everything that comes after that is a test you can trust.
The red-green-refactor cycle
- Write a test for behavior that does not exist yet. Name it after what it checks, not how it checks it.
- Run it and watch it fail. If it passes immediately, the test is not checking what you think it is. Fix the test before writing any implementation.
- Write the minimum code to make it pass. Not the general solution, not the version that also handles three cases nobody asked for yet. Just enough to turn the test green.
- Run the full suite. The new test passes and nothing else broke.
- Refactor. Rename, extract, simplify, remove duplication. The tests stay green through every change, because a refactor by definition does not change behavior.
- Repeat for the next piece of behavior.
Step 3 is the one people skip. It feels slower to write a deliberately incomplete implementation and come back for the next case, but that is where the discipline earns its keep: each cycle stays small enough to reason about, and the test suite grows in lockstep with the code instead of getting bolted on afterward.
Free and open sourceCasely writes these cases for youAttach a spec and one file of your team's existing test cases in Claude. Casely copies your columns, names the gaps it found in the spec, and exports a single Excel file your tracker imports in one pass.Read the install docsA worked example
Say you're writing a function that applies a percentage discount to a price, and it should refuse a negative result.
| Step | Code | Result |
|---|---|---|
| Red | expect(applyDiscount(100, 10)).toBe(90) against a function that doesn't exist yet | Fails: not defined |
| Green | function applyDiscount(price, pct) { return price - (price * pct) / 100; } | Passes |
| Red | expect(applyDiscount(100, 150)).toBe(0) | Fails: returns -50 |
| Green | Add a floor: return Math.max(0, price - (price * pct) / 100); | Passes |
| Refactor | Extract the percentage math into a named discountAmount() helper, rerun both tests | Still passes |
Two tests, two behaviors, each one driving a specific line of the implementation. Nobody wrote a discount function and then tried to remember what to test against it. The tests were the spec, one case at a time.
Why the test comes first
A test suite exists to catch a regression: a change that breaks behavior someone relied on. That only works if the test would actually fail when the behavior breaks. Writing the test after the code tends to produce tests shaped by what the code already does, including its bugs, because the person writing the test is looking at a working implementation and describing it back rather than describing the requirement independently.
Writing the test first forces the opposite order. You state the expected behavior before you know how the implementation will satisfy it, then you get to watch the test fail for the right reason: the behavior genuinely does not exist yet, not because of a mistyped assertion or an import that silently resolved to undefined. That single red run is the only moment you get real evidence the test is wired correctly. Skip it and you're trusting a test you've never seen fail.
Where it fails
TDD is a design discipline for code with a testable seam, and not every piece of software offers one.
UI layout and visual behavior resist it. A test can assert that a button exists and that clicking it fires a handler, but it cannot assert that the spacing looks right or that an animation feels smooth. Teams that try to drive pixel-level CSS through TDD usually give up and fall back to visual review, correctly.
Exploratory or research code does not have a specification to write a test against yet. When you genuinely do not know what the function should do until you've tried three approaches, writing a test first means writing and rewriting the test three times, which is wasted motion. TDD earns its cost once the shape of the problem is known, not while you're still finding it.
Legacy code without seams makes the first test the hardest one. A function tangled into a global singleton or a database call with no interface to substitute cannot be tested in isolation without restructuring it first, and that restructuring itself needs a safety net TDD assumes already exists. Characterization tests, written against current behavior to pin it down before touching anything, come before TDD in that situation, not instead of it.
Testing implementation instead of behavior turns TDD against itself. A test that asserts a private method was called a specific number of times breaks on every refactor, even ones that change nothing a caller can observe. That kind of test punishes the exact cleanup step TDD is supposed to protect.
What TDD replaces, and what it doesn't
TDD is a developer discipline for unit-level code, driven by the person writing the implementation, one small increment at a time. It is not requirements-derived test case design. A QA suite built from user stories and acceptance criteria answers a different question: does the system satisfy what the business asked for, across the paths a real user takes? A green TDD suite proves the code does what its author intended. It says nothing about whether that intention matched the requirement, whether an edge case in the requirement was ever considered, or how the feature behaves once several of these units are wired together into a workflow.
The two disciplines catch different classes of bugs and neither substitutes for the other. Unit tests written test-first catch regressions in logic close to where it lives. A QA suite generated from requirements catches gaps between what was built and what was asked for. A team running TDD well still needs the second kind of coverage, and that is the part worth automating separately rather than folding into the same cycle: keeping unit tests and requirement-driven test cases in sync as both the code and the spec change is tedious to do by hand, and it is exactly the kind of tracing that tooling, not developer discipline, should carry.