Loading learning content...
There is a rhythm to Test-Driven Development that, once internalized, fundamentally transforms how you write software. This rhythm is captured in three simple words: Red, Green, Refactor. These words represent more than a technique—they embody a complete philosophy of software construction that has shaped how elite engineers approach code for over two decades.
The Red-Green-Refactor cycle is deceptively simple to describe but profoundly challenging to master. It asks you to invert your natural programming instincts: instead of writing code and then verifying it works, you first articulate precisely what "working" means through an executable specification—a test—and only then write the code to satisfy that specification.
By the end of this page, you will understand each phase of the TDD cycle with surgical precision. You'll know why the cycle works in this specific order, what traps await at each phase, and how the cumulative effect of thousands of these micro-cycles produces software with exceptional design quality. This isn't just process—it's architecture through incremental discovery.
The TDD cycle consists of three discrete phases, each with distinct purpose and characteristics. Understanding these phases deeply is essential because the power of TDD emerges from their precise execution and ordering.
The Three Phases:
Red Phase: Write a test that fails. This test defines a small, specific behavior you want to add to your system. The failure demonstrates that the behavior doesn't exist yet.
Green Phase: Write the minimum code necessary to make the test pass. This means doing the simplest possible thing that satisfies the test—nothing more.
Refactor Phase: Improve the code structure while keeping all tests passing. This is where design emerges and technical debt is actively managed.
Each cycle typically takes between 30 seconds and 5 minutes. Longer cycles suggest you're taking too big a step. The discipline is in keeping steps small and the cycle fast.
Each TDD cycle represents a micro-commitment to functionality. By committing to tiny increments of verified behavior, you build confidence that compounds. One passing test is a fact. A hundred passing tests is a fortress. A thousand is an unmovable foundation.
123456789101112131415161718192021222324
┌─────────────────────────────────────────────────────────────┐│ TDD CYCLE ││ ││ ┌─────────┐ ││ │ RED │ ←── Write a failing test ││ │ (Fail) │ (Define desired behavior) ││ └────┬────┘ ││ │ ││ ▼ ││ ┌─────────┐ ││ │ GREEN │ ←── Write minimal code to pass ││ │ (Pass) │ (Make it work, any way possible) ││ └────┬────┘ ││ │ ││ ▼ ││ ┌──────────┐ ││ │ REFACTOR │ ←── Improve structure, keep tests green ││ │ (Clean) │ (Make it right) ││ └────┬─────┘ ││ │ ││ └──────────────► Back to RED ││ ││ Typical cycle time: 30 seconds - 5 minutes │└─────────────────────────────────────────────────────────────┘The Red phase is where TDD begins—and where many practitioners stumble. Writing a failing test sounds trivial, but doing it well requires deep understanding of what tests should express and how they should fail.
The Purpose of Red:
The failing test serves multiple critical purposes:
Critically, the test should fail for the right reason. If you're testing that a method returns 42, the failure should be "expected 42 but got null" or "method not found"—not a compilation error in unrelated code or a configuration failure.
should_return_empty_list_when_no_items_added(), not testList() or test1().1234567891011121314151617181920212223242526272829303132333435363738
// RED PHASE: Writing a failing test// We're testing behavior that doesn't exist yet describe('ShoppingCart', () => { // Test name reads as a specification it('should return zero total when cart is empty', () => { // Arrange: Set up the scenario const cart = new ShoppingCart(); // Act: Perform the action const total = cart.getTotal(); // Assert: Verify the expected outcome expect(total).toBe(0); }); // Another specification-style test it('should return item price as total when single item added', () => { // Arrange const cart = new ShoppingCart(); const item = new CartItem('Widget', 29.99); // Act cart.addItem(item); const total = cart.getTotal(); // Assert expect(total).toBe(29.99); });}); // At this point, running the test fails:// "ReferenceError: ShoppingCart is not defined"// or after creating empty class:// "TypeError: cart.getTotal is not a function"// // These are "right reason" failures - they tell us// exactly what's missing.If you write production code before the test, you lose the verification that your test actually tests what you think. A test that never failed might be testing nothing. Practitioners who skip red often discover later that their tests pass regardless of production code behavior—they're testing configuration, not logic.
The Green phase is deceptively challenging because it requires restraint. The goal is to make the test pass—nothing more. This means you might write code that looks incomplete, hardcoded, or even "wrong" to a conventional eye.
The Purpose of Green:
The green phase serves specific purposes that distinguish TDD from conventional development:
The mantra of green is "make it work"—not "make it right" or "make it elegant." Elegance comes in refactor.
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// GREEN PHASE: Make the test pass with minimal code// We're deliberately writing "incomplete" code // First iteration: Make "empty cart returns zero" passclass ShoppingCart { getTotal(): number { return 0; // Hardcoded! And that's fine. }} // Test runs: ✓ should return zero total when cart is empty// But wait, we have another failing test... // Second iteration: Make "single item returns item price" passclass CartItem { constructor( public readonly name: string, public readonly price: number ) {}} class ShoppingCart { private items: CartItem[] = []; addItem(item: CartItem): void { this.items.push(item); } getTotal(): number { // Still simple - no abstraction yet if (this.items.length === 0) { return 0; } return this.items[0].price; }} // Test runs: // ✓ should return zero total when cart is empty// ✓ should return item price as total when single item added // Notice: getTotal() doesn't sum multiple items!// That's because we don't have a test for that YET.// The next RED phase would add that test.Returning hardcoded values feels wrong, but it's strategically correct. It forces you to write additional tests that break the hardcoding. Each test you add triangulates towards the general solution. By the time enough tests exist, the general solution becomes obvious—and is thoroughly verified.
The Refactor phase is where design emerges. With a safety net of passing tests, you can confidently restructure code to improve clarity, reduce duplication, enhance naming, and introduce proper abstractions.
The Purpose of Refactor:
Refactoring serves essential purposes that make TDD sustainable:
The mantra of refactor is "make it right"—improve the implementation while preserving behavior.
The Golden Rule of Refactoring:
Change structure, not behavior. If your tests still pass after refactoring, you've preserved behavior. If they fail, you've introduced a bug or changed behavior that was intentional.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// REFACTOR PHASE: Improve structure while keeping tests green// Assume we've added more tests and the code has grown // BEFORE REFACTOR (accumulated "sins" from green phases):class ShoppingCart { private items: CartItem[] = []; addItem(item: CartItem): void { this.items.push(item); } getTotal(): number { // Duplication, no extraction, inline everything let total = 0; for (const item of this.items) { total = total + item.price * item.quantity; if (item.hasDiscount) { total = total - (item.price * item.quantity * item.discountPercent / 100); } } // Tax calculation inline const taxRate = 0.08; total = total + (total * taxRate); return Math.round(total * 100) / 100; }} // AFTER REFACTOR (structure improved, tests still pass): class ShoppingCart { private static readonly TAX_RATE = 0.08; private readonly items: CartItem[] = []; addItem(item: CartItem): void { this.items.push(item); } getTotal(): number { const subtotal = this.calculateSubtotal(); const withTax = this.applyTax(subtotal); return this.roundToMoney(withTax); } private calculateSubtotal(): number { return this.items.reduce( (sum, item) => sum + item.getEffectivePrice(), 0 ); } private applyTax(amount: number): number { return amount * (1 + ShoppingCart.TAX_RATE); } private roundToMoney(amount: number): number { return Math.round(amount * 100) / 100; }} // CartItem now handles its own pricing logicclass CartItem { constructor( public readonly name: string, public readonly price: number, public readonly quantity: number = 1, private readonly discountPercent: number = 0 ) {} getEffectivePrice(): number { const basePrice = this.price * this.quantity; return basePrice * (1 - this.discountPercent / 100); } get hasDiscount(): boolean { return this.discountPercent > 0; }} // All tests still pass! ✓// But the code is now:// - More readable (meaningful method names)// - More maintainable (single responsibility)// - More testable (each method can be tested independently)// - More extensible (tax rates, rounding could vary)Let's watch multiple complete TDD cycles in action, building a feature from nothing to completion. We'll implement a simple StringCalculator that adds numbers from a string—a classic TDD kata.
The Kata:
Create a StringCalculator with a method add(string numbers) that:
Watch how each cycle reveals the next requirement, and how the design emerges organically.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
// ═══════════════════════════════════════════════════════════// CYCLE 1: Empty string returns zero// ═══════════════════════════════════════════════════════════ // RED: Write failing testdescribe('StringCalculator', () => { it('should return 0 for empty string', () => { const calculator = new StringCalculator(); expect(calculator.add("")).toBe(0); });});// Run: ✗ ReferenceError: StringCalculator is not defined // GREEN: Minimal implementationclass StringCalculator { add(numbers: string): number { return 0; }}// Run: ✓ All tests pass // REFACTOR: Nothing to refactor yet - move on // ═══════════════════════════════════════════════════════════// CYCLE 2: Single number returns itself// ═══════════════════════════════════════════════════════════ // RED: Write failing testit('should return the number for single number input', () => { const calculator = new StringCalculator(); expect(calculator.add("5")).toBe(5);});// Run: ✗ Expected 5, received 0 // GREEN: Minimal changeclass StringCalculator { add(numbers: string): number { if (numbers === "") return 0; return parseInt(numbers); }}// Run: ✓ All tests pass // REFACTOR: Code is simple enough - move on // ═══════════════════════════════════════════════════════════// CYCLE 3: Two numbers returns sum// ═══════════════════════════════════════════════════════════ // RED: Write failing testit('should return sum of two comma-separated numbers', () => { const calculator = new StringCalculator(); expect(calculator.add("1,2")).toBe(3);});// Run: ✗ Expected 3, received NaN (parseInt of "1,2" is NaN) // GREEN: Make it workclass StringCalculator { add(numbers: string): number { if (numbers === "") return 0; if (numbers.includes(",")) { const parts = numbers.split(","); return parseInt(parts[0]) + parseInt(parts[1]); } return parseInt(numbers); }}// Run: ✓ All tests pass // REFACTOR: Now we see duplication and can improveclass StringCalculator { add(numbers: string): number { if (numbers === "") return 0; const numberStrings = numbers.split(","); return numberStrings .map(s => parseInt(s)) .reduce((sum, n) => sum + n, 0); }}// Run: ✓ All tests still pass - refactoring preserved behavior // ═══════════════════════════════════════════════════════════// CYCLE 4: Handle any amount of numbers// ═══════════════════════════════════════════════════════════ // RED: Test with more numbersit('should handle any amount of numbers', () => { const calculator = new StringCalculator(); expect(calculator.add("1,2,3,4,5")).toBe(15);});// Run: ✓ Already passes! Our refactoring handled it. // This is the magic of TDD: our refactoring in cycle 3// naturally extended to handle this case. // REFACTOR: Add test but no code change needed // ═══════════════════════════════════════════════════════════// CYCLE 5: Handle newlines as delimiters// ═══════════════════════════════════════════════════════════ // RED: New delimiter requirementit('should handle newlines as delimiters', () => { const calculator = new StringCalculator(); expect(calculator.add("1\n2,3")).toBe(6);});// Run: ✗ Expected 6, received NaN // GREEN: Quick fixclass StringCalculator { add(numbers: string): number { if (numbers === "") return 0; // Replace newlines with commas, then split const normalized = numbers.replace(/\n/g, ","); return normalized .split(",") .map(s => parseInt(s)) .reduce((sum, n) => sum + n, 0); }}// Run: ✓ All tests pass // REFACTOR: Extract delimiter handlingclass StringCalculator { private static readonly DELIMITERS = /[,\n]/; add(numbers: string): number { if (this.isEmpty(numbers)) return 0; return this.parseNumbers(numbers) .reduce((sum, n) => sum + n, 0); } private isEmpty(input: string): boolean { return input === ""; } private parseNumbers(input: string): number[] { return input .split(StringCalculator.DELIMITERS) .map(s => parseInt(s)); }}// Run: ✓ All tests still pass // Notice how design emerged:// - Single responsibility for parsing// - Configurable delimiters// - Clear method names// All through the TDD cycle!Notice how we never "designed" the StringCalculator upfront. We didn't create UML diagrams or architecture documents. The design emerged naturally from tests demanding behavior. Each refactoring made the code slightly more general, and the final result handles cases we didn't initially anticipate.
TDD seems simple but has subtle failure modes. Understanding anti-patterns helps you maintain the discipline that makes TDD effective.
| Anti-Pattern | Why It's Harmful | The Remedy |
|---|---|---|
| Writing tests after code | Tests become verification of implementation rather than specification of behavior. You're testing what you built, not what should be built. | Commit to writing tests first, even when tempted. The discomfort fades with practice. |
| Big steps (too much code before testing) | Long gaps between tests mean longer debugging when something fails. You lose the localization benefit. | If you can't make a test pass in 5 minutes, the step is too big. Write a smaller test. |
| Skipping refactor | Technical debt accumulates. The codebase becomes a sprawl of barely-working code. TDD without refactoring is just writing bad code with tests. | Make refactoring a non-negotiable part of each cycle. Block time for it. |
| Testing implementation, not behavior | Tests become brittle, breaking on every internal change. Refactoring becomes feared rather than embraced. | Focus tests on public APIs and observable behavior. Test what, not how. |
| Ignoring the failed test | Proceeding without seeing red means you don't know if the test actually tests anything. | Always run the test, verify it fails, understand why it fails. |
| Over-engineering in green | Adding abstractions before they're needed slows the cycle and creates premature generalization. | Write stupid simple code in green. Let patterns emerge through multiple tests. |
"I'll write tests later" is a promise that's rarely kept. Under deadline pressure, tests are the first things cut. TDD's strength is that tests are written when the behavior is fresh in your mind and before the pressure mounts. The later you wait, the harder (and more costly) testing becomes.
The Red-Green-Refactor cycle is the fundamental heartbeat of Test-Driven Development. Let's consolidate the key lessons:
What's Next:
Now that you understand the mechanics of the TDD cycle, we'll explore why this discipline produces better designs. The next page examines TDD's benefits for design quality—how testing first forces decoupled, focused, and naturally modular code.
You now understand the Red-Green-Refactor cycle that forms the core rhythm of TDD. This isn't just a testing technique—it's a design discipline that produces better software through thousands of small, verified steps. Next, we'll see how this cycle directly improves the design quality of your code.