Appearance
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
- Why TDD: Running Code As You Write It
- The Red-Green-Refactor Cycle
- TDD and Design Pressure
- When TDD Shines
- When TDD Struggles
- The Danger of TDD Dogma
- 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:
- Manual testing - Launch the app, click through screens, enter data
- Debug/REPL - Run snippets in isolation
- 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 millisecondsThe 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)
└─────┬───────┘
│
└──────► RepeatWhy "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 regressAlgorithmic 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 feedbackExploring 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 designLearning 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 codeWhen 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 solutionUI 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 betterSimple 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 insteadThe 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 wantKnow 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 fixesFocus 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 maintainableCombine Approaches
Use TDD as one tool among many:
- Spike to explore - Write throwaway code to understand the problem
- TDD the core logic - Use tests to drive the implementation
- Integration test the boundaries - Verify the pieces work together
- Manual test the UX - Ensure it feels right
- 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:
- You must run your code - TDD is the most efficient way
- Tests provide immediate feedback - Know it works instantly
- TDD reveals design problems - But doesn't fix them automatically
- Design skills are required - TDD + good design = great code
- Pragmatism over dogma - Use TDD when it helps
- 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.