Test-Driven Development
The Red-Green-Refactor Cycle
Master the fundamental rhythm of TDD — Red (failing test), Green (make it pass), Refactor (improve the design).
The Three Colors
The Red-Green-Refactor cycle is the heartbeat of TDD:
🔴 Red — Write a failing test. The test describes a new requirement. It fails because the code doesn't implement it yet. A failing test is good — it proves the test is actually testing something.
🟢 Green — Write the minimum code to pass the test. The simplest, dumbest code that makes the test pass. Resist the urge to build the "real" solution.
🔵 Refactor — Improve the code without changing behavior. Extract duplication, improve naming, simplify structure. Tests verify you didn't break anything.
Why "Minimum Code"?
The "make it pass as simply as possible" step feels wrong. Surely you should write the correct code directly?
No — and this is the key insight. If you solve everything at once, you cannot tell which part of your solution is actually needed. Baby steps guarantee each test drives a real requirement.
Classic example: if the only test is expect(add(1, 1)).toBe(2), the simplest passing code is return 2. When you add more tests, they force generalization.
Complete TDD Session: PasswordValidator
Walk through building a password validator using strict Red-Green-Refactor:
// Cycle 1 — Minimum length
// RED:
it('rejects passwords under 8 characters', () => {
expect(validate('short')).toBe(false);
});
// GREEN (dumbest possible code):
function validate(password: string): boolean {
return password.length >= 8;
}
// Cycle 2 — Uppercase requirement
// RED:
it('rejects passwords with no uppercase letter', () => {
expect(validate('alllowercase1')).toBe(false);
expect(validate('Has1Uppercase')).toBe(true); // Triangulation
});
// GREEN:
function validate(password: string): boolean {
if (password.length < 8) return false;
if (!/[A-Z]/.test(password)) return false;
return true;
}
// Cycle 3 — Number requirement
// RED:
it('rejects passwords with no numbers', () => {
expect(validate('NoNumbers!')).toBe(false);
expect(validate('HasANumber1')).toBe(true);
});
// GREEN:
function validate(password: string): boolean {
if (password.length < 8) return false;
if (!/[A-Z]/.test(password)) return false;
if (!/[0-9]/.test(password)) return false;
return true;
}
// REFACTOR — extract rules array (tests still pass):
const rules = [
{ test: (p: string) => p.length >= 8 },
{ test: (p: string) => /[A-Z]/.test(p) },
{ test: (p: string) => /[0-9]/.test(p) },
];
function validate(password: string): boolean {
return rules.every(rule => rule.test(password));
}Triangulation
Sometimes a single test case can be satisfied by trivial code that doesn't generalize. Add a second test case that forces the real implementation:
// One test can be satisfied trivially
it('adds two numbers', () => {
expect(add(1, 1)).toBe(2);
});
// Could pass with: return 2;
// Add triangulating test — forces real implementation
it('adds different numbers', () => {
expect(add(3, 4)).toBe(7);
});
// Now must implement: return a + b;Keeping the Rhythm
Each Red-Green-Refactor cycle should take 1-5 minutes. If a cycle is taking longer:
- The step is too large — break it into smaller steps
- The code is too complex — simplify the design
- The test is testing too much — test one thing at a time
The discipline of small steps is what makes TDD work. Large steps accumulate mistakes; small steps catch them immediately.
Key Takeaways
- Red = failing test (proves the test is testing something real), Green = minimum code to pass, Refactor = improve without changing behavior
- "Minimum code" means the simplest code that passes — this reveals what is actually needed
- Triangulation: add a second test case to force generalization when one test allows trivial solutions
- Each cycle should take 1-5 minutes — longer means the step is too big
- Refactoring is safe because tests immediately verify you haven't broken behavior
Example
// Complete Red-Green-Refactor example
// RED: Write a failing test
test('calculates total with quantity discount', () => {
const cart = new ShoppingCart();
cart.add({ price: 1000, quantity: 5 });
// Buy 5+, get 10% off
expect(cart.total()).toBe(4500);
});
// ShoppingCart doesn't exist — test fails (RED)
// GREEN: Minimum code to pass
class ShoppingCart {
private items: Array<{ price: number; quantity: number }> = [];
add(item: { price: number; quantity: number }) {
this.items.push(item);
}
total(): number {
const subtotal = this.items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
const quantity = this.items.reduce((sum, item) => sum + item.quantity, 0);
return quantity >= 5 ? Math.round(subtotal * 0.9) : subtotal;
}
}
// Test passes (GREEN)
// REFACTOR: Extract discount logic
private applyDiscount(subtotal: number, quantity: number): number {
if (quantity >= 5) return Math.round(subtotal * 0.9);
return subtotal;
}
// All tests still pass