TechLead

Test-Driven Development (TDD)

Learn the red-green-refactor cycle, writing tests first, and applying TDD to React components and API endpoints effectively

What is TDD?

Test-Driven Development is a software development methodology where you write tests before writing the production code. The process follows a strict cycle: write a failing test (Red), write the minimum code to pass (Green), then improve the code (Refactor). TDD produces well-tested, well-designed code by making testing an integral part of the development process.

🔴🟢🔵 The TDD Cycle:

Red: Write a test that fails. Green: Write just enough code to pass. Refactor: Clean up without changing behavior.

TDD in Practice: Building a Validator

// Step 1 - RED: Write a failing test
// validator.test.ts
import { validateEmail, validatePassword } from './validator';

describe('validateEmail', () => {
  test('returns true for valid email', () => {
    expect(validateEmail('user@example.com')).toBe(true);
  });

  test('returns false for email without @', () => {
    expect(validateEmail('userexample.com')).toBe(false);
  });

  test('returns false for empty string', () => {
    expect(validateEmail('')).toBe(false);
  });
});

// Step 2 - GREEN: Write minimum code to pass
// validator.ts
export function validateEmail(email: string): boolean {
  if (!email) return false;
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

// Step 3 - REFACTOR: Improve without changing behavior
// (In this case the code is already clean)

// Continue cycle with new tests
describe('validatePassword', () => {
  test('returns errors for short password', () => {
    const result = validatePassword('ab');
    expect(result.valid).toBe(false);
    expect(result.errors).toContain('Password must be at least 8 characters');
  });

  test('returns errors for missing uppercase', () => {
    const result = validatePassword('abcdefgh');
    expect(result.valid).toBe(false);
    expect(result.errors).toContain('Password must contain an uppercase letter');
  });

  test('returns valid for strong password', () => {
    const result = validatePassword('MyStr0ng!Pass');
    expect(result.valid).toBe(true);
    expect(result.errors).toHaveLength(0);
  });
});

// GREEN: Implementation
export function validatePassword(password: string) {
  const errors: string[] = [];

  if (password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }
  if (!/[A-Z]/.test(password)) {
    errors.push('Password must contain an uppercase letter');
  }
  if (!/[0-9]/.test(password)) {
    errors.push('Password must contain a number');
  }

  return { valid: errors.length === 0, errors };
}

TDD with React Components

// Step 1 - RED: Write tests for a Counter component
// Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter', () => {
  test('renders initial count of 0', () => {
    render(<Counter />);
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });

  test('increments count on click', async () => {
    render(<Counter />);
    await userEvent.click(screen.getByRole('button', { name: 'Increment' }));
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });

  test('decrements count on click', async () => {
    render(<Counter initialCount={5} />);
    await userEvent.click(screen.getByRole('button', { name: 'Decrement' }));
    expect(screen.getByText('Count: 4')).toBeInTheDocument();
  });

  test('does not go below 0', async () => {
    render(<Counter />);
    await userEvent.click(screen.getByRole('button', { name: 'Decrement' }));
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });
});

// Step 2 - GREEN: Implement Counter
// Counter.tsx
import { useState } from 'react';

export function Counter({ initialCount = 0 }: { initialCount?: number }) {
  const [count, setCount] = useState(initialCount);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <button onClick={() => setCount(c => Math.max(0, c - 1))}>
        Decrement
      </button>
    </div>
  );
}

TDD with API Endpoints

// RED: Test the endpoint first
// users.test.ts
import request from 'supertest';
import app from './app';

describe('POST /api/users', () => {
  test('creates a user with valid data', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@test.com' });

    expect(res.status).toBe(201);
    expect(res.body.user).toMatchObject({
      name: 'Alice',
      email: 'alice@test.com',
    });
  });

  test('returns 400 for missing name', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ email: 'alice@test.com' });

    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Name is required');
  });

  test('returns 409 for duplicate email', async () => {
    await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'dup@test.com' });

    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Bob', email: 'dup@test.com' });

    expect(res.status).toBe(409);
  });
});

When TDD Works Best & Common Mistakes

TDD Works Best For:

  • Pure business logic and utilities
  • API endpoint behavior
  • Data transformations and validation
  • Complex state management
  • Algorithm implementation

Common TDD Mistakes:

  • Writing too many tests before any code
  • Skipping the refactor step
  • Testing implementation instead of behavior
  • Making tests too granular or trivial
  • Forcing TDD on exploratory/prototype code

Key Takeaways

  • Follow Red-Green-Refactor strictly until it becomes habit
  • Write the simplest test that forces you to write meaningful code
  • TDD is a design tool, not just a testing technique
  • Skip TDD for throwaway prototypes and UI exploration
  • Combine TDD with behavior-driven naming for clarity

Continue Learning