A

Test-Driven Development (TDD)

testingtddbest-practicesmethodologyunit-testing

Test-Driven Development (TDD)

Test-Driven Development is a software development approach where you write tests before writing the code that makes them pass. It's a discipline that changes how you think about code—forcing you to consider interface, behavior, and edge cases before implementation. TDD isn't just about testing; it's a design technique that produces cleaner, more modular code.

The Red-Green-Refactor Cycle

TDD follows a strict three-phase cycle:

┌─────────────────────────────────────────────────────────┐
│                                                         │
│   ┌─────────┐     ┌─────────┐     ┌──────────┐        │
│   │   RED   │────▶│  GREEN  │────▶│ REFACTOR │───┐    │
│   │  Write  │     │  Make   │     │  Clean   │   │    │
│   │ failing │     │   it    │     │   up     │   │    │
│   │  test   │     │  pass   │     │  code    │   │    │
│   └─────────┘     └─────────┘     └──────────┘   │    │
│        ▲                                          │    │
│        └──────────────────────────────────────────┘    │
│                                                         │
└─────────────────────────────────────────────────────────┘

1. Red: Write a Failing Test

Write a test for functionality that doesn't exist yet. Run it and watch it fail.

// src/cart.test.ts
import { describe, it, expect } from 'vitest';
import { ShoppingCart } from './cart';

describe('ShoppingCart', () => {
  it('calculates total for items in cart', () => {
    const cart = new ShoppingCart();
    cart.addItem({ id: '1', name: 'Widget', price: 9.99 });
    cart.addItem({ id: '2', name: 'Gadget', price: 14.99 });

    expect(cart.getTotal()).toBe(24.98);
  });
});

Run the test:

$ pnpm test

FAIL  src/cart.test.ts
  ShoppingCart
    ✕ calculates total for items in cart

    Error: Cannot find module './cart'

The test fails because ShoppingCart doesn't exist. This is exactly what we want—a failing test that defines our target.

2. Green: Make It Pass

Write the minimum code necessary to make the test pass. Don't optimize, don't add features—just make it green.

// src/cart.ts
interface CartItem {
  id: string;
  name: string;
  price: number;
}

export class ShoppingCart {
  private items: CartItem[] = [];

  addItem(item: CartItem): void {
    this.items.push(item);
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}

Run the test:

$ pnpm test

PASS  src/cart.test.ts
  ShoppingCart
    ✓ calculates total for items in cart

3. Refactor: Clean Up

Now that tests pass, improve the code without changing behavior. The tests protect you from breaking anything.

// src/cart.ts
interface CartItem {
  id: string;
  name: string;
  price: number;
}

export class ShoppingCart {
  private items: Map<string, CartItem> = new Map();

  addItem(item: CartItem): void {
    this.items.set(item.id, item);
  }

  getTotal(): number {
    return Array.from(this.items.values())
      .reduce((sum, item) => sum + item.price, 0);
  }
}

Run tests after refactoring to ensure nothing broke:

$ pnpm test

PASS  src/cart.test.ts

Continue the Cycle

Add the next test, watch it fail, make it pass, refactor:

it('removes item from cart', () => {
  const cart = new ShoppingCart();
  cart.addItem({ id: '1', name: 'Widget', price: 9.99 });
  cart.removeItem('1');

  expect(cart.getTotal()).toBe(0);
});

Walkthrough: Building a Price Calculator

Let's build a realistic example: a pricing service that calculates discounts.

Requirements

  • Calculate price after discount percentage
  • Handle bulk discounts (buy X get Y% off)
  • Apply coupon codes
  • Round to 2 decimal places

Step 1: Basic Price Calculation

Red: Write the first test.

// src/pricing.test.ts
import { describe, it, expect } from 'vitest';
import { PricingService } from './pricing';

describe('PricingService', () => {
  describe('calculatePrice', () => {
    it('returns original price with no discount', () => {
      const service = new PricingService();
      const result = service.calculatePrice(100, { discountPercent: 0 });
      expect(result).toBe(100);
    });
  });
});

Green: Implement minimum code.

// src/pricing.ts
interface PricingOptions {
  discountPercent: number;
}

export class PricingService {
  calculatePrice(basePrice: number, options: PricingOptions): number {
    return basePrice;
  }
}

Test passes. We haven't even used discountPercent yet—that's fine. We write the simplest code that passes.

Step 2: Apply Discount

Red: Add a test that requires discount logic.

it('applies percentage discount', () => {
  const service = new PricingService();
  const result = service.calculatePrice(100, { discountPercent: 20 });
  expect(result).toBe(80);
});

Green: Implement discount.

calculatePrice(basePrice: number, options: PricingOptions): number {
  const discount = basePrice * (options.discountPercent / 100);
  return basePrice - discount;
}

Step 3: Handle Edge Cases

Red: What about floating-point precision?

it('rounds to 2 decimal places', () => {
  const service = new PricingService();
  // 33.33% of 100 = 66.666...
  const result = service.calculatePrice(100, { discountPercent: 33.33 });
  expect(result).toBe(66.67);
});

Green: Add rounding.

calculatePrice(basePrice: number, options: PricingOptions): number {
  const discount = basePrice * (options.discountPercent / 100);
  const finalPrice = basePrice - discount;
  return Math.round(finalPrice * 100) / 100;
}

Step 4: Bulk Discounts

Red: New requirement—bulk pricing.

describe('bulk discounts', () => {
  it('applies bulk discount when quantity threshold met', () => {
    const service = new PricingService();
    // Buy 5 or more, get 10% off
    service.setBulkDiscount({ minQuantity: 5, discountPercent: 10 });

    const result = service.calculatePrice(100, {
      discountPercent: 0,
      quantity: 5,
    });

    expect(result).toBe(450); // 5 × 100 × 0.9 = 450
  });

  it('does not apply bulk discount below threshold', () => {
    const service = new PricingService();
    service.setBulkDiscount({ minQuantity: 5, discountPercent: 10 });

    const result = service.calculatePrice(100, {
      discountPercent: 0,
      quantity: 4,
    });

    expect(result).toBe(400); // 4 × 100 = 400, no discount
  });
});

Green: Extend the implementation.

interface PricingOptions {
  discountPercent: number;
  quantity?: number;
}

interface BulkDiscount {
  minQuantity: number;
  discountPercent: number;
}

export class PricingService {
  private bulkDiscount: BulkDiscount | null = null;

  setBulkDiscount(discount: BulkDiscount): void {
    this.bulkDiscount = discount;
  }

  calculatePrice(basePrice: number, options: PricingOptions): number {
    const quantity = options.quantity ?? 1;
    let subtotal = basePrice * quantity;

    // Apply bulk discount if eligible
    if (this.bulkDiscount && quantity >= this.bulkDiscount.minQuantity) {
      subtotal *= (1 - this.bulkDiscount.discountPercent / 100);
    }

    // Apply additional discount
    const discount = subtotal * (options.discountPercent / 100);
    const finalPrice = subtotal - discount;

    return Math.round(finalPrice * 100) / 100;
  }
}

Step 5: Refactor

Our test suite now protects us. Let's clean up:

// src/pricing.ts
interface PricingOptions {
  discountPercent?: number;
  quantity?: number;
}

interface BulkDiscount {
  minQuantity: number;
  discountPercent: number;
}

export class PricingService {
  private bulkDiscount: BulkDiscount | null = null;

  setBulkDiscount(discount: BulkDiscount): void {
    this.bulkDiscount = discount;
  }

  calculatePrice(basePrice: number, options: PricingOptions = {}): number {
    const { discountPercent = 0, quantity = 1 } = options;

    const subtotal = this.calculateSubtotal(basePrice, quantity);
    const afterBulk = this.applyBulkDiscount(subtotal, quantity);
    const afterDiscount = this.applyDiscount(afterBulk, discountPercent);

    return this.roundPrice(afterDiscount);
  }

  private calculateSubtotal(price: number, quantity: number): number {
    return price * quantity;
  }

  private applyBulkDiscount(amount: number, quantity: number): number {
    if (!this.bulkDiscount || quantity < this.bulkDiscount.minQuantity) {
      return amount;
    }
    return amount * (1 - this.bulkDiscount.discountPercent / 100);
  }

  private applyDiscount(amount: number, percent: number): number {
    return amount * (1 - percent / 100);
  }

  private roundPrice(amount: number): number {
    return Math.round(amount * 100) / 100;
  }
}

All tests still pass. The code is cleaner and more maintainable.

When TDD Shines

TDD works best in specific contexts:

Clear Requirements

When you know what the code should do, TDD helps you express it precisely:

// Clear requirement: validate email format
it('accepts valid email addresses', () => {
  expect(isValidEmail('[email protected]')).toBe(true);
  expect(isValidEmail('[email protected]')).toBe(true);
});

it('rejects invalid email addresses', () => {
  expect(isValidEmail('not-an-email')).toBe(false);
  expect(isValidEmail('@missing-local.com')).toBe(false);
  expect(isValidEmail('missing@domain')).toBe(false);
});

Complex Business Logic

Logic with many rules benefits from tests that document each rule:

describe('loan eligibility', () => {
  it('requires minimum credit score of 650', () => {});
  it('requires debt-to-income ratio below 43%', () => {});
  it('requires minimum 3% down payment', () => {});
  it('allows exceptions for first-time buyers', () => {});
});

Bug Fixes

Write a test that reproduces the bug before fixing it:

it('handles negative quantities (bug #1234)', () => {
  // This used to throw an unhandled error
  const cart = new ShoppingCart();
  expect(() => cart.setQuantity('item-1', -1)).toThrow('Quantity must be positive');
});

Refactoring

Tests let you refactor with confidence:

// Before: tests pass with original implementation
// After: tests still pass with refactored code
// If tests fail, the refactor broke something

When TDD Slows You Down

TDD isn't always the right approach:

Exploratory Work

When you're unsure what you're building:

// Don't TDD this yet
async function experimentWithNewApi() {
  // Trying different approaches
  // Interface will change multiple times
  // Test would be rewritten constantly
}

Write tests after the design stabilizes.

UI Iteration

Visual components evolve through iteration:

// Component structure changes constantly during design phase
// Testing too early creates maintenance burden
// Better: wait until component behavior is stable

Prototypes

Code you might throw away:

// Spike to evaluate feasibility
// If it works, write tests before production code
// If not, delete and try another approach

Learning New Technology

When you don't know the API yet, write code first to learn, then add tests.

TDD vs. Writing Tests After

Pure TDD

  1. Write failing test
  2. Write minimum code to pass
  3. Refactor
  4. Repeat

Pros:

  • Forces you to think about interface first
  • Ensures testable code (you can't write untestable code)
  • High test coverage by default
  • Tests document intended behavior

Cons:

  • Slower initial development
  • Requires discipline
  • Can feel awkward for exploratory work

Tests After

  1. Write code
  2. Write tests
  3. Fix bugs found by tests
  4. Refactor

Pros:

  • Faster for exploratory development
  • Natural for UI work
  • Lower barrier to entry

Cons:

  • Code may be hard to test
  • Tests may miss edge cases
  • Tests may test implementation, not behavior

Pragmatic Middle Ground

Most experienced developers use a hybrid:

  1. TDD for business logic: Core algorithms, validation, calculations
  2. Tests after for UI: Components, layouts, visual elements
  3. TDD for bug fixes: Always reproduce the bug in a test first
  4. Spike first for unknowns: Experiment, then write tests for the solution

TDD with TypeScript

TypeScript's type system provides a form of "tests" via static analysis:

// Types catch many bugs at compile time
interface User {
  id: string;
  email: string;
  role: 'admin' | 'user';
}

function createUser(data: User): User {
  // TypeScript ensures data has correct shape
  return data;
}

// These are compile-time errors, not runtime tests
createUser({ id: '1' });  // Error: missing email, role
createUser({ id: '1', email: 'x', role: 'superuser' });  // Error: invalid role

Types as the first test:

  1. Define types that express your domain
  2. TypeScript verifies structural correctness
  3. Unit tests verify behavioral correctness
// Type system handles structure
interface CreateOrderInput {
  items: Array<{ productId: string; quantity: number }>;
  customerId: string;
  shippingAddress: Address;
}

// Tests handle behavior
describe('createOrder', () => {
  it('calculates order total from item prices', () => {});
  it('applies customer discount', () => {});
  it('validates shipping address format', () => {});
});

Common TDD Mistakes

Writing Too Much Test First

// ❌ Don't write all tests at once
describe('UserService', () => {
  it('creates user', () => {});
  it('updates user', () => {});
  it('deletes user', () => {});
  it('finds user by email', () => {});
  it('validates email format', () => {});
  // ... 20 more tests
});

// ✅ Write one test, make it pass, repeat
describe('UserService', () => {
  it('creates user', () => {
    // Start here
  });
});

Testing Implementation Details

// ❌ Tests internal implementation
it('stores user in _users array', () => {
  const service = new UserService();
  service.createUser({ name: 'John' });
  expect(service._users.length).toBe(1);  // Exposes internal state
});

// ✅ Tests behavior
it('can retrieve created user', () => {
  const service = new UserService();
  const user = service.createUser({ name: 'John' });
  expect(service.getUser(user.id)).toEqual(user);
});

Not Refactoring

The refactor step is crucial—don't skip it:

// After making tests pass, ask:
// - Is this code clear?
// - Are there duplicate patterns?
// - Can I extract a helper?
// - Are names descriptive?

Over-Mocking

// ❌ Mocking everything
it('creates user', () => {
  const mockDb = { save: vi.fn() };
  const mockValidator = { validate: vi.fn().mockReturnValue(true) };
  const mockHasher = { hash: vi.fn().mockReturnValue('hash') };
  // Test doesn't verify real behavior
});

// ✅ Use real implementations where practical
it('creates user', () => {
  const db = new InMemoryDatabase();  // Real, but in-memory
  const service = new UserService(db);
  // Tests real integration
});

See Also

Last updated: March 23, 2026