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