Test-Driven Development (TDD) Basics

Red, Green, Refactor: Building Software with Confidence

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.

graph LR A[Write Failing Test] --> B[Run Test
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:

graph TD A[Red Phase] --> B[Green Phase] B --> C[Refactor Phase] C --> A A1[Write a failing test] --> A A2[Run test and see it fail] --> A B1[Write minimal code] --> B B2[Make the test pass] --> B C1[Improve code structure] --> C C2[Maintain test passing] --> C style A fill:#f99,stroke:#333,stroke-width:3px style B fill:#9f9,stroke:#333,stroke-width:3px style C fill:#99f,stroke:#333,stroke-width:3px

1. Red Phase: Write a Failing Test

2. Green Phase: Make it Pass

3. Refactor Phase: Improve the Code

Benefits of TDD

TDD offers numerous advantages beyond just having tests:

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:

  1. Method signature: add(numbers: string): number
  2. Empty string returns 0
  3. Single number returns the number
  4. Two numbers separated by comma returns their sum
  5. Handle any amount of numbers
  6. Handle new lines between numbers
  7. 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:

// 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:

Challenging for TDD:

TDD Exercise: Building a Shopping Cart

Let's practice TDD by building a shopping cart step by step:

Requirements:

  1. Add items to cart
  2. Remove items from cart
  3. Update item quantity
  4. Calculate total price
  5. Apply discount codes
  6. 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

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

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 Checklist

Remember

"TDD is not about testing, it's about design and development guided by tests."