Turning Development Upside Down
Imagine building a house by first creating the inspection checklist, then building the house to pass each inspection. That's Test-Driven Development! Instead of writing code first and tests later, TDD flips the process: we write tests first, then write code to make those tests pass.
See it Fail] B --> C[Write Minimal Code] C --> D[Run Test
See it Pass] D --> E[Refactor Code] E --> F[Run Test
Ensure it Still Passes] F --> A style A fill:#f99,stroke:#333 style B fill:#f99,stroke:#333 style C fill:#ff9,stroke:#333 style D fill:#9f9,stroke:#333 style E fill:#99f,stroke:#333 style F fill:#9f9,stroke:#333
The TDD Cycle: Red, Green, Refactor
TDD follows a simple three-step cycle that we repeat for each new feature:
1. Red Phase: Write a Failing Test
- Write a test for the next small piece of functionality
- Run the test and watch it fail
- This confirms the test is actually testing something
2. Green Phase: Make it Pass
- Write the minimal code needed to pass the test
- Don't worry about perfect code yet
- Focus only on making the test green
3. Refactor Phase: Improve the Code
- Clean up the code while keeping tests green
- Remove duplication
- Improve naming and structure
- Run tests after each change
Benefits of TDD
TDD offers numerous advantages beyond just having tests:
- Better Design: Forces you to think about API before implementation
- Confidence: Comprehensive test coverage from the start
- Documentation: Tests serve as executable documentation
- Debugging: Bugs are caught immediately
- Refactoring Safety: Change code without fear of breaking functionality
- Focus: Work on one requirement at a time
- Simplicity: Encourages writing only necessary code
TDD in Action: Building a String Calculator
Let's build a string calculator step by step using TDD. We'll start with simple requirements and gradually add complexity.
Requirements:
- Method signature: add(numbers: string): number
- Empty string returns 0
- Single number returns the number
- Two numbers separated by comma returns their sum
- Handle any amount of numbers
- Handle new lines between numbers
- Support custom delimiters
Step 1: Empty String Returns 0
// RED: Write failing test
describe('StringCalculator', () => {
test('should return 0 for empty string', () => {
const calculator = new StringCalculator();
expect(calculator.add('')).toBe(0);
});
});
// Run test - FAILS (StringCalculator is not defined)
// GREEN: Write minimal code to pass
class StringCalculator {
add(numbers) {
return 0;
}
}
// Run test - PASSES
// REFACTOR: Nothing to refactor yet
Step 2: Single Number Returns the Number
// RED: Write failing test
test('should return number for single number', () => {
const calculator = new StringCalculator();
expect(calculator.add('1')).toBe(1);
});
// Run test - FAILS (returns 0 instead of 1)
// GREEN: Write minimal code to pass
class StringCalculator {
add(numbers) {
if (numbers === '') return 0;
return parseInt(numbers);
}
}
// Run tests - ALL PASS
// REFACTOR: Consider edge cases
class StringCalculator {
add(numbers) {
if (!numbers) return 0;
return parseInt(numbers, 10);
}
}
Step 3: Two Numbers Return Their Sum
// RED: Write failing test
test('should return sum of two numbers', () => {
const calculator = new StringCalculator();
expect(calculator.add('1,2')).toBe(3);
});
// Run test - FAILS (returns NaN)
// GREEN: Write minimal code to pass
class StringCalculator {
add(numbers) {
if (!numbers) return 0;
if (numbers.includes(',')) {
const parts = numbers.split(',');
return parseInt(parts[0], 10) + parseInt(parts[1], 10);
}
return parseInt(numbers, 10);
}
}
// Run tests - ALL PASS
// REFACTOR: Remove duplication, improve structure
class StringCalculator {
add(numbers) {
if (!numbers) return 0;
const parts = numbers.split(',');
return parts.reduce((sum, num) => sum + parseInt(num, 10), 0);
}
}
Step 4: Handle Multiple Numbers
// RED: Write failing test
test('should handle multiple numbers', () => {
const calculator = new StringCalculator();
expect(calculator.add('1,2,3,4')).toBe(10);
});
// Run test - PASSES! Our refactored code already handles this
// Let's add another test to be sure
test('should handle many numbers', () => {
const calculator = new StringCalculator();
expect(calculator.add('1,2,3,4,5,6,7,8,9,10')).toBe(55);
});
// Run tests - ALL PASS
Step 5: Handle New Lines
// RED: Write failing test
test('should handle new lines between numbers', () => {
const calculator = new StringCalculator();
expect(calculator.add('1\n2,3')).toBe(6);
});
// Run test - FAILS (returns NaN)
// GREEN: Write code to pass
class StringCalculator {
add(numbers) {
if (!numbers) return 0;
const parts = numbers.split(/,|\n/);
return parts.reduce((sum, num) => sum + parseInt(num, 10), 0);
}
}
// Run tests - ALL PASS
// REFACTOR: Extract delimiter pattern
class StringCalculator {
add(numbers) {
if (!numbers) return 0;
const delimiter = /,|\n/;
const parts = numbers.split(delimiter);
return parts.reduce((sum, num) => sum + parseInt(num, 10), 0);
}
}
Step 6: Support Custom Delimiters
// RED: Write failing test
test('should support custom delimiters', () => {
const calculator = new StringCalculator();
expect(calculator.add('//;\n1;2')).toBe(3);
});
// Run test - FAILS
// GREEN: Write code to pass
class StringCalculator {
add(numbers) {
if (!numbers) return 0;
let delimiter = /,|\n/;
let numberString = numbers;
// Check for custom delimiter
if (numbers.startsWith('//')) {
const delimiterLine = numbers.split('\n')[0];
delimiter = delimiterLine.substring(2);
numberString = numbers.substring(numbers.indexOf('\n') + 1);
}
const parts = numberString.split(delimiter);
return parts.reduce((sum, num) => sum + parseInt(num, 10), 0);
}
}
// Run tests - ALL PASS
// REFACTOR: Extract methods for clarity
class StringCalculator {
add(numbers) {
if (!numbers) return 0;
const { delimiter, numberString } = this.parseInput(numbers);
const parts = numberString.split(delimiter);
return this.sum(parts);
}
parseInput(input) {
if (input.startsWith('//')) {
return this.parseCustomDelimiter(input);
}
return {
delimiter: /,|\n/,
numberString: input
};
}
parseCustomDelimiter(input) {
const delimiterLine = input.split('\n')[0];
const delimiter = delimiterLine.substring(2);
const numberString = input.substring(input.indexOf('\n') + 1);
return { delimiter, numberString };
}
sum(parts) {
return parts.reduce((sum, num) => sum + parseInt(num, 10), 0);
}
}
TDD Best Practices
1. Write One Test at a Time
// DON'T: Write multiple tests at once
test('handles empty string', () => { /* ... */ });
test('handles single number', () => { /* ... */ });
test('handles multiple numbers', () => { /* ... */ });
// Then try to implement all at once
// DO: Write one test, implement, then next test
test('handles empty string', () => { /* ... */ });
// Implement, then write next test
2. Keep Tests Small and Focused
// DON'T: Test multiple behaviors in one test
test('calculator works', () => {
const calc = new Calculator();
expect(calc.add('')).toBe(0);
expect(calc.add('1')).toBe(1);
expect(calc.add('1,2')).toBe(3);
});
// DO: One behavior per test
test('returns 0 for empty string', () => {
const calc = new Calculator();
expect(calc.add('')).toBe(0);
});
test('returns number for single digit', () => {
const calc = new Calculator();
expect(calc.add('1')).toBe(1);
});
3. Follow the TDD Cycle Strictly
// DON'T: Write code before tests
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
isValid() {
return this.name && this.email.includes('@');
}
}
// Then write tests
// DO: Start with a failing test
test('user is invalid without name', () => {
const user = new User('', 'test@example.com');
expect(user.isValid()).toBe(false);
});
// Then implement just enough to pass
4. Refactor Continuously
// After getting tests to pass, always look for improvements
// Before refactoring
function validateEmail(email) {
if (!email) return false;
if (!email.includes('@')) return false;
if (!email.includes('.')) return false;
return true;
}
// After refactoring
function validateEmail(email) {
return email && email.includes('@') && email.includes('.');
}
Common TDD Patterns
1. Fake It Till You Make It
// Start with hardcoded values, then generalize
// First implementation
function fibonacci(n) {
return 1; // Just enough to pass first test
}
// After more tests
function fibonacci(n) {
if (n <= 2) return 1; // Handle first two cases
return 2; // Hardcode third case
}
// Final implementation
function fibonacci(n) {
if (n <= 2) return 1;
return fibonacci(n - 1) + fibonacci(n - 2);
}
2. Triangulation
// Use multiple examples to drive generalization
test('multiplies 2 x 3', () => {
expect(multiply(2, 3)).toBe(6);
});
test('multiplies 3 x 4', () => {
expect(multiply(3, 4)).toBe(12);
});
// Forces general solution instead of hardcoding
function multiply(a, b) {
return a * b; // Can't fake it with multiple examples
}
3. Obvious Implementation
// When the implementation is obvious, just write it
test('returns true for empty array', () => {
expect(isEmpty([])).toBe(true);
});
// Obvious implementation
function isEmpty(array) {
return array.length === 0;
}
TDD with Complex Features
Let's apply TDD to a more complex feature: a password validator with multiple rules.
Requirements:
- At least 8 characters long
- Contains at least one uppercase letter
- Contains at least one lowercase letter
- Contains at least one number
- Contains at least one special character
- Provides specific error messages
// Step 1: Test minimum length
describe('PasswordValidator', () => {
let validator;
beforeEach(() => {
validator = new PasswordValidator();
});
test('rejects password shorter than 8 characters', () => {
const result = validator.validate('Pass1!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must be at least 8 characters');
});
});
// Implement
class PasswordValidator {
validate(password) {
const errors = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
return {
isValid: errors.length === 0,
errors
};
}
}
// Step 2: Test uppercase requirement
test('requires at least one uppercase letter', () => {
const result = validator.validate('password1!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one uppercase letter');
});
// Update implementation
class PasswordValidator {
validate(password) {
const errors = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
return {
isValid: errors.length === 0,
errors
};
}
}
// Continue with remaining requirements...
// Step 3: Test lowercase requirement
test('requires at least one lowercase letter', () => {
const result = validator.validate('PASSWORD1!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one lowercase letter');
});
// Step 4: Test number requirement
test('requires at least one number', () => {
const result = validator.validate('Password!');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one number');
});
// Step 5: Test special character requirement
test('requires at least one special character', () => {
const result = validator.validate('Password1');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one special character');
});
// Step 6: Test valid password
test('accepts valid password', () => {
const result = validator.validate('Password1!');
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
// Final refactored implementation
class PasswordValidator {
constructor() {
this.rules = [
{
test: password => password.length >= 8,
message: 'Password must be at least 8 characters'
},
{
test: password => /[A-Z]/.test(password),
message: 'Password must contain at least one uppercase letter'
},
{
test: password => /[a-z]/.test(password),
message: 'Password must contain at least one lowercase letter'
},
{
test: password => /\d/.test(password),
message: 'Password must contain at least one number'
},
{
test: password => /[!@#$%^&*(),.?":{}|<>]/.test(password),
message: 'Password must contain at least one special character'
}
];
}
validate(password) {
const errors = this.rules
.filter(rule => !rule.test(password))
.map(rule => rule.message);
return {
isValid: errors.length === 0,
errors
};
}
}
TDD Anti-Patterns
Avoid these common TDD mistakes:
1. Writing Tests After Code
// ANTI-PATTERN: Writing implementation first
class EmailService {
sendEmail(to, subject, body) {
// Complex implementation
if (!this.validateEmail(to)) {
throw new Error('Invalid email');
}
// ... more code
}
}
// Then trying to write tests
test('sends email', () => {
// Difficult to test because not designed for testing
});
2. Testing Implementation Details
// ANTI-PATTERN: Testing private methods or internal state
test('sets internal cache', () => {
const service = new DataService();
service.fetchData();
expect(service._cache).toBeDefined(); // Testing private property
});
// BETTER: Test behavior, not implementation
test('returns cached data on second call', () => {
const service = new DataService();
service.fetchData();
const result1 = service.fetchData();
const result2 = service.fetchData();
expect(result1).toBe(result2); // Same instance = cached
});
3. Writing Too Much Code
// ANTI-PATTERN: Implementing more than the test requires
test('adds two numbers', () => {
expect(calculator.add(2, 3)).toBe(5);
});
// Over-implementation
class Calculator {
add(a, b) {
return a + b;
}
subtract(a, b) { // Not required by current test!
return a - b;
}
multiply(a, b) { // Not required by current test!
return a * b;
}
}
TDD with Asynchronous Code
TDD works well with async code too. Here's an example with a user service:
// Test async user fetching with TDD
describe('UserService', () => {
let userService;
let mockHttpClient;
beforeEach(() => {
mockHttpClient = {
get: jest.fn()
};
userService = new UserService(mockHttpClient);
});
// RED: Test fetching user by ID
test('fetches user by ID', async () => {
const mockUser = { id: 1, name: 'John Doe' };
mockHttpClient.get.mockResolvedValue({ data: mockUser });
const user = await userService.getUserById(1);
expect(user).toEqual(mockUser);
expect(mockHttpClient.get).toHaveBeenCalledWith('/users/1');
});
});
// GREEN: Implement getUserById
class UserService {
constructor(httpClient) {
this.httpClient = httpClient;
}
async getUserById(id) {
const response = await this.httpClient.get(`/users/${id}`);
return response.data;
}
}
// RED: Test error handling
test('throws error when user not found', async () => {
mockHttpClient.get.mockRejectedValue(new Error('404 Not Found'));
await expect(userService.getUserById(999))
.rejects.toThrow('User not found');
});
// GREEN: Add error handling
class UserService {
constructor(httpClient) {
this.httpClient = httpClient;
}
async getUserById(id) {
try {
const response = await this.httpClient.get(`/users/${id}`);
return response.data;
} catch (error) {
if (error.message.includes('404')) {
throw new Error('User not found');
}
throw error;
}
}
}
// Continue with more features...
test('caches user after first fetch', async () => {
const mockUser = { id: 1, name: 'John Doe' };
mockHttpClient.get.mockResolvedValue({ data: mockUser });
// First call
await userService.getUserById(1);
// Second call
await userService.getUserById(1);
// Should only call HTTP once due to caching
expect(mockHttpClient.get).toHaveBeenCalledTimes(1);
});
// Implement caching
class UserService {
constructor(httpClient) {
this.httpClient = httpClient;
this.cache = new Map();
}
async getUserById(id) {
if (this.cache.has(id)) {
return this.cache.get(id);
}
try {
const response = await this.httpClient.get(`/users/${id}`);
const user = response.data;
this.cache.set(id, user);
return user;
} catch (error) {
if (error.message.includes('404')) {
throw new Error('User not found');
}
throw error;
}
}
}
TDD with UI Components
TDD can be applied to UI components as well. Here's an example with a React component:
// Test-drive a TodoList component
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { TodoList } from './TodoList';
describe('TodoList', () => {
// RED: Test rendering empty list
test('renders empty list initially', () => {
render(<TodoList />);
expect(screen.getByText('No todos yet')).toBeInTheDocument();
});
});
// GREEN: Create component
export function TodoList() {
return (
<div>
<h2>Todo List</h2>
<p>No todos yet</p>
</div>
);
}
// RED: Test adding a todo
test('adds todo when form is submitted', () => {
render(<TodoList />);
const input = screen.getByPlaceholderText('Add a todo');
const button = screen.getByText('Add');
fireEvent.change(input, { target: { value: 'Buy milk' } });
fireEvent.click(button);
expect(screen.getByText('Buy milk')).toBeInTheDocument();
});
// GREEN: Add form and state management
export function TodoList() {
const [todos, setTodos] = React.useState([]);
const [inputValue, setInputValue] = React.useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim()) {
setTodos([...todos, { id: Date.now(), text: inputValue }]);
setInputValue('');
}
};
return (
<div>
<h2>Todo List</h2>
{todos.length === 0 ? (
<p>No todos yet</p>
) : (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
)}
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Add a todo"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button type="submit">Add</button>
</form>
</div>
);
}
// Continue with more features...
test('marks todo as completed when clicked', () => {
render(<TodoList />);
// Add a todo
const input = screen.getByPlaceholderText('Add a todo');
fireEvent.change(input, { target: { value: 'Buy milk' } });
fireEvent.click(screen.getByText('Add'));
// Click to complete
const todoItem = screen.getByText('Buy milk');
fireEvent.click(todoItem);
expect(todoItem).toHaveClass('completed');
});
// Implement completion feature
export function TodoList() {
const [todos, setTodos] = React.useState([]);
const [inputValue, setInputValue] = React.useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim()) {
setTodos([...todos, {
id: Date.now(),
text: inputValue,
completed: false
}]);
setInputValue('');
}
};
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
return (
<div>
<h2>Todo List</h2>
{todos.length === 0 ? (
<p>No todos yet</p>
) : (
<ul>
{todos.map(todo => (
<li
key={todo.id}
onClick={() => toggleTodo(todo.id)}
className={todo.completed ? 'completed' : ''}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
cursor: 'pointer'
}}
>
{todo.text}
</li>
))}
</ul>
)}
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Add a todo"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button type="submit">Add</button>
</form>
</div>
);
}
When to Use TDD
TDD is particularly valuable in certain situations:
Great for TDD:
- Pure Functions: Functions with clear inputs and outputs
- Business Logic: Complex rules and calculations
- Bug Fixes: Write a failing test that reproduces the bug
- API Development: Design the API through tests
- Algorithms: Step-by-step implementation
- Refactoring: Ensure behavior doesn't change
Challenging for TDD:
- UI Layout: Visual appearance is hard to test
- Exploratory Code: When you're not sure what you're building
- Legacy Code: Hard to test code not designed for testing
- Integration Points: External systems and APIs
TDD Exercise: Building a Shopping Cart
Let's practice TDD by building a shopping cart step by step:
Requirements:
- Add items to cart
- Remove items from cart
- Update item quantity
- Calculate total price
- Apply discount codes
- Calculate shipping based on total
// Start with the first test
describe('ShoppingCart', () => {
test('starts empty', () => {
const cart = new ShoppingCart();
expect(cart.getItems()).toEqual([]);
});
});
// Your task: Implement the shopping cart using TDD!
// Follow the red-green-refactor cycle for each requirement
// Solution structure (implement step by step):
class ShoppingCart {
constructor() {
// Start simple, add complexity as tests require
}
addItem(item) {
// Implement when test requires it
}
removeItem(itemId) {
// Implement when test requires it
}
updateQuantity(itemId, quantity) {
// Implement when test requires it
}
getTotal() {
// Implement when test requires it
}
applyDiscount(code) {
// Implement when test requires it
}
calculateShipping() {
// Implement when test requires it
}
}
// Example test progression:
test('adds item to cart', () => {
const cart = new ShoppingCart();
const item = { id: 1, name: 'Book', price: 10 };
cart.addItem(item);
expect(cart.getItems()).toEqual([item]);
});
test('calculates total price', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Book', price: 10, quantity: 2 });
cart.addItem({ id: 2, name: 'Pen', price: 2, quantity: 5 });
expect(cart.getTotal()).toBe(30);
});
// Continue with more tests...
TDD Tools and Techniques
1. Test Runners
- Jest: Built-in watch mode perfect for TDD
- Mocha: Flexible test runner with various reporters
- Vitest: Fast, Vite-native test runner
2. Watch Mode
# Run tests in watch mode
npm test -- --watch
# Run only related tests
npm test -- --watch --onlyChanged
# Run tests matching pattern
npm test -- --watch calculator
3. IDE Integration
- VS Code Test Explorer
- WebStorm built-in test runner
- Wallaby.js for real-time feedback
4. Continuous Testing
// package.json scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:tdd": "jest --watch --coverage --verbose"
}
}
Summary and Best Practices
Key Takeaways
- TDD is about design as much as testing
- Red-Green-Refactor is the core cycle
- Write the minimum code to pass tests
- Refactor continuously while keeping tests green
- Tests serve as living documentation
- TDD leads to better design and more maintainable code
TDD Checklist
- □ Write one failing test at a time
- □ Run the test and see it fail
- □ Write minimal code to pass
- □ Run the test and see it pass
- □ Refactor while keeping tests green
- □ Repeat for next requirement
Remember
"TDD is not about testing, it's about design and development guided by tests."