Skip to content
End To End Tester

Test-Driven Development

Red-green-refactor, what TDD actually changes about a codebase, where it fits badly, and the honest evidence for and against it.

3 min read · updated 19 September 2026

Write a failing test. Write the least code that passes it. Clean up. Repeat.

RED → GREEN → REFACTOR → RED → ...

The cycle is minutes long, not hours. That is the part people skip, and skipping it turns TDD into "writing tests, but in a more annoying order".

#The cycle, worked

Building a rate limiter. Start with the simplest true statement:

typescript
// RED — this does not compile, which counts as failing
it('allows the first request', () => {
  const limiter = new RateLimiter({ perMinute: 3 });

  expect(limiter.allow('alice')).toBe(true);
});
typescript
// GREEN — the least code that passes. Yes, really.
export class RateLimiter {
  constructor(private options: { perMinute: number }) {}
  allow(_key: string) { return true; }
}

Returning true unconditionally feels absurd, and it is the discipline working: it proves the test can pass, and it forces the next test to be the one that pins down the real behaviour.

typescript
// RED
it('refuses the fourth request in the same minute', () => {
  const limiter = new RateLimiter({ perMinute: 3 });

  limiter.allow('alice');
  limiter.allow('alice');
  limiter.allow('alice');

  expect(limiter.allow('alice')).toBe(false);
});
typescript
// GREEN
export class RateLimiter {
  private counts = new Map<string, number>();
  constructor(private options: { perMinute: number }) {}

  allow(key: string) {
    const used = this.counts.get(key) ?? 0;
    if (used >= this.options.perMinute) return false;
    this.counts.set(key, used + 1);
    return true;
  }
}

Now the third test forces the design decision that matters:

typescript
// RED — and here TDD earns its keep
it('forgets a request once its minute has passed', () => {
  const clock = new FakeClock(Date.parse('2026-01-01T12:00:00Z'));
  const limiter = new RateLimiter({ perMinute: 1 }, clock);

  limiter.allow('alice');
  clock.advance(61_000);

  expect(limiter.allow('alice')).toBe(true);
});

There is no way to write that test without the clock being injectable. TDD did not suggest dependency injection — it made the alternative impossible. That is the mechanism by which TDD changes design, and it is the whole argument for it.

#What it actually changes

Testability, by construction. Code written test-first is testable because untestable code cannot be written this way. Retrofitting tests onto a year-old codebase is a different and much harder job.

Unit size. Steps are small, so units end up small. This shows up in the research more reliably than defect reduction does.

Coverage that means something. Every line exists because a test demanded it, so coverage is high as a side effect rather than as a target — which is the only way the number is worth anything.

A finished definition of done. The test that drove the code is the specification of the code, and it is executable.

#Where it fits badly

Exploratory work. When you do not yet know what you are building, you cannot write the test for it. Spike first with no tests, learn the shape, throw the spike away, then TDD the real thing. The throwing away is not optional — a spike kept is a spike deployed.

Visual UI. TDD requires a cheap, unambiguous statement of "correct". Layout does not have one. Test the logic behind the view test-first and use snapshot or visual regression tooling for the pixels.

Algorithmic work with an emergent solution. Some problems are solved by thinking, and the test can only be written once you know the shape of the answer.

Integration-heavy glue. A function whose entire job is to call four APIs in order has no interesting unit test — its correctness is in the integration.

#The honest evidence

The strong claim — TDD substantially reduces defects — is not well supported. Meta-analyses find small and inconsistent effects, with considerable variation by team and context. The claims that hold up better are about design: smaller classes, fewer dependencies, higher cohesion, more tests.

Which means the best reason to do TDD is the one that is easiest to verify for yourself: it makes you notice a bad design within minutes rather than months. If you try it for a fortnight and your code does not get easier to test, it is not working for you and that is a legitimate finding.

#Common ways it goes wrong

Skipping refactor. The third step is where the design improves. Without it, TDD produces a working, well-tested mess — and the tests make the mess harder to fix, not easier.

Testing implementation. If the test asserts on how the code works rather than what it produces, every refactor breaks it and the third step becomes unaffordable. See unit testing with mocks.

Steps that are too big. Writing twelve tests and then the implementation is not TDD; it is test-first batch programming, and it loses the incremental design feedback entirely.

Treating it as a rule rather than a tool. Nobody TDDs a config file. Use it where the feedback is valuable and stop where it is not.

For the outside-in variant that starts from a business-readable scenario, see BDD.

Common questions

Does TDD actually reduce defects?
The research is mixed and the effect sizes are modest. What studies more consistently show is smaller units, lower coupling and higher test coverage — which is to say TDD reliably changes design, and changes defect rates only sometimes. That is still a good reason to do it, but it is a different claim from the one usually made for it.
Do I have to write the test first?
To get the design feedback, yes — that feedback is generated by the difficulty of writing the test against code that does not exist yet. To get a well-tested codebase, no. Both are legitimate goals and it helps to be clear about which one you are pursuing.
Does TDD work for UI code?
Poorly, for anything visual. TDD needs a cheap, unambiguous way to state the expected outcome, and "this looks right" is neither. It works well for the logic behind a UI — state machines, form validation, data transformation — which is usually where the bugs are anyway.

Runnable samples for this page

last test results ↗
  • TypeScripttypescript/src/practices/test-driven-development

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?