Test-Driven Development (TDD)
Test-Driven Development inverts the usual order: you write a failing test that describes the behaviour you want, then write the minimal code to make it pass, then clean up — repeating in tight loops of a few minutes each. The discipline isn't really about testing; it's about design. Writing the test first forces you to use your code before it exists, which surfaces awkward APIs and unclear requirements early. This page covers the red-green-refactor cycle, a worked example, the benefits and costs, and the honest answer to when TDD helps and when it doesn't.
The red-green-refactor cycle
┌─────────────────────────────────────────────┐ │ │ ▼ │ ┌──────┐ ┌────────┐ ┌──────────┐ │ │ RED │ ───▶ │ GREEN │ ───▶ │ REFACTOR │ ───────┘ └──────┘ └────────┘ └──────────┘ Write a Write the Improve the failing minimal code code while test for to make the tests stay the next test pass green — no new behaviour (even ugly) behaviour
Phase | Goal | Rule |
|---|---|---|
Red | Write a test that fails for the right reason | Run it — confirm it fails before writing code |
Green | Make the test pass as fast as possible | Write the simplest thing that works — even hard-coded |
Refactor | Remove duplication, clarify names, improve design | Change structure, not behaviour — tests stay green |
A worked example — building a discount calculator
Step 1 — RED: write the failing test first
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { applyDiscount } from './pricing.js' // doesn't exist yet!
test('applies a percentage discount', () => {
assert.equal(applyDiscount(100, 0.2), 80)
})✖ applies a percentage discount Error: Cannot find module './pricing.js' ← fails for the right reason
Step 2 — GREEN: the minimal code to pass
// pricing.js — simplest thing that works:
export function applyDiscount(price, rate) {
return price - price * rate
}✔ applies a percentage discount ← green
Step 3 — RED again: add the next behaviour (an edge case)
test('rejects a discount rate above 100%', () => {
assert.throws(() => applyDiscount(100, 1.5), /rate must be between 0 and 1/)
})Step 4 — GREEN: extend the code to satisfy both tests
export function applyDiscount(price, rate) {
if (rate < 0 || rate > 1) {
throw new RangeError('rate must be between 0 and 1')
}
return price - price * rate
}Refactoring under green
The tests are your safety net — restructure freely while they stay green
// Refactor: extract validation, no behaviour change.
function assertValidRate(rate) {
if (rate < 0 || rate > 1) {
throw new RangeError('rate must be between 0 and 1')
}
}
export function applyDiscount(price, rate) {
assertValidRate(rate)
return price - price * rate
}
// Run the suite — still green. The refactor is proven safe.Why write the test first?
Design pressure — using your API in a test before it exists exposes awkward signatures and hidden dependencies early, when they're cheap to change.
Built-in regression safety — every behaviour ships with a test, so the suite catches breakage the moment it happens.
Living documentation — the tests describe what the code is supposed to do, and they can't drift out of date like comments.
Forces small steps — you can't over-build; you only write code a failing test demands, avoiding speculative complexity (YAGNI).
Faster feedback — you find bugs seconds after writing the line, not days later in QA.
Better testability — code written test-first is naturally decoupled, because hard-to-test code is painful to write tests for first.
The costs and common objections
When TDD fits — and when it doesn't
TDD shines when… | TDD struggles when… |
|---|---|
Logic is well-defined (parsers, calculations, state machines) | Requirements are unknown — you're exploring/prototyping |
Bugs are expensive (payments, auth, data integrity) | The output is hard to assert (UI layout, visual design) |
You're fixing a bug — write a failing test that reproduces it first | You're spiking to learn an API and will throw the code away |
Refactoring legacy code — pin behaviour with tests, then change | The "unit" is mostly glue with no logic of its own |
Guidelines
Keep cycles short — minutes, not hours. If you're writing lots of code between green bars, take smaller steps.
One behaviour per test — a focused test names exactly what broke when it fails.
Test behaviour, not implementation — assert on outputs and effects, not private helpers.
Don't skip the refactor — green-then-move-on lets duplication and mess accumulate.
Watch the test fail first — a test you never saw red is a test you can't trust.
Be pragmatic — TDD is a tool, not a religion; apply it where logic is rich and bugs are costly.