Lesson 42 of 54

Testing and Test-Driven Development

The Three Laws of TDD

I used to write code first, then add tests. The tests always felt like homework—something to get through before the PR. Then I tried writing the test first, and something shifted. The code came out simpler. The design emerged from the requirements instead of my assumptions.

Test-Driven Development isn't about testing. It's about design.

The Red-Green-Refactor Cycle

TDD has one loop:

  1. Red — Write a failing test. See it fail for the right reason.
  2. Green — Write the minimal code to make it pass. No gold-plating.
  3. Refactor — Clean up. Tests stay green.

Repeat.

The cycle is short. One behavior at a time. You're never more than a few minutes from a green suite.

The Three Laws

Robert C. Martin formalized TDD as three laws:

Law 1: You may not write production code until you have written a failing unit test.

No code without a test. The test defines what you're building.

Law 2: You may not write more of a unit test than is sufficient to fail.

Don't write a massive test. Write the smallest test that would fail if the behavior doesn't exist.

Law 3: You may not write more production code than is sufficient to pass the currently failing test.

No extra features. No "while I'm here" improvements. Just enough to turn red to green.

These laws feel restrictive. They're meant to be. They force you to think in small steps.

A Practical TDD Workflow

Let's build a calculateDiscount function for an e-commerce cart.

Step 1: Red

Typescript
describe('calculateDiscount', () => {
  it('returns 0 when cart total is below threshold', () => {
    const cart = { total: 50, itemCount: 2 };
    expect(calculateDiscount(cart)).toBe(0);
  });
});

Run the test. It fails—calculateDiscount doesn't exist. Good. We're red.

Step 2: Green

Typescript
function calculateDiscount(cart: { total: number; itemCount: number }): number {
  return 0;
}

Test passes. We've written the minimal code. No logic yet—just enough to pass.

Step 3: Refactor

Nothing to refactor yet. Move on.

Step 4: Red (next behavior)

Typescript
it('returns 10% when cart total exceeds 100', () => {
  const cart = { total: 150, itemCount: 3 };
  expect(calculateDiscount(cart)).toBe(15);
});

Fails. Cart total is 150, 10% is 15. We're testing the right thing.

Step 5: Green

Typescript
function calculateDiscount(cart: { total: number; itemCount: number }): number {
  if (cart.total >= 100) {
    return cart.total * 0.1;
  }
  return 0;
}

Passes. Minimal.

Step 6: Red (edge case)

Typescript
it('returns 0 when cart total is exactly 99', () => {
  const cart = { total: 99, itemCount: 5 };
  expect(calculateDiscount(cart)).toBe(0);
});

Already passes with our implementation. Good—we didn't over-engineer the boundary.

Step 7: Red (new rule)

Typescript
it('adds 5% bonus when cart has 5 or more items', () => {
  const cart = { total: 150, itemCount: 5 };
  // 10% base + 5% bonus = 15% = 22.50
  expect(calculateDiscount(cart)).toBe(22.5);
});

Fails. Now we add the logic.

Step 8: Green

Typescript
function calculateDiscount(cart: { total: number; itemCount: number }): number {
  let discountPercent = 0;
  if (cart.total >= 100) discountPercent += 10;
  if (cart.itemCount >= 5) discountPercent += 5;
  return cart.total * (discountPercent / 100);
}

Each step was small. Each test drove one behavior. The design emerged.

When TDD Helps

Greenfield code. You're building something new. TDD gives you a clear path.

Unclear requirements. Writing the test forces you to specify the behavior. "What should happen when the user doesn't exist?" The test answers that.

Complex logic. Discount rules, tax calculations, validation—these benefit from incremental test-first development.

APIs and interfaces. Testing from the outside in helps you design usable APIs. You feel the pain of a bad API when you write the test.

When TDD Doesn't Help (As Much)

Exploratory spikes. You're figuring out if something is possible. Write code, throw it away, learn. Tests add friction.

UI and visual work. Testing pixel-perfect layouts is hard. TDD shines on logic, not CSS.

Trivial code. A one-liner getter doesn't need a test-first cycle. Use judgment.

Legacy code with no tests. You need characterization tests first. TDD assumes you're building from scratch.

Common TDD Mistakes

1. Writing Too Many Tests at Once

You write five tests, then implement. You've broken the cycle. You're back to "code then test."

One test. One behavior. Implement. Repeat.

2. Gold-Plating in the Green Phase

The test passes. You think, "I'll add input validation while I'm here." Don't. Add a new test for validation. Then implement.

3. Skipping the Red Phase

You write a test that passes immediately because you wrote the implementation first. You never saw it fail. You don't know the test actually verifies anything.

Always see red first.

4. Testing Implementation Instead of Behavior

Typescript
// Bad: testing that we call a specific method
it('calls validateUser internally', () => {
  const spy = jest.spyOn(validator, 'validateUser');
  processOrder(order);
  expect(spy).toHaveBeenCalled();
});

// Good: testing the outcome
it('throws when user is invalid', () => {
  const invalidOrder = { ...order, userId: 'invalid' };
  expect(() => processOrder(invalidOrder)).toThrow('Invalid user');
});

5. Giant Steps

You write a test for the entire checkout flow. It fails. You write 200 lines to make it pass. You've lost the benefit of small steps.

Break it down. Test "calculates tax" before "processes full checkout."

The Trade-off

TDD is slower at first. You write tests before code. You run the cycle many times.

But you get:

  • Design feedback — Bad APIs hurt when you write the test
  • No over-engineering — You only build what the tests demand
  • Confidence — Every line was written to satisfy a failing test
  • Living documentation — Tests show how the system behaves

For greenfield logic, the upfront cost pays off. For throwaway scripts, skip it.


Key insight: TDD is a design discipline, not a testing technique. Red-Green-Refactor in small steps. Write the minimal failing test, then the minimal passing code. Use it when requirements are unclear or logic is complex. Skip it for spikes, UI, and trivial code.