Intermediate7 min read

TDD in Practice

Red-Green-Refactor as a design tool, not a ceremony — with the failure modes to avoid.

Test-Driven Development is less about testing and more about design under pressure. Writing the test first forces you to use the code before it exists, which surfaces awkward APIs while they are still cheap to change.

The loop

  1. Red — write the smallest failing test for the next slice of behaviour. Watch it fail for the right reason.
  2. Green — write the least code that makes it pass. Hardcoding is allowed here on purpose.
  3. Refactor — with a green bar as your safety net, remove duplication and improve names. Run tests again.

Note: The discipline is in the order and the size of the steps. Small steps give fast feedback; the green bar between them is what makes refactoring safe.

A worked micro-example

// Red: the behaviour we want, before any implementation
import { describe, it, expect } from 'vitest';
import { fizzbuzz } from './fizzbuzz';

describe('fizzbuzz', () => {
  it('returns the number as a string by default', () => {
    expect(fizzbuzz(1)).toBe('1');
  });

  it('returns "Fizz" for multiples of 3', () => {
    expect(fizzbuzz(3)).toBe('Fizz');
  });
});
// Green: the least code that satisfies the current tests
export function fizzbuzz(n: number): string {
  if (n % 3 === 0) return 'Fizz';
  return String(n);
}
// Refactor only once a third test (multiples of 5, then 15) forces structure.

Common failure modes

  • Writing the whole implementation, then back-filling tests — that is test-after wearing a TDD badge.
  • Steps too big: a failing test that needs 50 lines to pass gives no design feedback.
  • Testing private helpers directly instead of the public behaviour that drove them.
  • Skipping the refactor step — green-on-green is where the design payoff lives.

Run the cycle yourself in the TDD Learning Lab: it walks you through Red-Green-Refactor on real exercises and runs your tests in an isolated Web Worker sandbox.

Practice this

Read next