Loading content...
Reading about TDD and doing TDD are vastly different experiences. The concepts seem clear on paper, but applying them to real problems reveals nuances that theory can't capture. How do you handle ambiguous requirements? What happens when your tests lead you to an unexpected design? How do multiple TDD cycles compose into a complete feature?
This page bridges theory and practice through a complete feature development walkthrough. We'll build a realistic feature—a discount pricing engine—using pure TDD. You'll see not just the clean path but the messy moments: false starts, design pivots, and decisions that emerge from the process itself.
By the end of this page, you'll have witnessed TDD applied to a real feature from empty files to working code. You'll see how cycles accumulate, how design emerges, and how to navigate the inevitable surprises. This is TDD as practiced by experienced engineers, not TDD as presented in textbooks.
Business Context:
Our e-commerce platform needs a flexible discount system. Currently, discounts are hardcoded. The marketing team wants to create and apply various discount types without developer involvement.
Requirements:
These requirements are typical of real features: clear enough to start, ambiguous enough to require clarification as we build.
Our TDD Approach:
We'll build this feature test-first, letting the tests drive us toward a design. We won't plan the class structure upfront—we'll discover it.
Notice the requirements don't specify: What happens with negative percentages? Can discounts exceed the order total? Are there discount caps? These questions will arise during TDD, and we'll make decisions as we go—documenting them as tests.
Let's begin. We start with empty files and the simplest possible test.
Cycle 1: A discount exists and can be applied
Our first test establishes that discounts exist and can do something to a price. We don't know the full API yet—this test will help us discover it.
12345678910111213141516171819202122232425262728293031323334353637
// ═══════════════════════════════════════════════════════════// CYCLE 1: RED - The simplest discount test// ═══════════════════════════════════════════════════════════ describe('PercentageDiscount', () => { it('should reduce price by percentage', () => { // What do I want to write? Let's see... const discount = new PercentageDiscount(20); // 20% off const result = discount.apply(100); // Apply to $100 expect(result).toBe(80); // $80 after 20% off });}); // Run: ✗ ReferenceError: PercentageDiscount is not defined// Good! We failed for the right reason. // ═══════════════════════════════════════════════════════════// CYCLE 1: GREEN - Minimal implementation// ═══════════════════════════════════════════════════════════ class PercentageDiscount { constructor(private percentage: number) {} apply(price: number): number { return price - (price * this.percentage / 100); }} // Run: ✓ All tests pass // ═══════════════════════════════════════════════════════════// CYCLE 1: REFACTOR - Nothing to refactor yet// ═══════════════════════════════════════════════════════════ // The code is minimal and clear. Move on.Cycle 2: Fixed amount discount
We have percentage discounts. Now let's add fixed amount discounts. This test will reveal whether we need an abstraction to unify them.
12345678910111213141516171819202122232425262728293031323334353637383940
// ═══════════════════════════════════════════════════════════// CYCLE 2: RED - Fixed amount discount// ═══════════════════════════════════════════════════════════ describe('FixedAmountDiscount', () => { it('should reduce price by fixed amount', () => { const discount = new FixedAmountDiscount(15); // $15 off const result = discount.apply(100); // Apply to $100 expect(result).toBe(85); // $85 after $15 off });}); // Run: ✗ ReferenceError: FixedAmountDiscount is not defined // ═══════════════════════════════════════════════════════════// CYCLE 2: GREEN - Minimal implementation// ═══════════════════════════════════════════════════════════ class FixedAmountDiscount { constructor(private amount: number) {} apply(price: number): number { return price - this.amount; }} // Run: ✓ All tests pass // ═══════════════════════════════════════════════════════════// CYCLE 2: REFACTOR - Pattern emerging// ═══════════════════════════════════════════════════════════ // We have two classes with the same method signature: apply(price)// Should we create an interface? Let's wait - "Rule of Three"// Two isn't enough to know the pattern is stable. // For now, minimal refactor: just ensure both are exportedexport { PercentageDiscount, FixedAmountDiscount };Cycle 3: Edge case - Discount exceeds price
What happens when a discount is larger than the price? The requirements say "don't go below zero." Let's test this.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// ═══════════════════════════════════════════════════════════// CYCLE 3: RED - Discount cannot reduce below zero// ═══════════════════════════════════════════════════════════ describe('FixedAmountDiscount', () => { // ...existing tests... it('should not reduce price below zero', () => { const discount = new FixedAmountDiscount(150); // $150 off const result = discount.apply(100); // Apply to $100 // Should be 0, not -50! expect(result).toBe(0); });}); // Run: ✗ Expected: 0, Received: -50// Good! Our test caught the edge case. // ═══════════════════════════════════════════════════════════// CYCLE 3: GREEN - Add minimum bound// ═══════════════════════════════════════════════════════════ class FixedAmountDiscount { constructor(private amount: number) {} apply(price: number): number { return Math.max(0, price - this.amount); }} // Run: ✓ All tests pass // ═══════════════════════════════════════════════════════════// Wait - does PercentageDiscount need this too?// Let's add a test to be sure// ═══════════════════════════════════════════════════════════ describe('PercentageDiscount', () => { // ...existing tests... it('should handle 100% discount', () => { const discount = new PercentageDiscount(100); expect(discount.apply(50)).toBe(0); }); it('should handle percentage over 100%', () => { // Can this even happen? Let's define the behavior const discount = new PercentageDiscount(150); expect(discount.apply(100)).toBe(0); // Not -50! });}); // Run: ✗ Expected: 0, Received: -50// Percentage discount has the same bug! // GREEN: Fix itclass PercentageDiscount { constructor(private percentage: number) {} apply(price: number): number { const reduction = price * this.percentage / 100; return Math.max(0, price - reduction); }} // Run: ✓ All tests passNotice how writing the edge case test for one class prompted us to check the other. TDD naturally surfaces these connections. Without tests, this bug might have shipped and only appeared when a customer tried to use a huge discount code.
Cycle 4: Multiple discounts on an order
The requirements say we need to support multiple discounts. Now the design gets interesting. How should we combine discounts? This test will force us to create an abstraction.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// ═══════════════════════════════════════════════════════════// CYCLE 4: RED - Combining multiple discounts// ═══════════════════════════════════════════════════════════ describe('CompositeDiscount', () => { it('should apply multiple discounts in sequence', () => { // We want to combine a percentage and a fixed discount // What API would be natural? const tenPercentOff = new PercentageDiscount(10); const fiveDollarsOff = new FixedAmountDiscount(5); // A composite that holds both const combined = new CompositeDiscount([ tenPercentOff, fiveDollarsOff ]); // Apply to $100: // 10% off = $90, then $5 off = $85 const result = combined.apply(100); expect(result).toBe(85); });}); // Run: ✗ ReferenceError: CompositeDiscount is not defined // But wait - how does CompositeDiscount know to call .apply()?// Both our discount classes have apply(), but there's no shared type!// TDD is revealing the need for an INTERFACE. // ═══════════════════════════════════════════════════════════// REFACTOR BEFORE GREEN: Create the interface// ═══════════════════════════════════════════════════════════ // The tests are asking for this interface:interface Discount { apply(price: number): number;} class PercentageDiscount implements Discount { constructor(private percentage: number) {} apply(price: number): number { const reduction = price * this.percentage / 100; return Math.max(0, price - reduction); }} class FixedAmountDiscount implements Discount { constructor(private amount: number) {} apply(price: number): number { return Math.max(0, price - this.amount); }} // ═══════════════════════════════════════════════════════════// CYCLE 4: GREEN - CompositeDiscount// ═══════════════════════════════════════════════════════════ class CompositeDiscount implements Discount { constructor(private discounts: Discount[]) {} apply(price: number): number { return this.discounts.reduce( (currentPrice, discount) => discount.apply(currentPrice), price ); }} // Run: ✓ All tests pass! // Notice: CompositeDiscount is ALSO a Discount!// This is the Composite pattern, discovered through TDD.We just discovered the Composite pattern organically! We didn't plan to use it—the tests led us to it. This is TDD at its best: design patterns emerge from the pressure of testability and composition, not from upfront architecture.
Cycle 5: Verify discount order matters
Our CompositeDiscount applies discounts in sequence. But does order matter? Let's test and understand.
12345678910111213141516171819202122232425262728293031
// ═══════════════════════════════════════════════════════════// CYCLE 5: Exploring - Does discount order matter?// ═══════════════════════════════════════════════════════════ describe('CompositeDiscount ordering', () => { it('should produce different results with different orders', () => { const tenPercent = new PercentageDiscount(10); const tenDollars = new FixedAmountDiscount(10); // Order A: Percentage first, then fixed const orderA = new CompositeDiscount([tenPercent, tenDollars]); // $100 → 10% off = $90 → $10 off = $80 // Order B: Fixed first, then percentage const orderB = new CompositeDiscount([tenDollars, tenPercent]); // $100 → $10 off = $90 → 10% off = $81 // These should NOT be equal expect(orderA.apply(100)).toBe(80); expect(orderB.apply(100)).toBe(81); expect(orderA.apply(100)).not.toBe(orderB.apply(100)); });}); // Run: ✓ All tests pass// Order DOES matter. This is important domain knowledge now captured in tests. // ═══════════════════════════════════════════════════════════// This test documents business logic!// If someone asks "does discount order matter?", point them to this test.// ═══════════════════════════════════════════════════════════A wild requirement appears!
Marketing now wants "Buy One Get One" discounts—if you buy product X, you get product Y free. This is fundamentally different from our current discounts, which just reduce a price.
TDD Decision Point:
Should we extend our current structure or create something new? Let's write a test and see what the design tells us.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
// ═══════════════════════════════════════════════════════════// CYCLE 6: Attempting BOGO with current structure// ═══════════════════════════════════════════════════════════ describe('BuyOneGetOneFree', () => { it('should make second qualifying item free', () => { // Let's try to use the Discount interface... const bogo = new BuyOneGetOneFree('widget'); // But wait - apply() takes a price, not an order! // How would BOGO know which items are in the cart? // This doesn't work: // bogo.apply(100); // 100 what? Items? Total? // The test is telling us: BOGO doesn't fit the Discount interface! });}); // ═══════════════════════════════════════════════════════════// Learning from the failed test// ═══════════════════════════════════════════════════════════ // BOGO needs ORDER information, not just a price.// Our Discount interface is too narrow for this use case. // We have options:// 1. Expand Discount to take Order instead of price (breaking change!)// 2. Keep Discount for price-based discounts, create separate OrderPromotion// 3. Create a more abstract Promotion interface with specialized types // Let's explore option 2 - it's least disruptive and follows SRP interface Discount { apply(price: number): number; // Keep as-is} interface OrderPromotion { apply(order: Order): Order; // New interface for order-level effects} // ═══════════════════════════════════════════════════════════// CYCLE 6 (restart): BOGO as OrderPromotion// ═══════════════════════════════════════════════════════════ describe('BuyOneGetOneFree', () => { it('should zero price of cheapest qualifying item when pair exists', () => { const order = new Order([ new LineItem('widget', 50), new LineItem('widget', 50), ]); const bogo = new BuyOneGetOneFree('widget'); const result = bogo.apply(order); // One widget is free, so total is 50 instead of 100 expect(result.getTotal()).toBe(50); }); it('should not apply when only one qualifying item', () => { const order = new Order([ new LineItem('widget', 50), // Only one widget new LineItem('gadget', 30), // Doesn't count ]); const bogo = new BuyOneGetOneFree('widget'); const result = bogo.apply(order); expect(result.getTotal()).toBe(80); // No discount }); it('should apply multiple times for multiple pairs', () => { const order = new Order([ new LineItem('widget', 50), new LineItem('widget', 40), new LineItem('widget', 30), new LineItem('widget', 20), ]); const bogo = new BuyOneGetOneFree('widget'); const result = bogo.apply(order); // 4 widgets = 2 pairs, 2 free (cheapest in each pair) // Pair 1: 50 + 40 → 40 free → 50 // Pair 2: 30 + 20 → 20 free → 30 // Total: 80 expect(result.getTotal()).toBe(80); });}); // GREEN: Implementclass BuyOneGetOneFree implements OrderPromotion { constructor(private productId: string) {} apply(order: Order): Order { const qualifying = order.items .filter(item => item.productId === this.productId) .sort((a, b) => b.price - a.price); // Descending by price const pairs = Math.floor(qualifying.length / 2); const freeIndices = new Set<number>(); // Every other item (starting from second) is free for (let i = 1; i <= pairs * 2; i += 2) { freeIndices.add(qualifying[i].id); } const adjustedItems = order.items.map(item => freeIndices.has(item.id) ? item.withPrice(0) : item ); return new Order(adjustedItems); }}The failed test wasn't failure—it was valuable information! It revealed that our Discount interface is for price transformations, but BOGO needs order transformations. Trying to force BOGO into Discount would have degraded the design. TDD helped us see the distinction.
We have building blocks: Discount for price transformations, OrderPromotion for order transformations. Now we need to orchestrate them into a complete pricing engine.
The Pricing Pipeline:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
// ═══════════════════════════════════════════════════════════// CYCLE 7: PricingEngine - Orchestrating everything// ═══════════════════════════════════════════════════════════ describe('PricingEngine', () => { it('should return order total when no promotions or discounts', () => { const order = new Order([ new LineItem('widget', 50), new LineItem('gadget', 30), ]); const engine = new PricingEngine(); expect(engine.calculateTotal(order)).toBe(80); }); it('should apply discount to order total', () => { const order = new Order([ new LineItem('widget', 100), ]); const engine = new PricingEngine() .withDiscount(new PercentageDiscount(10)); expect(engine.calculateTotal(order)).toBe(90); }); it('should apply promotion before calculating discount', () => { // BOGO makes one widget free, THEN we apply 10% off const order = new Order([ new LineItem('widget', 100), new LineItem('widget', 100), ]); const engine = new PricingEngine() .withPromotion(new BuyOneGetOneFree('widget')) .withDiscount(new PercentageDiscount(10)); // Original: 200 // After BOGO: 100 (one free) // After 10%: 90 expect(engine.calculateTotal(order)).toBe(90); }); it('should apply multiple promotions in order', () => { const order = new Order([ new LineItem('widget', 100), new LineItem('widget', 80), new LineItem('gadget', 50), new LineItem('gadget', 40), ]); const engine = new PricingEngine() .withPromotion(new BuyOneGetOneFree('widget')) .withPromotion(new BuyOneGetOneFree('gadget')); // Widgets: 100 + 80, one free = 100 // Gadgets: 50 + 40, one free = 50 // Total: 150 expect(engine.calculateTotal(order)).toBe(150); }); it('should apply multiple discounts in order', () => { const order = new Order([new LineItem('item', 100)]); const engine = new PricingEngine() .withDiscount(new PercentageDiscount(10)) // 100 → 90 .withDiscount(new FixedAmountDiscount(5)); // 90 → 85 expect(engine.calculateTotal(order)).toBe(85); });}); // ═══════════════════════════════════════════════════════════// GREEN: Implement PricingEngine// ═══════════════════════════════════════════════════════════ class PricingEngine { private promotions: OrderPromotion[] = []; private discounts: Discount[] = []; withPromotion(promotion: OrderPromotion): PricingEngine { this.promotions.push(promotion); return this; // Fluent API } withDiscount(discount: Discount): PricingEngine { this.discounts.push(discount); return this; // Fluent API } calculateTotal(order: Order): number { // Step 1: Apply all promotions const promotedOrder = this.applyPromotions(order); // Step 2: Get subtotal const subtotal = promotedOrder.getTotal(); // Step 3: Apply all discounts return this.applyDiscounts(subtotal); } private applyPromotions(order: Order): Order { return this.promotions.reduce( (currentOrder, promo) => promo.apply(currentOrder), order ); } private applyDiscounts(price: number): number { return this.discounts.reduce( (currentPrice, discount) => discount.apply(currentPrice), price ); }} // ═══════════════════════════════════════════════════════════// REFACTOR: Clean up fluent API// ═══════════════════════════════════════════════════════════ // The engine is immutable-ish (returns this but mutates)// Let's make it truly immutable for predictability: class PricingEngine { constructor( private readonly promotions: OrderPromotion[] = [], private readonly discounts: Discount[] = [] ) {} withPromotion(promotion: OrderPromotion): PricingEngine { return new PricingEngine( [...this.promotions, promotion], this.discounts ); } withDiscount(discount: Discount): PricingEngine { return new PricingEngine( this.promotions, [...this.discounts, discount] ); } // calculateTotal remains the same...} // Run: ✓ All tests still pass!// Refactoring to immutability didn't break anything.Let's step back and see what emerged from our TDD process. We didn't design this upfront—the tests drove us here.
12345678910111213141516171819202122232425262728293031323334353637383940
┌────────────────────────────────────────────────────────────┐│ PRICING ENGINE ││ ││ ┌─────────────────────┐ ┌─────────────────────┐ ││ │ OrderPromotion │ │ Discount │ ││ │ ─────────────────── │ │ ─────────────────── │ ││ │ apply(Order): Order │ │ apply(price): price │ ││ └─────────┬───────────┘ └──────────┬──────────┘ ││ │ │ ││ ┌───────┴───────┐ ┌───────┴───────┐ ││ │ │ │ │ ││ ┌─▼───────────┐ ┌─▼───────┐ ┌▼────────────┐ ┌▼──────────┐ ││ │ BuyOneGet │ │ Future │ │ Percentage │ │ FixedAmt │ ││ │ OneFree │ │ Promos │ │ Discount │ │ Discount │ ││ └─────────────┘ └─────────┘ └─────────────┘ └───────────┘ ││ │ ││ ┌──────────▼──────────┐ ││ │ CompositeDiscount │ ││ │ (Also a Discount!) │ ││ └─────────────────────┘ ││ ││ Pipeline: Order → Promotions → Subtotal → Discounts → $ │└────────────────────────────────────────────────────────────┘ DESIGN PATTERNS DISCOVERED:• Strategy Pattern - Discount and OrderPromotion interfaces• Composite Pattern - CompositeDiscount • Pipeline Pattern - PricingEngine's calculation flow• Fluent Builder - PricingEngine.withDiscount().withPromotion() SOLID PRINCIPLES ACHIEVED:• Single Responsibility - Each class does one thing• Open/Closed - Add new discounts without changing engine• Liskov Substitution - All discounts are interchangeable• Interface Segregation - Discount vs OrderPromotion split• Dependency Inversion - Engine depends on interfaces Total Tests: 15+Total Lines of Production Code: ~100All functionality verified by executable specificationsWe didn't plan Strategy, Composite, or Pipeline patterns. We didn't consciously apply SOLID principles. We just wrote tests, made them pass, and refactored. The architecture emerged from the pressure of testability and the continuous improvement of refactoring.
Looking back at our development process, several meta-lessons emerge about TDD in practice:
| Metric | Value | Observation |
|---|---|---|
| Total TDD Cycles | 15+ | Small, focused iterations |
| Average Cycle Time | 3-5 minutes | Fast feedback loops |
| Interfaces Discovered | 3 | Discount, OrderPromotion, Order |
| Classes Created | 6 | All designed by tests |
| Design Patterns Used | 3+ | All emerged naturally |
| Edge Cases Caught | 4+ | Below zero, ordering, pairs, etc. |
| Refactorings Performed | Many | All with test safety net |
We've walked through a complete feature development using TDD. The journey revealed TDD's power not just as a testing technique, but as a design discipline. Let's consolidate the key insights:
Module Complete:
You've now mastered the fundamentals of Test-Driven Development—the Red-Green-Refactor cycle, its benefits for design, techniques for writing tests first, and how it plays out in real development. TDD is a skill that improves with practice. Each project you build test-first will feel more natural, and the designs you produce will continue to improve.
Congratulations! You've completed the Test-Driven Development module. You understand the TDD cycle, its design benefits, how to write tests first, and how real features emerge from many small cycles. TDD isn't just a technique—it's a fundamentally different way of building software that produces better designs, comprehensive tests, and evolvable code.