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
- Write failing test
- Write minimum code to pass
- Refactor
- 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
- Write code
- Write tests
- Fix bugs found by tests
- 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:
- TDD for business logic: Core algorithms, validation, calculations
- Tests after for UI: Components, layouts, visual elements
- TDD for bug fixes: Always reproduce the bug in a test first
- 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:
- Define types that express your domain
- TypeScript verifies structural correctness
- 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
- Testing Overview - Testing philosophy and types
- Vitest - Fast test runner for TDD workflow
- Jest - Established test framework
- Mocking Strategies - When and how to mock
- Kent Beck's "Test-Driven Development: By Example" - The original TDD book