TDD in Practice: Building a Real Feature
A practical walkthrough of Test-Driven Development building a shopping cart feature, showing the Red-Green-Refactor cycle in action.
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
- 🔴 Red: Write a failing test for the next small behavior
- 🟢 Green: Write the simplest code to make it pass
- 🔵 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:
- Think about the next behavior (30 seconds)
- Write a test for it (1-2 minutes)
- Make it pass (1-5 minutes)
- 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