Patterns for Humans software concepts, explained simply
← All concepts
practice

Test-Driven Development

Write the failure first, so you know exactly when you've won.

The simple version

You write a test that fails before you write the code that makes it pass. Not because tests are sacred, but because a failing test proves you actually built something that was needed — instead of code you merely believe works.

The analogy

Imagine assembling furniture with no picture on the box. You just start screwing pieces together, hoping it resembles a shelf by the end. You’ll get something. Whether it’s the shelf you needed, you only find out at the very end — maybe after the walls are already painted around it.

Now imagine you first draw the shelf you want, with exact measurements, then start assembling. Every few minutes you can hold your progress against the drawing and know precisely: closer, or not.

The test is the drawing. TDD is drawing it before you pick up the screwdriver.

The bridge

TDD runs in a tight loop, usually called red, green, refactor:

  1. Red — write a small test for behavior that doesn’t exist yet. Run it. Watch it fail. (If it doesn’t fail, either the behavior already existed, or your test isn’t testing anything.)
  2. Green — write the minimum code to make that test pass. Not the elegant version. The fastest honest version.
  3. Refactor — now that a passing test has your back, clean the code up: rename things, remove duplication, restructure. Re-run the test after every change. It should stay green throughout.

The point of “red” isn’t ritual — it’s proof. A test you never watched fail might be passing for the wrong reason (a typo that makes it vacuously true, testing the wrong thing entirely). Watching it fail first is how you know the test is actually connected to the code.

In code

// 1. RED — write the test first. It fails: discount() doesn't exist yet.
test("10% discount applies to orders over $100", () => {
  expect(discount(150)).toBe(15);
});

// 2. GREEN — the dumbest possible implementation that passes
function discount(amount: number): number {
  return 15;
}

// ...add a second test that exposes the hardcoding:
test("no discount below $100", () => {
  expect(discount(50)).toBe(0);
});
// now the hardcoded `return 15` fails this one — forced to write real logic:

function discount(amount: number): number {
  return amount > 100 ? amount * 0.1 : 0;
}

// 3. REFACTOR — extract the threshold, rename for clarity, tests still green
const DISCOUNT_THRESHOLD = 100;
const DISCOUNT_RATE = 0.1;

function discount(orderTotal: number): number {
  return orderTotal > DISCOUNT_THRESHOLD ? orderTotal * DISCOUNT_RATE : 0;
}

Notice the tests never changed once written — only the implementation moved underneath them. That’s the actual payoff: the tests become a safety net for every refactor that comes after, not just a one-time check.

When you don’t need it

TDD shines when the desired behavior is knowable in advance — business rules, edge cases, calculations. It’s a poor fit for pure exploration: throwaway spikes, unfamiliar APIs you’re still probing, or UI you’re still deciding the shape of by eye. In those cases, explore first, then write tests once you know what “correct” even means — sometimes called “test-after,” and that’s fine too.