TDD Is a Design Tool, Not a Testing Tool

Red-Green-Refactor isn't about getting test coverage up — it's about letting the test you write first push you toward code that's small, decoupled, and provably useful. The tests are a side effect; the design is the point. Teams that treat TDD as “test after, but earlier” miss the entire benefit.

🤔 Sound familiar?
  • You write tests after the code, just before the PR
  • Your classes have private methods you wish you could test directly
  • Refactoring feels dangerous because nothing pins down behavior
  • Test names describe what the code does (“callsRepoFindByIdOnce”) not what it should do

A 30-second Red, two-minute Green, and a five-minute Refactor cycle becomes muscle memory in a week and changes how you write code permanently.

The Cycle


flowchart LR
    R[🔴 Red<br/>write a failing test] --> G[🟢 Green<br/>simplest code to pass]
    G --> RF[🔵 Refactor<br/>improve design, tests still green]
    RF --> R
      
  1. Red. Write a test that describes the next small behavior. Run it. Watch it fail for the right reason (not a typo, an actual missing behavior).
  2. Green. Write the simplest code that makes the test pass. Hardcode if you have to. Don't solve future tests.
  3. Refactor. Improve names, eliminate duplication, simplify shapes. Tests stay green throughout.

Triangulation — When To Generalize

Kent Beck's rule: don't generalize from one example. With one test, hardcode. With two, you might see a pattern. With three, refactor to the general case. This keeps you from writing speculative abstractions.

// Test 1
it('sums one number', () => expect(sum([1])).toBe(1));
// Implementation: function sum(xs) { return 1; } ← hardcode!

// Test 2
it('sums two numbers', () => expect(sum([1, 2])).toBe(3));
// Now you must generalize
function sum(xs: number[]) { return xs[0] + (xs[1] ?? 0); }

// Test 3
it('sums many numbers', () => expect(sum([1, 2, 3])).toBe(6));
// Pattern is obvious; refactor
function sum(xs: number[]) { return xs.reduce((a, b) => a + b, 0); }

Outside-In TDD (London School)

Start at the system boundary — an HTTP endpoint, a UI action — and write a test that describes what the user wants. Use mocks for the collaborators that don't exist yet; each mock becomes a TODO list for the next layer down. Drive the system from the outside in, creating components only when a higher-level test demands them.

Inside-Out TDD (Chicago / Classicist School)

Start at a leaf domain object. Build it bottom-up with no mocks — real collaborators all the way down. The two styles aren't opposed; most teams use outside-in for new features and inside-out for pure domain logic.

BDD — Same Cycle, Different Vocabulary

Behavior-Driven Development reframes “test” as “behavior example” and uses Given/When/Then to align with product language. Whether you adopt Gherkin and Cucumber depends on whether your product folks actually write the scenarios. If only developers write them, plain RSpec/xUnit with descriptive names is simpler.

describe('Cart', () => {
  describe('when adding an item already in the cart', () => {
    it('increments its quantity instead of adding a duplicate row', () => {
      const cart = aCart().with({ sku: 'X', qty: 1 }).build();
      cart.add({ sku: 'X', qty: 2 });
      expect(cart.lines).toEqual([{ sku: 'X', qty: 3 }]);
    });
  });
});

Working With Legacy Code (Feathers)

TDD on a greenfield is easy. The real test is a 12-year-old codebase with 4% coverage. Michael Feathers' Working Effectively with Legacy Code gives you the moves:

  1. Characterization tests — pin down current behavior, even if it's weird. Don't fix bugs, just record what is.
  2. Sprout method / sprout class — write new behavior in a small testable unit; call it from the legacy code.
  3. Seams — find points where you can change behavior without editing in-place (constructor injection, parameterized statics).
  4. Extract and override — pull dependencies into virtual methods so a test subclass can stub them.

What TDD Won't Do

  • It won't teach you architecture. You still need to know “a controller shouldn't talk to a DB driver”.
  • It won't catch integration bugs — that's what integration tests are for.
  • It won't make a 200-line method good. TDD reveals the smell; you still need the courage to break the method up.
  • It won't replace design discussions — for complex domains, an hour at the whiteboard before TDD saves a day of refactoring.

Common Pushbacks and Honest Answers

ObjectionReality
“TDD is slower”Slower the first month, faster after that — fewer rework cycles, no debugger spelunking.
“I don't know what to test first”Start with the smallest observable behavior. Often the happy path with one input.
“UI / frontend isn't suitable for TDD”Component tests with Testing Library cover most of it. E2E for what they can't.
“Our domain is too complex”That's the case where TDD pays off the most. Examples force the ambiguity into the open.

A Sample Session

// RED — describe one tiny behavior
it('returns "FizzBuzz" for 15', () => {
  expect(fizzbuzz(15)).toBe('FizzBuzz');
});

// GREEN — simplest thing that works (yes, really)
const fizzbuzz = (n: number) => 'FizzBuzz';

// RED — drive out the next case
it('returns "Fizz" for 3', () => {
  expect(fizzbuzz(3)).toBe('Fizz');
});

// GREEN
const fizzbuzz = (n: number) => n === 3 ? 'Fizz' : 'FizzBuzz';

// RED
it('returns "Buzz" for 5', () => { ... });

// ... after enough tests, the pattern emerges; refactor
const fizzbuzz = (n: number) => {
  if (n % 15 === 0) return 'FizzBuzz';
  if (n % 3 === 0)  return 'Fizz';
  if (n % 5 === 0)  return 'Buzz';
  return String(n);
};

Pitfalls

Skipping the Refactor step

Without refactor, the code grows messy and the tests grow brittle around it. Refactor isn't optional — it's where TDD's design benefit actually happens.

Writing the test after the code “in your head”

If you already know the implementation, the test ends up shaped around it (coupled to private internals). Force yourself to start with the test and let the implementation surprise you.

Mock-heavy outside-in TDD without rolling down

Outside-in TDD creates mocks at every layer; each mock is a debt you owe to the next layer's real implementation. If you never roll down, you have a beautifully tested top layer over an untested mess.