Test-Driven Development (TDD): Red-Green-Refactor in Practice
TDD flips the script: write a failing test first, then write just enough code to pass it.
The Red-Green-Refactor Cycle
- Red: Write a failing test that describes desired behavior
- Green: Write the simplest code to make it pass
- Refactor: Clean up without breaking tests
Practical Example: Shopping Cart
Step 1: Red - Write failing test
// cart.test.ts
import { ShoppingCart } from './cart';
describe('ShoppingCart', () => {
it('should start empty', () => {
const cart = new ShoppingCart();
expect(cart.items).toHaveLength(0);
expect(cart.total).toBe(0);
});
});
Step 2: Green - Minimal implementation
// cart.ts
export class ShoppingCart {
items: never[] = [];
total = 0;
}
Step 3: Red again - Add more behavior
it('should add items', () => {
const cart = new ShoppingCart();
cart.addItem({ id: '1', name: 'Book', price: 15.99, quantity: 2 });
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(31.98);
});
Step 4: Green
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
export class ShoppingCart {
private _items: CartItem[] = [];
get items(): CartItem[] { return [...this._items]; }
get total(): number {
return this._items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
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 });
}
}
}
Continue: Remove item
it('should remove items', () => {
const cart = new ShoppingCart();
cart.addItem({ id: '1', name: 'Book', price: 15.99, quantity: 1 });
cart.removeItem('1');
expect(cart.items).toHaveLength(0);
});
it('should apply discount', () => {
const cart = new ShoppingCart();
cart.addItem({ id: '1', name: 'Book', price: 100, quantity: 1 });
cart.applyDiscount(10); // 10% off
expect(cart.total).toBe(90);
});
TDD with Outside-In (London School)
// Start from the outside: controller test with mocks
describe('POST /cart/items', () => {
it('should add item to cart', async () => {
const mockCartService = { addItem: jest.fn().mockResolvedValue(updatedCart) };
const controller = new CartController(mockCartService);
const req = { body: { itemId: '1', quantity: 2 }, user: { id: 'user1' } };
const res = { json: jest.fn() };
await controller.addItem(req, res);
expect(mockCartService.addItem).toHaveBeenCalledWith('user1', '1', 2);
expect(res.json).toHaveBeenCalledWith(updatedCart);
});
});
When to Use TDD
Great for:
- Business logic and algorithms
- Utilities and pure functions
- Complex state machines
Less useful for:
- UI components (prefer snapshot/interaction tests)
- Database migrations
- Third-party integrations
TDD Benefits
- Design pressure: Hard-to-test code often has poor design
- Documentation: Tests describe expected behavior
- Confidence: Refactor without fear
- Coverage: By definition, all code has tests
The discipline of TDD pays off most in long-lived codebases with many contributors.