Testing React Components with Jest and React Testing Library
Master React component testing with Jest and React Testing Library. Learn testing patterns, mocking strategies, and best practices for reliable tests.
Writing tests that give you confidence without slowing you down is an art. After testing hundreds of React components across production applications, I've developed patterns that catch real bugs while keeping tests maintainable. Here's what actually works.
Setting Up Your Testing Environment
Installation
npm install -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
Jest Configuration
// jest.config.js
const nextJest = require('next/jest');
const createJestConfig = nextJest({
dir: './',
});
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{js,jsx,ts,tsx}',
],
};
module.exports = createJestConfig(customJestConfig);
// jest.setup.js
import '@testing-library/jest-dom';
The Testing Philosophy
React Testing Library encourages testing from the user's perspective. Instead of testing implementation details, test what users see and do.
// Bad: Testing implementation details
expect(component.state.isOpen).toBe(true);
expect(wrapper.find('.dropdown-menu').exists()).toBe(true);
// Good: Testing user behavior
expect(screen.getByRole('menu')).toBeVisible();
expect(screen.getByText('Settings')).toBeInTheDocument();
Your First Component Test
Let's test a simple button component:
// components/Button.tsx
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: 'primary' | 'secondary';
}
export function Button({
children,
onClick,
disabled = false,
variant = 'primary'
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{children}
</button>
);
}
// components/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick when disabled', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick} disabled>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
it('applies variant class correctly', () => {
render(<Button variant="secondary">Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-secondary');
});
});
Testing User Interactions
Form Testing
// components/LoginForm.tsx
export function LoginForm({ onSubmit }: { onSubmit: (data: LoginData) => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
setError('Please fill in all fields');
return;
}
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-label="Email"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-label="Password"
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Log In</button>
</form>
);
}
// components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits form with email and password', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('shows error when fields are empty', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(screen.getByRole('alert')).toHaveTextContent(/please fill in all fields/i);
expect(handleSubmit).not.toHaveBeenCalled();
});
});
Mocking in Tests
Mocking API Calls
// hooks/useUser.ts
export function useUser(userId: string) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
return { user, loading };
}
// components/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
// Mock the fetch function
global.fetch = jest.fn();
describe('UserProfile', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('displays user data after loading', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
json: async () => ({ name: 'John Doe', email: 'john@example.com' }),
});
render(<UserProfile userId="123" />);
// Initially shows loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
it('handles fetch error gracefully', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText(/error loading user/i)).toBeInTheDocument();
});
});
});
Mocking Modules
// Mock Next.js router
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
}),
usePathname: () => '/current-path',
useSearchParams: () => new URLSearchParams(),
}));
// Mock custom hooks
jest.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: '1', name: 'Test User' },
isAuthenticated: true,
logout: jest.fn(),
}),
}));
Testing Async Components
// components/ProductList.tsx
export async function ProductList() {
const products = await fetchProducts();
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name} - ${product.price}</li>
))}
</ul>
);
}
// components/ProductList.test.tsx
import { render, screen } from '@testing-library/react';
import { ProductList } from './ProductList';
jest.mock('@/lib/api', () => ({
fetchProducts: jest.fn().mockResolvedValue([
{ id: '1', name: 'Widget', price: 9.99 },
{ id: '2', name: 'Gadget', price: 19.99 },
]),
}));
describe('ProductList', () => {
it('renders products from API', async () => {
render(await ProductList());
expect(screen.getByText(/widget/i)).toBeInTheDocument();
expect(screen.getByText(/gadget/i)).toBeInTheDocument();
expect(screen.getByText('$9.99')).toBeInTheDocument();
});
});
Testing Custom Hooks
// hooks/useCounter.ts
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
Best Practices
Query Priority
Use queries in this order for accessibility:
getByRole- Best for accessibilitygetByLabelText- Form elementsgetByPlaceholderText- Input placeholdersgetByText- Non-interactive elementsgetByTestId- Last resort
// Prefer accessible queries
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });
// Avoid test IDs when possible
screen.getByTestId('submit-button'); // Less preferred
Arrange-Act-Assert Pattern
it('adds item to cart', async () => {
// Arrange
const user = userEvent.setup();
render(<ProductPage product={mockProduct} />);
// Act
await user.click(screen.getByRole('button', { name: /add to cart/i }));
// Assert
expect(screen.getByText(/added to cart/i)).toBeInTheDocument();
});
Test Coverage Goals
Aim for meaningful coverage, not 100%:
- Critical paths: Authentication, checkout, data mutations
- Edge cases: Error states, empty states, loading states
- User interactions: Forms, navigation, dynamic content
Run coverage reports:
npm test -- --coverage
Testing is an investment in code quality. Start with the patterns above, and you'll catch bugs before users do. For more React development tips, check out my React hooks guide.