Skip to content

Test-Driven Development

Using TDD as a practical tool for running and validating code as you write it

Related Concepts: Unit Testing | Clean Architecture | Dependency Inversion

Table of Contents

  1. Why TDD: Running Code As You Write It
  2. The Red-Green-Refactor Cycle
  3. TDD and Design Pressure
  4. When TDD Shines
  5. When TDD Struggles
  6. The Danger of TDD Dogma
  7. Practical TDD

Why TDD: Running Code As You Write It

The Fundamental Problem

Every line of code you write needs to be executed to know if it works. You have three choices:

  1. Manual testing - Launch the app, click through screens, enter data
  2. Debug/REPL - Run snippets in isolation
  3. Automated tests - Write a test that exercises the code

Here's the reality: TDD is simply the fastest way to run and validate your code.

The Speed of Feedback

Consider writing a tax calculation function:

typescript
// Without TDD - Manual testing
function calculateTax(amount: number): number {
  // Write the function
  return amount * 0.08;
}

// Now what? 
// - Build the entire app?
// - Create a UI to input values?
// - Add console.logs?
// - Use a debugger?

With TDD:

typescript
// With TDD - Immediate validation
it('should calculate 8% tax', () => {
  expect(calculateTax(100)).toBe(8);
});

function calculateTax(amount: number): number {
  return amount * 0.08;
}

// Run test - instant feedback in milliseconds

The Value Proposition

TDD isn't about testing—it's about running code efficiently:

  • Immediate execution - Run code the moment you write it
  • Persistent validation - That test keeps validating as you refactor
  • Documented examples - Tests show how to use the code
  • Regression prevention - Free benefit, not the main goal

The test isn't the point. The point is having a fast, repeatable way to run your code.

The Red-Green-Refactor Cycle

The Rhythm of Development

TDD follows a simple three-step cycle:

┌─────────────┐
│   1. RED    │ Write a test that fails
│             │ (because the code doesn't exist)
└─────┬───────┘


┌─────────────┐
│  2. GREEN   │ Write just enough code to pass
│             │ (not the perfect solution)
└─────┬───────┘


┌─────────────┐
│ 3. REFACTOR │ Improve the code
│             │ (with tests as safety net)
└─────┬───────┘

      └──────► Repeat

Why "Just Enough" Matters

The "write just enough code to pass" rule serves a purpose:

typescript
// Test 1: Handle empty array
it('should return 0 for empty array', () => {
  expect(sum([])).toBe(0);
});

// Just enough to pass
function sum(numbers: number[]): number {
  return 0;  // Simplest thing that works
}

// Test 2: Handle single number
it('should return number for single element', () => {
  expect(sum([5])).toBe(5);
});

// Now we need real logic
function sum(numbers: number[]): number {
  return numbers.length === 0 ? 0 : numbers[0];
}

// Test 3: Handle multiple numbers
it('should sum multiple numbers', () => {
  expect(sum([1, 2, 3])).toBe(6);
});

// Finally, the full implementation
function sum(numbers: number[]): number {
  return numbers.reduce((a, b) => a + b, 0);
}

This incremental approach:

  • Prevents over-engineering
  • Ensures every line of code is justified by a test
  • Builds confidence through small steps

The Refactor Step

Refactoring is where design happens:

typescript
// After green, we might have:
class OrderService {
  calculateTotal(items: Item[]): number {
    let total = 0;
    for (const item of items) {
      total += item.price * item.quantity;
      if (item.quantity > 10) {
        total *= 0.9; // 10% bulk discount
      }
    }
    total *= 1.08; // tax
    return total;
  }
}

// Refactor with tests as safety net:
class OrderService {
  calculateTotal(items: Item[]): number {
    const subtotal = this.calculateSubtotal(items);
    const discounted = this.applyDiscounts(subtotal, items);
    return this.addTax(discounted);
  }
  
  private calculateSubtotal(items: Item[]): number {
    return items.reduce((sum, item) => 
      sum + item.price * item.quantity, 0);
  }
  
  private applyDiscounts(amount: number, items: Item[]): number {
    const hasBulkItems = items.some(item => item.quantity > 10);
    return hasBulkItems ? amount * 0.9 : amount;
  }
  
  private addTax(amount: number): number {
    return amount * 1.08;
  }
}

Tests give you the confidence to refactor aggressively.

TDD and Design Pressure

The Critical Distinction

"TDD doesn't force good design, it puts pressure toward good design."

This is crucial to understand. TDD is like a mirror—it shows you problems, but doesn't fix them.

How TDD Reveals Design Problems

When tests become painful, they're telling you something:

typescript
// Hard to test = Poor design
class EmailService {
  async sendOrderConfirmation(orderId: string): Promise<void> {
    // This is a nightmare to test
    const order = await db.query(`SELECT * FROM orders WHERE id = ?`, [orderId]);
    const user = await db.query(`SELECT * FROM users WHERE id = ?`, [order.userId]);
    const items = await db.query(`SELECT * FROM order_items WHERE order_id = ?`, [orderId]);
    
    const html = this.templateEngine.render('order-confirmation', {
      order, user, items
    });
    
    await this.smtpClient.send({
      to: user.email,
      subject: `Order ${orderId} Confirmation`,
      html
    });
    
    await db.query(`UPDATE orders SET email_sent = true WHERE id = ?`, [orderId]);
  }
}

// Test requires mocking the entire world
it('should send order confirmation', async () => {
  // 50 lines of mock setup...
});

The test pain reveals:

  • Too many responsibilities
  • Direct database access
  • No separation of concerns
  • Tight coupling to infrastructure

TDD Guides You Toward Better Design

The testing pain pushes you to refactor:

typescript
// Better design emerges from testing pressure
class OrderConfirmationUseCase {
  constructor(
    private orderRepo: OrderRepository,
    private emailService: EmailService
  ) {}
  
  async execute(orderId: string): Promise<void> {
    const orderData = await this.orderRepo.getOrderWithDetails(orderId);
    const email = this.buildConfirmationEmail(orderData);
    await this.emailService.send(email);
    await this.orderRepo.markEmailSent(orderId);
  }
  
  private buildConfirmationEmail(orderData: OrderData): Email {
    // Pure function - easy to test
    return {
      to: orderData.user.email,
      subject: `Order ${orderData.id} Confirmation`,
      template: 'order-confirmation',
      data: orderData
    };
  }
}

// Now testing is simple
it('should build correct email', () => {
  const email = useCase.buildConfirmationEmail(testOrderData);
  expect(email.to).toBe('user@example.com');
  expect(email.subject).toBe('Order 123 Confirmation');
});

But You Need Design Skills

TDD alone won't save you if you don't understand design principles:

typescript
// TDD without design knowledge leads to:
class UserService {
  constructor(
    private validator: Validator,
    private hasher: Hasher,
    private tokenGenerator: TokenGenerator,
    private emailer: Emailer,
    private logger: Logger,
    private cache: Cache,
    private metrics: Metrics
    // 10 more dependencies...
  ) {}
}

// "I'm doing TDD and dependency injection, why is this still hard?"

The problem isn't the testing—it's not knowing how to design cohesive, loosely coupled systems.

When TDD Shines

Bug Fixes

TDD is perfect for bug fixes:

typescript
// 1. Write a test that reproduces the bug
it('should handle negative numbers in percentage calculation', () => {
  expect(calculatePercentage(-100, 10)).toBe(-10);
  // This test fails, confirming the bug
});

// 2. Fix the bug
function calculatePercentage(value: number, percentage: number): number {
  return value * (percentage / 100); // Now handles negatives
}

// 3. Test passes, bug is fixed and won't regress

Algorithmic Code

Algorithms have clear inputs and outputs, perfect for TDD:

typescript
describe('Binary Search', () => {
  it('should find element in sorted array', () => {
    expect(binarySearch([1, 3, 5, 7, 9], 5)).toBe(2);
  });
  
  it('should return -1 for missing element', () => {
    expect(binarySearch([1, 3, 5, 7, 9], 4)).toBe(-1);
  });
  
  it('should handle empty array', () => {
    expect(binarySearch([], 5)).toBe(-1);
  });
});

// Build algorithm incrementally with test feedback

Exploring Design

When you're unsure how to structure code:

typescript
// Start with how you want to use it
it('should parse command with arguments', () => {
  const command = parseCommand('deploy --env=prod --force');
  expect(command.name).toBe('deploy');
  expect(command.flags.env).toBe('prod');
  expect(command.flags.force).toBe(true);
});

// The test drives the interface design

Learning New APIs

TDD helps you learn and document external APIs:

typescript
it('should fetch user from GitHub API', async () => {
  const user = await github.getUser('octocat');
  expect(user.login).toBe('octocat');
  expect(user.type).toBe('User');
});

// You're learning the API shape while writing code

When TDD Struggles

Exploratory Coding

When you don't know what you're building:

typescript
// Bad: Trying to TDD something you don't understand
it('should do something with data', () => {
  // What am I even testing?
});

// Better: Spike first, then TDD
// 1. Write throwaway code to explore
// 2. Understand the problem
// 3. Delete the spike
// 4. TDD the real solution

UI Development

Visual changes are hard to TDD:

typescript
// Pointless TDD for UI
it('should have blue button', () => {
  const button = render(<Button color="blue" />);
  expect(button.style.backgroundColor).toBe('blue');
});

// Better: Visual regression testing or manual review
// TDD the behavior, not the appearance
it('should call onClick when clicked', () => {
  const handleClick = jest.fn();
  const button = render(<Button onClick={handleClick} />);
  fireEvent.click(button);
  expect(handleClick).toHaveBeenCalled();
});

Infrastructure Code

Setting up test environments can be more complex than the code:

typescript
// Over-engineering test setup
describe('Kubernetes Deployment', () => {
  beforeAll(async () => {
    await setupTestCluster();
    await deployTestServices();
    await waitForPodsReady();
  });
  
  it('should deploy app', async () => {
    // Is this test worth the complexity?
  });
});

// Sometimes manual testing or staging environments are better

Simple CRUD

When tests duplicate implementation:

typescript
// Test adds no value
it('should save user', async () => {
  const user = { name: 'Alice' };
  await userService.save(user);
  const saved = await userService.findById(user.id);
  expect(saved.name).toBe('Alice');
});

// If save() just calls repository.save(), skip the unit test
// Integration test the actual database operation instead

The Danger of TDD Dogma

When Religion Replaces Reasoning

TDD zealotry creates problems:

  • "You must write tests first, always" - Sometimes exploring first is better
  • "100% TDD coverage" - Not all code benefits from TDD
  • "Never write code without a failing test" - Absolute rules ignore context
  • "If you're not doing TDD, you're not professional" - Gatekeeping drives people away

The Brittle Test Problem

Dogmatic TDD often creates brittle tests:

typescript
// Over-specified test from blind TDD
it('should process order', () => {
  const mockInventory = jest.fn();
  const mockPayment = jest.fn();
  const mockEmail = jest.fn();
  const mockLogger = jest.fn();
  
  service.processOrder(order);
  
  expect(mockInventory).toHaveBeenCalledTimes(1);
  expect(mockInventory).toHaveBeenCalledWith(order.items);
  expect(mockPayment).toHaveBeenCalledAfter(mockInventory);
  expect(mockEmail).toHaveBeenCalledAfter(mockPayment);
  expect(mockLogger).toHaveBeenCalledTimes(4);
  // Tests implementation, not behavior
});

These tests:

  • Break with any refactoring
  • Don't catch real bugs
  • Make developers hate testing

The Backlash Effect

As we've observed:

"Developers who have tried TDD and... ended up with brittle tests that never added any value. Now those developers are skeptical of any kind of test automation."

Bad TDD experiences create testing skeptics. It's better to be pragmatic than pure.

Practical TDD

Start Simple

Begin with the simplest possible test:

typescript
// Don't overthink the first test
it('should exist', () => {
  expect(Calculator).toBeDefined();
});

// Then build incrementally
it('should add two numbers', () => {
  expect(new Calculator().add(2, 3)).toBe(5);
});

Use TDD for Design Discovery

Let tests help you explore interfaces:

typescript
// Write the test you wish you had
it('should process payment', async () => {
  const result = await paymentProcessor.process({
    amount: 100,
    currency: 'USD',
    method: 'card'
  });
  
  expect(result.success).toBe(true);
  expect(result.transactionId).toBeDefined();
});

// The test reveals the interface you want

Know When to Stop

Not everything needs TDD:

typescript
// Skip TDD for:
- Getters/setters
- Simple delegation
- Framework configuration
- Exploratory spikes
- Pure UI layout

// Use TDD for:
- Business logic
- Algorithms
- Data transformation
- Complex state management
- Bug fixes

Focus on Value

The goal isn't TDD—it's working software:

typescript
// Bad: TDD for metrics
"We do TDD"
"100% test-first"
"All tests pass"
// But the software is still buggy and hard to change

// Good: TDD for outcomes
"Fast feedback while coding"
"Confidence to refactor"
"Documentation of intent"
"Prevented regression bugs"
// Software is reliable and maintainable

Combine Approaches

Use TDD as one tool among many:

  1. Spike to explore - Write throwaway code to understand the problem
  2. TDD the core logic - Use tests to drive the implementation
  3. Integration test the boundaries - Verify the pieces work together
  4. Manual test the UX - Ensure it feels right
  5. Monitor production - Catch what tests missed

The Bottom Line

TDD is fundamentally about running code as you write it. You're going to execute every line somehow—TDD just happens to be the fastest, most repeatable way to do it.

Key principles:

  1. You must run your code - TDD is the most efficient way
  2. Tests provide immediate feedback - Know it works instantly
  3. TDD reveals design problems - But doesn't fix them automatically
  4. Design skills are required - TDD + good design = great code
  5. Pragmatism over dogma - Use TDD when it helps
  6. The goal is working software - Not TDD compliance

Remember: TDD is a tool, not a religion. Use it to write better code faster, not to check boxes on a process chart.


For practical testing techniques, see our Unit Testing guide.