Execute the Red-Green-Refactor cycle with precision: write the minimal failing test, implement just enough to pass, then refactor aggressively while keeping tests green.
The Red-Green-Refactor cycle is the heartbeat of TDD. Red: write a test that fails. Green: write the minimum code to make it pass. Refactor: clean up the code while keeping tests green. Then repeat. The cycle should take under two minutes per iteration. If it takes longer, the step is too big.
Mastering this cycle requires discipline in each phase. In Red, the failure must be meaningful. In Green, resist the urge to write clever code. In Refactor, make the code correct and clear without changing behaviour. This lesson practises each phase until the rhythm becomes automatic.
A failing test must fail for exactly the right reason. 'The function does not exist yet' is a valid failure. 'The function exists but returns the wrong value' is a valid failure. 'TypeScript does not compile' is not a meaningful test failure.
1// WRONG RED: test fails because of a type error, not a behaviour failure
2test('calculates shipping cost', () => {
3 // TypeScript will error: 'calculateShipping' is not defined
4 // This is a compile error, not a test failure
5 const cost = calculateShipping('heavy', 100);
6 expect(cost).toBe(25);
7});
8// FIX: declare the function signature first (without implementation)
9export function calculateShipping(weight: 'light' | 'heavy', distance: number): number {
10 throw new Error('not implemented');
11}
12// NOW the test fails meaningfully: 'Error: not implemented'
13
14// WRONG RED: test fails because assertion is incorrect
15test('returns 25 for heavy item shipping 100km', () => {
16 expect(calculateShipping('heavy', 100)).toBe(999); // wrong expected value
17});
18// A test written with the wrong assertion will still be 'green' after wrong implementation
19
20// CORRECT RED: precise test with correct expected value
21test('charges $25 for heavy items shipping 100km', () => {
22 expect(calculateShipping('heavy', 100)).toBe(25);
23});
24// This test fails because the implementation throws — not a compile error, not a wrong assertion
25
26// RED phase checklist:
27// ✅ The test compiles (no type errors)
28// ✅ The test runs (no runtime errors before the assertion)
29// ✅ The test fails AT THE ASSERTION (not before it)
30// ✅ The failure message is clear about what was expected vs received
31// ✅ You understand exactly why it fails and what implementation would make it passGreen means passing — not correct, not complete, just passing. The strict TDD interpretation says the simplest code that passes is acceptable, even if it is obviously incomplete. This forces the next test to be written.
1// After writing the first test:
2test('charges $25 for heavy items 100km', () => {
3 expect(calculateShipping('heavy', 100)).toBe(25);
4});
5
6// VALID green implementation (obviously incomplete, but passes):
7export function calculateShipping(weight: 'light' | 'heavy', distance: number): number {
8 return 25;
9}
10// Run: PASS ✅ — yes, this is genuinely what TDD says to do
11
12// Now write the next test that forces generalisation:
13test('charges $15 for light items 100km', () => {
14 expect(calculateShipping('light', 100)).toBe(15);
15});
16// FAIL — returns 25, expected 15
17
18// More general green:
19export function calculateShipping(weight: 'light' | 'heavy', distance: number): number {
20 return weight === 'heavy' ? 25 : 15;
21}
22// Both tests PASS ✅
23
24// Next test forces distance to matter:
25test('charges more for heavy items over 200km', () => {
26 expect(calculateShipping('heavy', 200)).toBe(45);
27});
28// FAIL — returns 25 regardless of distance
29
30// More general green (now distance must matter):
31export function calculateShipping(weight: 'light' | 'heavy', distance: number): number {
32 const base = weight === 'heavy' ? 15 : 10;
33 const perKm = weight === 'heavy' ? 0.1 : 0.05;
34 return base + perKm * distance;
35}
36// 15 + 0.1*100 = 25 ✅
37// 10 + 0.05*100 = 15 ✅
38// 15 + 0.1*200 = 35 ✗ (expected 45 — so the formula is wrong, test drives correction)
39
40// The process of writing minimum code that passes each test drives
41// the design toward correctness through concrete examplesRefactoring means improving the structure of code without changing its observable behaviour. The tests prove behaviour is unchanged — they must all pass after every refactoring step.
1// Green implementation after 4 iterations (messy but all tests pass):
2export function calculateShipping(weight: 'light' | 'heavy', dist: number): number {
3 if (weight === 'heavy') {
4 if (dist <= 100) { return 25; }
5 if (dist <= 500) { return 25 + (dist - 100) * 0.2; }
6 return 25 + 400 * 0.2 + (dist - 500) * 0.3;
7 } else {
8 if (dist <= 100) { return 15; }
9 if (dist <= 500) { return 15 + (dist - 100) * 0.1; }
10 return 15 + 400 * 0.1 + (dist - 500) * 0.15;
11 }
12}
13
14// REFACTOR 1: extract the duplicate tiered calculation
15function tieredCost(
16 base: number,
17 dist: number,
18 rates: { limit: number; rate: number }[]
19): number {
20 let cost = base;
21 let remaining = dist;
22 for (const tier of rates) {
23 const tierDist = Math.min(remaining, tier.limit);
24 cost += tierDist * tier.rate;
25 remaining -= tierDist;
26 if (remaining <= 0) break;
27 }
28 return cost;
29}
30
31const HEAVY_RATES = [
32 { limit: 100, rate: 0.1 }, // $0.10/km for first 100km
33 { limit: 400, rate: 0.2 }, // $0.20/km for 101-500km
34 { limit: Infinity, rate: 0.3 }, // $0.30/km beyond 500km
35];
36
37const LIGHT_RATES = [
38 { limit: 100, rate: 0.05 },
39 { limit: 400, rate: 0.10 },
40 { limit: Infinity, rate: 0.15 },
41];
42
43export function calculateShipping(weight: 'light' | 'heavy', distance: number): number {
44 const base = weight === 'heavy' ? 15 : 10;
45 const rates = weight === 'heavy' ? HEAVY_RATES : LIGHT_RATES;
46 return base + tieredCost(0, distance, rates);
47}
48
49// Run all tests: still PASS ✅
50// Refactoring improved readability without changing behaviour
51// The tests guarantee the refactoring is safeKent Beck's four rules guide what to do in the Refactor phase. Listed in priority order:
1// VIOLATES Rule 2 (reveals intention):
2const r = weight === 'h' ? 0.1 : 0.05;
3const t = d <= 100 ? d * r : 100 * r + (d - 100) * (r * 2);
4// Nobody knows what r, t, or h mean
5
6// FOLLOWS Rule 2:
7const perKmRate = weight === 'heavy' ? 0.10 : 0.05;
8const shippingCost = calculateTieredCost(distance, baseRate, perKmRate);
9// Clear names communicate intent
10
11// VIOLATES Rule 3 (no duplication):
12function getHeavyShippingCost(distance: number): number {
13 if (distance <= 100) return 15 + distance * 0.1;
14 return 15 + 100 * 0.1 + (distance - 100) * 0.2;
15}
16
17function getLightShippingCost(distance: number): number {
18 if (distance <= 100) return 10 + distance * 0.05; // same structure, different constants
19 return 10 + 100 * 0.05 + (distance - 100) * 0.10;
20}
21// Extract the shared calculation:
22function getShippingCost(base: number, rate1: number, rate2: number, distance: number): number {
23 const tier1 = Math.min(distance, 100);
24 const tier2 = Math.max(0, distance - 100);
25 return base + tier1 * rate1 + tier2 * rate2;
26}
27
28// VIOLATES Rule 4 (fewest elements):
29interface ShippingConfig {
30 weight: 'light' | 'heavy';
31 distance: number;
32 expressDelivery: boolean; // never used anywhere
33 insuranceAmount: number; // no test requires it
34}
35// Remove fields not required by any passing testEach Red-Green-Refactor cycle should complete in 1-3 minutes. Longer cycles indicate a step is too large.
1CYCLE TIMING GUIDE:
2
3Red phase: 30 seconds to 1 minute
4 - If it takes longer: the test scenario is too complex
5 - Split into multiple smaller tests
6
7Green phase: 30 seconds to 2 minutes
8 - If it takes longer: the jump from test to implementation is too large
9 - Consider a smaller test (test less at once)
10 - Or hardcode the result temporarily and write the next test
11
12Refactor phase: 1-5 minutes
13 - If it takes longer: refactoring is too ambitious
14 - Each refactoring step should be one change at a time
15 - Run tests after each change (not at the end)1// TOO LARGE a test (skips many cycles):
2test('fully processes an order with items, discounts, tax, and shipping', () => {
3 const result = processOrder({
4 items: [{ id: '1', qty: 2, price: 49.99 }],
5 coupon: 'SAVE10',
6 address: { country: 'US', state: 'CA' },
7 express: true,
8 });
9 expect(result.subtotal).toBe(99.98);
10 expect(result.discount).toBe(9.998);
11 expect(result.tax).toBe(7.31);
12 expect(result.shipping).toBe(14.99);
13 expect(result.total).toBe(112.31);
14});
15// This will take 30+ minutes to make green and requires implementing
16// items, discounts, tax, and shipping all at once
17
18// SPLIT INTO SMALL CYCLES:
19test('subtotal is sum of price * qty for all items', () => { ... }); // cycle 1
20test('SAVE10 coupon reduces subtotal by 10%', () => { ... }); // cycle 2
21test('US/CA applies 7.25% sales tax to discounted subtotal', () => { ... }); // cycle 3
22test('express shipping costs $14.99 for domestic orders', () => { ... }); // cycle 4
23test('total is subtotal minus discount plus tax plus shipping', () => { ... }); // cycle 5
24// Each cycle: 2-3 minutes. Total: 10-15 minutes. Much safer.A complete TDD session building a ShoppingCart class through 10 precise cycles, each lasting 1-2 minutes, demonstrating the rhythm of professional TDD practice.
1// TDD Session: ShoppingCart
2// Each test was written BEFORE the implementation that makes it pass
3
4// src/cart/shopping-cart.test.ts
5import { ShoppingCart } from './shopping-cart';
6
7describe('ShoppingCart', () => {
8 // CYCLE 1: Red — cart starts empty
9 it('starts with zero items', () => {
10 const cart = new ShoppingCart();
11 expect(cart.itemCount()).toBe(0);
12 });
13 // Green: class with itemCount() returning 0
14
15 // CYCLE 2: Red — adding items
16 it('has one item after addItem is called', () => {
17 const cart = new ShoppingCart();
18 cart.addItem({ id: 'p1', name: 'Widget', price: 10, qty: 1 });
19 expect(cart.itemCount()).toBe(1);
20 });
21 // Green: items array, push in addItem, length in itemCount
22
23 // CYCLE 3: Red — subtotal
24 it('calculates subtotal as price * qty for a single item', () => {
25 const cart = new ShoppingCart();
26 cart.addItem({ id: 'p1', name: 'Widget', price: 10, qty: 3 });
27 expect(cart.subtotal()).toBe(30);
28 });
29 // Green: subtotal = sum of price * qty
30
31 // CYCLE 4: Red — multiple items
32 it('adds subtotals for multiple items', () => {
33 const cart = new ShoppingCart();
34 cart.addItem({ id: 'p1', name: 'Widget', price: 10, qty: 2 });
35 cart.addItem({ id: 'p2', name: 'Gadget', price: 25, qty: 1 });
36 expect(cart.subtotal()).toBe(45);
37 });
38 // Green: reduce over items
39
40 // CYCLE 5: Red — quantity accumulation
41 it('adds quantity when the same item is added again', () => {
42 const cart = new ShoppingCart();
43 cart.addItem({ id: 'p1', name: 'Widget', price: 10, qty: 1 });
44 cart.addItem({ id: 'p1', name: 'Widget', price: 10, qty: 2 });
45 expect(cart.itemCount()).toBe(1); // still one line item
46 expect(cart.subtotal()).toBe(30); // qty = 3
47 });
48 // Green: find existing item and increment qty
49
50 // CYCLE 6: Red — remove item
51 it('removes an item by id', () => {
52 const cart = new ShoppingCart();
53 cart.addItem({ id: 'p1', name: 'Widget', price: 10, qty: 1 });
54 cart.addItem({ id: 'p2', name: 'Gadget', price: 25, qty: 1 });
55 cart.removeItem('p1');
56 expect(cart.itemCount()).toBe(1);
57 expect(cart.subtotal()).toBe(25);
58 });
59 // Green: filter by id
60
61 // CYCLE 7: Red — coupon discount
62 it('applies SAVE10 coupon to reduce subtotal by 10%', () => {
63 const cart = new ShoppingCart();
64 cart.addItem({ id: 'p1', name: 'Widget', price: 100, qty: 1 });
65 cart.applyCoupon('SAVE10');
66 expect(cart.total()).toBe(90);
67 });
68 // Green: coupon store + total() applies discount
69
70 // CYCLE 8: Red — invalid coupon
71 it('throws for unknown coupon code', () => {
72 const cart = new ShoppingCart();
73 expect(() => cart.applyCoupon('FAKECODE')).toThrow('Invalid coupon');
74 });
75 // Green: throw if not in registry
76
77 // CYCLE 9: Red — immutability
78 it('does not mutate the input item object', () => {
79 const cart = new ShoppingCart();
80 const item = { id: 'p1', name: 'Widget', price: 10, qty: 1 };
81 cart.addItem(item);
82 cart.addItem({ id: 'p1', name: 'Widget', price: 10, qty: 5 });
83 expect(item.qty).toBe(1); // original item unchanged
84 });
85 // Green: spread item in addItem
86
87 // CYCLE 10: Red — clear cart
88 it('empties the cart after clear()', () => {
89 const cart = new ShoppingCart();
90 cart.addItem({ id: 'p1', name: 'Widget', price: 10, qty: 2 });
91 cart.clear();
92 expect(cart.itemCount()).toBe(0);
93 expect(cart.subtotal()).toBe(0);
94 });
95 // Green: items = []
96});
97
98// FINAL IMPLEMENTATION (emerged from 10 TDD cycles):
99export interface CartItem {
100 id: string;
101 name: string;
102 price: number;
103 qty: number;
104}
105
106const COUPONS: Record<string, number> = {
107 SAVE10: 0.10,
108 SAVE20: 0.20,
109 HALFOFF: 0.50,
110};
111
112export class ShoppingCart {
113 private items: CartItem[] = [];
114 private discountRate = 0;
115
116 addItem(item: CartItem): void {
117 const existing = this.items.find((i) => i.id === item.id);
118 if (existing) {
119 existing.qty += item.qty;
120 } else {
121 this.items.push({ ...item }); // spread to avoid mutation
122 }
123 }
124
125 removeItem(id: string): void {
126 this.items = this.items.filter((i) => i.id !== id);
127 }
128
129 applyCoupon(code: string): void {
130 const rate = COUPONS[code];
131 if (rate === undefined) throw new Error('Invalid coupon');
132 this.discountRate = rate;
133 }
134
135 itemCount(): number {
136 return this.items.length;
137 }
138
139 subtotal(): number {
140 return this.items.reduce((sum, i) => sum + i.price * i.qty, 0);
141 }
142
143 total(): number {
144 return this.subtotal() * (1 - this.discountRate);
145 }
146
147 clear(): void {
148 this.items = [];
149 this.discountRate = 0;
150 }
151}Each cycle took 1-2 minutes: 30 seconds to write the test, 30-90 seconds to make it pass, 30 seconds to refactor. The design emerged from the tests: the items array, the coupon registry, the spread for immutability — none of these were pre-designed. Tests 5 and 9 are particularly interesting: test 5 discovered that same-item additions needed qty accumulation (a requirement the developer might have forgotten); test 9 discovered the immutability requirement that prevents subtle React state bugs.
The next lesson covers writing the first failing test: how to choose which test to write first, how to identify the simplest scenario that starts the TDD session, and the triangulation technique for driving implementation.
Your progress is saved in this browser, on this device only.