React Testing with Jest
Testing is essential for building reliable React applications. Jest is the most popular testing framework for React, providing a complete testing solution out of the box. This tutorial covers unit testing, component testing, and integration testing with Jest and React Testing Library.
Why Test React Components?
Testing ensures your React components work as expected, prevents regressions, and gives confidence when refactoring. Well-tested code is more maintainable and reliable.
Setting Up Jest
Most React projects created with Create React App come with Jest pre-configured. For other setups:
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-eventJest Configuration
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
moduleNameMapping: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/index.js',
],
};// src/setupTests.js
import '@testing-library/jest-dom';Basic Component Testing
Simple Component Test
// src/components/Button.js
import React from 'react';
const Button = ({ children, onClick, disabled = false }) => {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
};
export default Button;// src/components/Button.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button', () => {
test('renders button with text', () => {
render(<Button>Click me</Button>);
const buttonElement = screen.getByText(/click me/i);
expect(buttonElement).toBeInTheDocument();
});
test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
const buttonElement = screen.getByText(/click me/i);
fireEvent.click(buttonElement);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
const buttonElement = screen.getByText(/click me/i);
expect(buttonElement).toBeDisabled();
});
});Testing User Interactions
Form Testing
// src/components/LoginForm.js
import React, { useState } from 'react';
const LoginForm = ({ onSubmit }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
data-testid="email-input"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
data-testid="password-input"
/>
<button type="submit">Login</button>
</form>
);
};
export default LoginForm;// src/components/LoginForm.test.js
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
test('submits form with email and password', async () => {
const mockSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={mockSubmit} />);
const emailInput = screen.getByTestId('email-input');
const passwordInput = screen.getByTestId('password-input');
const submitButton = screen.getByRole('button', { name: /login/i });
await user.type(emailInput, '[email protected]');
await user.type(passwordInput, 'password123');
await user.click(submitButton);
expect(mockSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123',
});
});
test('shows validation errors for empty fields', async () => {
const mockSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={mockSubmit} />);
const submitButton = screen.getByRole('button', { name: /login/i });
await user.click(submitButton);
// Assuming you add validation logic
// expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
});Testing Hooks
Custom Hook Testing
// src/hooks/useCounter.js
import { useState } from 'react';
const useCounter = (initialValue = 0) => {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
};
export default useCounter;// src/hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
test('should increment counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('should decrement counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('should reset counter', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});Mocking Dependencies
API Calls Testing
// src/components/UserList.js
import React, { useState, useEffect } from 'react';
const UserList = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUsers();
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
export default UserList;// src/components/UserList.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
// Mock fetch
global.fetch = jest.fn();
describe('UserList', () => {
beforeEach(() => {
fetch.mockClear();
});
test('displays loading state initially', () => {
fetch.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
render(<UserList />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('displays users after successful fetch', async () => {
const mockUsers = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' },
];
fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockUsers),
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
test('displays error message on fetch failure', async () => {
fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: false,
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Error: Failed to fetch users')).toBeInTheDocument();
});
});
});Testing Context
Context Provider Testing
// src/context/AuthContext.js
import React, { createContext, useContext, useState } from 'react';
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const login = (userData) => {
setUser(userData);
};
const logout = () => {
setUser(null);
};
const value = {
user,
loading,
login,
logout,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};// src/context/AuthContext.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import { AuthProvider, useAuth } from './AuthContext';
// Test component that uses the context
const TestComponent = () => {
const { user, login, logout } = useAuth();
return (
<div>
<div data-testid="user">{user ? user.name : 'No user'}</div>
<button onClick={() => login({ name: 'John' })}>Login</button>
<button onClick={logout}>Logout</button>
</div>
);
};
describe('AuthContext', () => {
test('provides auth context to children', () => {
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
expect(screen.getByTestId('user')).toHaveTextContent('No user');
});
test('throws error when useAuth is used outside provider', () => {
// Mock console.error to avoid noise
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<TestComponent />)).toThrow('useAuth must be used within an AuthProvider');
consoleSpy.mockRestore();
});
});Snapshot Testing
// src/components/SnapshotExample.test.js
import React from 'react';
import { render } from '@testing-library/react';
import Button from './Button';
test('Button matches snapshot', () => {
const { container } = render(<Button>Click me</Button>);
expect(container.firstChild).toMatchSnapshot();
});Coverage Reporting
# Run tests with coverage
npm test -- --coverage
# Generate HTML coverage report
npm test -- --coverage --watchAll=falseCoverage Configuration
// jest.config.js
module.exports = {
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/index.js',
'!src/serviceWorker.js',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};Testing Best Practices
1. Test Behavior, Not Implementation
// ✅ Good: Test what the user sees
test('shows loading state', () => {
render(<UserList />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
// ❌ Bad: Test internal implementation
test('calls useState', () => {
const setState = jest.fn();
React.useState = jest.fn(() => [false, setState]);
// ...
});2. Use Descriptive Test Names
// ✅ Good
test('displays error message when API call fails')
// ❌ Bad
test('error handling')3. Group Related Tests
describe('User authentication', () => {
describe('login form', () => {
test('validates email format', () => { /* ... */ });
test('validates password length', () => { /* ... */ });
});
describe('after login', () => {
test('redirects to dashboard', () => { /* ... */ });
test('stores user session', () => { /* ... */ });
});
});4. Mock External Dependencies
// Mock API calls, timers, localStorage, etc.
jest.mock('axios');
jest.useFakeTimers();5. Test Edge Cases
test('handles empty response', () => { /* ... */ });
test('handles network error', () => { /* ... */ });
test('handles malformed data', () => { /* ... */ });Common Testing Patterns
Custom Render Function
// src/test-utils.js
import React from 'react';
import { render } from '@testing-library/react';
import { AuthProvider } from './context/AuthContext';
const AllTheProviders = ({ children }) => {
return (
<AuthProvider>
{children}
</AuthProvider>
);
};
const customRender = (ui, options) =>
render(ui, { wrapper: AllTheProviders, ...options });
// re-export everything
export * from '@testing-library/react';
// override render method
export { customRender as render };Setup and Teardown
describe('API tests', () => {
beforeAll(() => {
// Setup once before all tests
});
beforeEach(() => {
// Setup before each test
jest.clearAllMocks();
});
afterEach(() => {
// Cleanup after each test
});
afterAll(() => {
// Cleanup once after all tests
});
});Testing Async Code
test('handles async operation', async () => {
const mockData = { id: 1, name: 'Test' };
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockData),
});
render(<AsyncComponent />);
await waitFor(() => {
expect(screen.getByText('Test')).toBeInTheDocument();
});
});Integration Testing
// src/App.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
test('full user flow', async () => {
const user = userEvent.setup();
render(<App />);
// Navigate to login page
await user.click(screen.getByRole('link', { name: /login/i }));
// Fill login form
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password');
await user.click(screen.getByRole('button', { name: /submit/i }));
// Check if redirected to dashboard
await waitFor(() => {
expect(screen.getByText(/welcome/i)).toBeInTheDocument();
});
});Summary
Jest and React Testing Library provide powerful tools for testing React applications:
- Unit tests for individual components and hooks
- Integration tests for component interactions
- Mocking for external dependencies
- Async testing for API calls and effects
- Coverage reporting to track test completeness
Key principles:
- Test behavior over implementation
- Write descriptive, maintainable tests
- Mock external dependencies
- Test edge cases and error conditions
- Use semantic queries over implementation details
Well-tested React applications are more reliable, maintainable, and easier to refactor.
External Resources:
Related Tutorials: