TDD in React: Test-Driven Development for Frontend Applications
Test-driven development in React is more challenging than server-side TDD, but it's absolutely practical. The key is knowing which layer you're testing: component behavior, custom hooks, or integration between components.
TDD Setup for React
Modern React TDD uses React Testing Library (RTL), which tests components the way users interact with them — by finding elements and simulating user actions, not by inspecting implementation details.
npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom vitest jsdom// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: './src/test-setup.js',
},
});// src/test-setup.js
import '@testing-library/jest-dom';The Red-Green-Refactor Loop in React
Red: Write a failing test
// SearchBar.test.jsx — written before SearchBar.jsx exists
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchBar from './SearchBar';
test('calls onSearch with query when submitted', async () => {
const onSearch = vi.fn();
render(<SearchBar onSearch={onSearch} />);
await userEvent.type(screen.getByRole('searchbox'), 'react testing');
await userEvent.click(screen.getByRole('button', { name: /search/i }));
expect(onSearch).toHaveBeenCalledWith('react testing');
});Run it. Cannot find module './SearchBar'. That's red.
Green: Write minimum code
// SearchBar.jsx
export default function SearchBar({ onSearch }) {
const [query, setQuery] = React.useState('');
return (
<form onSubmit={e => { e.preventDefault(); onSearch(query); }}>
<input
role="searchbox"
value={query}
onChange={e => setQuery(e.target.value)}
/>
<button type="submit">Search</button>
</form>
);
}Test passes. Don't add more features yet.
Refactor: Clean up
The implementation is fine. Add the next test.
test('trims whitespace from query', async () => {
const onSearch = vi.fn();
render(<SearchBar onSearch={onSearch} />);
await userEvent.type(screen.getByRole('searchbox'), ' react ');
await userEvent.click(screen.getByRole('button', { name: /search/i }));
expect(onSearch).toHaveBeenCalledWith('react');
});Fails. Fix:
onSubmit={e => { e.preventDefault(); onSearch(query.trim()); }}Green. Continue.
Testing Custom Hooks with TDD
Custom hooks are pure logic — they're the easiest part of React to TDD. Use renderHook from RTL:
// useCounter.test.js — written first
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('starts at initial value', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
test('does not go below zero', () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => result.current.increment());
act(() => result.current.reset());
expect(result.current.count).toBe(10);
});Now implement:
// useCounter.js
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => Math.max(0, c - 1)), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}All tests pass. The hook is tested independently, without needing any component.
Testing Async Components
Components that fetch data need special handling. TDD your data-fetching components by mocking the data layer:
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import UserProfile from './UserProfile';
import * as api from './api';
vi.mock('./api');
test('displays user name after loading', async () => {
api.getUser.mockResolvedValue({ name: 'Alice', email: 'alice@example.com' });
render(<UserProfile userId="1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
test('shows error message when fetch fails', async () => {
api.getUser.mockRejectedValue(new Error('Network error'));
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
});
test('shows empty state for unknown user', async () => {
api.getUser.mockResolvedValue(null);
render(<UserProfile userId="999" />);
await waitFor(() => {
expect(screen.getByText(/user not found/i)).toBeInTheDocument();
});
});Implement to make tests pass:
// UserProfile.jsx
import { useState, useEffect } from 'react';
import { getUser } from './api';
export default function UserProfile({ userId }) {
const [user, setUser] = useState(undefined);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getUser(userId)
.then(data => { setUser(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Failed to load user.</p>;
if (!user) return <p>User not found.</p>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}Testing User Interactions
RTL's userEvent simulates realistic user behavior — typing, clicking, tabbing:
// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('shows validation error when email is empty', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
test('submits with valid credentials', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), 'user@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'secret123');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'secret123'
});
});TDD with Context and State Management
Test components that use Context by wrapping them in a provider:
// CartContext.test.jsx
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CartProvider, useCart } from './CartContext';
// Test component that uses the cart
function CartConsumer() {
const { items, addItem, total } = useCart();
return (
<div>
<span data-testid="count">{items.length}</span>
<span data-testid="total">{total}</span>
<button onClick={() => addItem({ id: '1', name: 'Widget', price: 9.99 })}>
Add Item
</button>
</div>
);
}
test('starts with empty cart', () => {
render(<CartProvider><CartConsumer /></CartProvider>);
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
test('adds item to cart', async () => {
render(<CartProvider><CartConsumer /></CartProvider>);
await userEvent.click(screen.getByRole('button', { name: /add item/i }));
expect(screen.getByTestId('count')).toHaveTextContent('1');
expect(screen.getByTestId('total')).toHaveTextContent('9.99');
});What to TDD vs What to Skip
TDD these:
- Custom hooks (pure logic)
- Form validation logic
- Component interactions (user events → expected DOM changes)
- Conditional rendering (loading, error, empty, data states)
- Business logic in event handlers
Don't obsessively TDD these:
- CSS/styling (visual tests or screenshots)
- Third-party component configuration
- Pure presentational components with no logic
- Animations
Use snapshot tests sparingly:
test('renders correctly', () => {
const { container } = render(<Button variant="primary">Click me</Button>);
expect(container).toMatchSnapshot();
});Snapshot tests are fragile. Any visual change, even intentional ones, breaks them. Use them for stable, well-defined components — not for everything.
Common Mistakes in React TDD
Testing implementation details: Don't test state variables, internal methods, or DOM structure. Test what users see and do.
Over-mocking: If you mock everything, your tests don't verify real behavior. Mock at boundaries (API calls, browser APIs) but let components compose naturally.
Not testing error states: Always write tests for loading, error, and empty states before implementing them. These are the states most often neglected in development.
Using getByTestId for everything: getByRole, getByLabelText, and getByText find elements the way users do. getByTestId should be a last resort.
React TDD takes practice, but components driven by tests tend to be simpler, more focused, and easier to maintain. Start with hooks — they're pure logic and easiest to TDD — then move to components.