TDD isn’t about testing — it’s about design. By writing tests first, you design your API from the consumer’s perspective. Let’s build a shopping cart feature with TDD, step by step.

The Red-Green-Refactor Cycle

  1. 🔴 Red: Write a failing test for the next small behavior
  2. 🟢 Green: Write the simplest code to make it pass
  3. 🔵 Refactor: Clean up without changing behavior

Building a Shopping Cart

Iteration 1: Empty Cart

// 🔴 RED — Write the test first
describe('ShoppingCart', () => {
  test('new cart has zero items', () => {
    const cart = new ShoppingCart();
    expect(cart.itemCount).toBe(0);
  });

  test('new cart has zero total', () => {
    const cart = new ShoppingCart();
    expect(cart.total).toBe(0);
  });
});
// 🟢 GREEN — Simplest code to pass
class ShoppingCart {
  get itemCount(): number { return 0; }
  get total(): number { return 0; }
}

Iteration 2: Add Items

// 🔴 RED
test('adding an item increases count', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 'p1', name: 'Widget', price: 9.99, quantity: 1 });
  expect(cart.itemCount).toBe(1);
});

test('total reflects added item price', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 'p1', name: 'Widget', price: 9.99, quantity: 2 });
  expect(cart.total).toBe(19.98);
});
// 🟢 GREEN
interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

class ShoppingCart {
  private items: CartItem[] = [];

  addItem(item: CartItem): void {
    this.items.push(item);
  }

  get itemCount(): number {
    return this.items.length;
  }

  get total(): number {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }
}

Iteration 3: Duplicate Items Stack

// 🔴 RED
test('adding same item twice increases quantity', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 'p1', name: 'Widget', price: 9.99, quantity: 1 });
  cart.addItem({ id: 'p1', name: 'Widget', price: 9.99, quantity: 1 });
  
  expect(cart.itemCount).toBe(1); // Still 1 item
  expect(cart.total).toBe(19.98); // But quantity is 2
});
// 🟢 GREEN — update addItem
addItem(item: CartItem): void {
  const existing = this.items.find(i => i.id === item.id);
  if (existing) {
    existing.quantity += item.quantity;
  } else {
    this.items.push({ ...item });
  }
}

Iteration 4: Remove Items

// 🔴 RED
test('removes item from cart', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 'p1', name: 'Widget', price: 9.99, quantity: 1 });
  cart.removeItem('p1');
  expect(cart.itemCount).toBe(0);
});

test('removing non-existent item is a no-op', () => {
  const cart = new ShoppingCart();
  expect(() => cart.removeItem('nonexistent')).not.toThrow();
});
// 🟢 GREEN
removeItem(id: string): void {
  this.items = this.items.filter(i => i.id !== id);
}

Iteration 5: Apply Discount

// 🔴 RED
test('applies percentage discount to total', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 'p1', name: 'Widget', price: 100, quantity: 1 });
  cart.applyDiscount({ type: 'percentage', value: 10 });
  expect(cart.total).toBe(90);
});

test('applies fixed discount to total', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 'p1', name: 'Widget', price: 100, quantity: 1 });
  cart.applyDiscount({ type: 'fixed', value: 15 });
  expect(cart.total).toBe(85);
});

test('discount cannot make total negative', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: 'p1', name: 'Widget', price: 10, quantity: 1 });
  cart.applyDiscount({ type: 'fixed', value: 50 });
  expect(cart.total).toBe(0);
});
// 🟢 GREEN
interface Discount {
  type: 'percentage' | 'fixed';
  value: number;
}

class ShoppingCart {
  private items: CartItem[] = [];
  private discount?: Discount;

  applyDiscount(discount: Discount): void {
    this.discount = discount;
  }

  get subtotal(): number {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  get total(): number {
    const sub = this.subtotal;
    if (!this.discount) return sub;

    const discountAmount = this.discount.type === 'percentage'
      ? sub * (this.discount.value / 100)
      : this.discount.value;

    return Math.max(0, sub - discountAmount);
  }
}

🔵 Refactor

After all tests pass, we can safely refactor. The tests protect us:

// Extracted discount calculation for clarity
private calculateDiscount(subtotal: number): number {
  if (!this.discount) return 0;
  
  switch (this.discount.type) {
    case 'percentage': return subtotal * (this.discount.value / 100);
    case 'fixed': return this.discount.value;
  }
}

get total(): number {
  const sub = this.subtotal;
  return Math.max(0, sub - this.calculateDiscount(sub));
}

TDD Rhythm

After a few cycles, TDD feels natural:

  1. Think about the next behavior (30 seconds)
  2. Write a test for it (1-2 minutes)
  3. Make it pass (1-5 minutes)
  4. Refactor if needed (0-3 minutes)

Each cycle is 5-10 minutes. You always have working code, always have test coverage, and the design emerges organically.

“TDD is not about testing. It’s about design, documentation, and confidence in your code.” — Kent Beck