Introduction
Test-Driven Development (TDD) is a software development approach where tests are written before the implementation code. This practice leads to better design, fewer bugs, and more maintainable codebases. In this comprehensive guide, we'll explore TDD workflows, patterns, and best practices used by professional developers in 2025.
The TDD Cycle: Red-Green-Refactor
Understanding the Cycle
TDD follows a simple three-step cycle:
- 1Red: Write a failing test
- 2Green: Write just enough code to pass the test
- 3Refactor: Improve the code while keeping tests green
┌─────────────────────────────────────┐
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ RED │ -> │ GREEN │ -> │ REFACTOR │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ ^ │ │ │
│ └──────────────┴───────────────┘ │
│ │
└─────────────────────────────────────────────┘Example: Building a Calculator
Let's build a simple calculator using TDD:
Step 1: Red - Write a Failing Test
// calculator.test.ts
import { Calculator } from './calculator';
describe('Calculator', () => {
test('should add two numbers', () => {
const calculator = new Calculator();
expect(calculator.add(2, 3)).toBe(5);
});
});Run the test - it fails because we haven't implemented anything yet.
Step 2: Green - Write Minimal Implementation
// calculator.ts
export class Calculator {
add(a: number, b: number): number {
return a + b;
}
}Run the test - it passes!
Step 3: Refactor - Improve Code
The code is already simple, so no refactoring needed. Let's add more functionality.
// calculator.test.ts
test('should subtract two numbers', () => {
const calculator = new Calculator();
expect(calculator.subtract(5, 3)).toBe(2);
});
test('should multiply two numbers', () => {
const calculator = new Calculator();
expect(calculator.multiply(3, 4)).toBe(12);
});
test('should divide two numbers', () => {
const calculator = new Calculator();
expect(calculator.divide(10, 2)).toBe(5);
});
test('should throw error when dividing by zero', () => {
const calculator = new Calculator();
expect(() => calculator.divide(10, 0)).toThrow('Division by zero');
});// calculator.ts
export class Calculator {
add(a: number, b: number): number {
return a + b;
}
subtract(a: number, b: number): number {
return a - b;
}
multiply(a: number, b: number): number {
return a * b;
}
divide(a: number, b: number): number {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
}TDD Patterns and Practices
Baby Steps
Work in small, manageable increments:
// Step 1: Test for empty string
test('should return 0 for empty string', () => {
expect(add('')).toBe(0);
});
// Implementation
function add(numbers: string): number {
return 0;
}
// Step 2: Test for single number
test('should return number for single number string', () => {
expect(add('5')).toBe(5);
});
// Implementation
function add(numbers: string): number {
return numbers ? parseInt(numbers) : 0;
}
// Step 3: Test for two numbers
test('should return sum of two numbers', () => {
expect(add('1,2')).toBe(3);
});
// Implementation
function add(numbers: string): number {
if (!numbers) return 0;
const nums = numbers.split(',').map(n => parseInt(n));
return nums.reduce((sum, n) => sum + n, 0);
}Triangulation
Use multiple tests to drive general implementation:
// Test 1: Specific case
test('should format 1 as "1st"', () => {
expect(formatOrdinal(1)).toBe('1st');
});
// Implementation
function formatOrdinal(n: number): string {
return '1st';
}
// Test 2: Different case
test('should format 2 as "2nd"', () => {
expect(formatOrdinal(2)).toBe('2nd');
});
// This forces us to generalize
function formatOrdinal(n: number): string {
const suffixes = ['th', 'st', 'nd', 'rd', 'th'];
const remainder = n % 100;
const suffix = suffixes[remainder] || suffixes[0];
return `${n}${suffix}`;
}Fake It Till You Make It
Return constant values, then generalize:
// Test
test('should calculate price with discount', () => {
expect(calculatePrice(100, 10)).toBe(90);
});
// First implementation - fake it
function calculatePrice(price: number, discount: number): number {
return 90; // Just make it pass
}
// Then generalize
function calculatePrice(price: number, discount: number): number {
return price - (price * discount / 100);
}Testing Edge Cases
Boundary Testing
describe('Array utils', () => {
describe('findMax', () => {
test('should find maximum in array', () => {
expect(findMax([1, 2, 3, 4, 5])).toBe(5);
});
test('should handle negative numbers', () => {
expect(findMax([-1, -2, -3])).toBe(-1);
});
test('should handle single element', () => {
expect(findMax([42])).toBe(42);
});
test('should throw on empty array', () => {
expect(() => findMax([])).toThrow('Empty array');
});
});
});Null and Undefined Handling
describe('User service', () => {
test('should handle null input', () => {
expect(() => createUser(null)).toThrow('Invalid input');
});
test('should handle undefined input', () => {
expect(() => createUser(undefined)).toThrow('Invalid input');
});
test('should handle missing properties', () => {
const user = createUser({ name: 'John' });
expect(user.email).toBe('');
});
});TDD for Different Scenarios
TDD for API Development
// userController.test.ts
import request from 'supertest';
import app from './app';
describe('POST /api/users', () => {
test('should create user with valid data', async () => {
const response = await request(app)
.post('/api/users')
.send({
name: 'John Doe',
email: 'john@example.com',
password: 'SecurePass123!'
})
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe('John Doe');
});
test('should reject invalid email', async () => {
await request(app)
.post('/api/users')
.send({
name: 'John Doe',
email: 'invalid-email',
password: 'SecurePass123!'
})
.expect(400);
});
test('should reject weak password', async () => {
await request(app)
.post('/api/users')
.send({
name: 'John Doe',
email: 'john@example.com',
password: '123'
})
.expect(400);
});
});TDD for React Components
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button Component', () => {
test('should render button with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('should call onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('should be disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByText('Click me')).toBeDisabled();
});
test('should apply custom className', () => {
render(<Button className="custom-class">Click me</Button>);
expect(screen.getByText('Click me')).toHaveClass('custom-class');
});
});TDD for Database Operations
// userRepository.test.ts
import { UserRepository } from './userRepository';
describe('UserRepository', () => {
let repo: UserRepository;
beforeEach(() => {
repo = new UserRepository();
});
test('should save user to database', async () => {
const user = {
name: 'John Doe',
email: 'john@example.com'
};
const savedUser = await repo.save(user);
expect(savedUser).toHaveProperty('id');
expect(savedUser.name).toBe('John Doe');
});
test('should find user by id', async () => {
const user = await repo.save({
name: 'Jane Smith',
email: 'jane@example.com'
});
const foundUser = await repo.findById(user.id);
expect(foundUser).toEqual(user);
});
test('should return null for non-existent user', async () => {
const user = await repo.findById('non-existent');
expect(user).toBeNull();
});
test('should update user', async () => {
const user = await repo.save({
name: 'John Doe',
email: 'john@example.com'
});
const updated = await repo.update(user.id, {
name: 'John Updated'
});
expect(updated.name).toBe('John Updated');
});
});Refactoring with Confidence
Extracting Methods
// Before
function processUser(user: User) {
const formattedName = user.name.trim().toLowerCase();
const formattedEmail = user.email.trim().toLowerCase();
// ... more code
}
// Test still passes after refactoring
function processUser(user: User) {
const formattedName = formatName(user.name);
const formattedEmail = formatEmail(user.email);
// ... more code
}
function formatName(name: string): string {
return name.trim().toLowerCase();
}
function formatEmail(email: string): string {
return email.trim().toLowerCase();
}Extracting Classes
// Before
class OrderService {
processOrder(order: Order) {
// Validate
if (!order.items || order.items.length === 0) {
throw new Error('Invalid order');
}
// Calculate total
const total = order.items.reduce((sum, item) => {
return sum + (item.price * item.quantity);
}, 0);
// Apply discount
const discount = total > 100 ? total * 0.1 : 0;
const finalTotal = total - discount;
// Save order
// ... more code
}
}
// After - with tests still passing
class OrderValidator {
validate(order: Order) {
if (!order.items || order.items.length === 0) {
throw new Error('Invalid order');
}
}
}
class OrderCalculator {
calculateTotal(order: Order): number {
const total = order.items.reduce((sum, item) => {
return sum + (item.price * item.quantity);
}, 0);
const discount = this.calculateDiscount(total);
return total - discount;
}
private calculateDiscount(total: number): number {
return total > 100 ? total * 0.1 : 0;
}
}
class OrderService {
private validator = new OrderValidator();
private calculator = new OrderCalculator();
processOrder(order: Order) {
this.validator.validate(order);
const total = this.calculator.calculateTotal(order);
// Save order
}
}Measuring TDD Success
Code Coverage
npm test -- --coverageTarget: >80% coverage
Test Quality Indicators
- Fast test execution (seconds, not minutes)
- Clear test names that describe behavior
- Tests that fail for valid reasons
- No flaky tests
- Easy to understand test failures
Common TDD Mistakes
1. Writing Tests After Code
This defeats the purpose of TDD. Always write tests first.
2. Testing Implementation Details
Focus on behavior, not implementation:
// Bad - testing implementation
test('should set isEmailValid to true', () => {
const validator = new EmailValidator();
validator.validate('test@example.com');
expect(validator.isEmailValid).toBe(true);
});
// Good - testing behavior
test('should return true for valid email', () => {
const validator = new EmailValidator();
const result = validator.validate('test@example.com');
expect(result).toBe(true);
});3. Skipping Refactoring
Don't move to the next feature without refactoring. Technical debt accumulates.
4. Writing Too Many Tests at Once
Write one test at a time, make it pass, then refactor.
TDD in CI/CD
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Generate coverage
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3Conclusion
TDD is a discipline that transforms how you write software. By following the red-green-refactor cycle, you'll produce better-designed, more maintainable code with fewer bugs. Start small, be patient, and watch your code quality improve.