Testing React Applications with Jest and React Testing Library
Testing React Applications with Jest and React Testing Library
Testing is essential for building reliable React applications. This guide covers testing strategies with Jest and React Testing Library.
Setup
Installation
npm install -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
Configuration
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'^@/(.*)$': '<rootDir>/src/$1',
},
};
// jest.setup.js
import '@testing-library/jest-dom';
Basic Tests
Component Rendering
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('handles click events', async () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await userEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByText('Click me')).toBeDisabled();
});
});
Query Methods
Priority Order
// 1. getByRole - most accessible
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('textbox', { name: 'Email' });
screen.getByRole('heading', { name: 'Welcome', level: 1 });
// 2. getByLabelText - for form elements
screen.getByLabelText('Email');
screen.getByLabelText(/password/i);
// 3. getByPlaceholderText
screen.getByPlaceholderText('Enter your email');
// 4. getByText - for non-interactive elements
screen.getByText('Welcome');
screen.getByText(/loading/i);
// 5. getByTestId - last resort
screen.getByTestId('loading-spinner');
Query Variants
// getBy - throws if not found or multiple found
screen.getByText('Hello');
// queryBy - returns null if not found
expect(screen.queryByText('Error')).not.toBeInTheDocument();
// findBy - async, waits for element
await screen.findByText('Loaded');
// All variants
screen.getAllByRole('listitem');
screen.queryAllByText(/item/i);
await screen.findAllByText(/loaded/i);
Testing User Interactions
User Event
import userEvent from '@testing-library/user-event';
describe('Form', () => {
it('submits form with user input', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<ContactForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Name'), 'John Doe');
await user.type(screen.getByLabelText('Email'), 'john@example.com');
await user.click(screen.getByRole('button', { name: 'Submit' }));
expect(handleSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
});
});
it('handles select and checkbox', async () => {
const user = userEvent.setup();
render(<PreferencesForm />);
await user.selectOptions(
screen.getByLabelText('Country'),
'USA'
);
await user.click(screen.getByLabelText('Subscribe to newsletter'));
expect(screen.getByLabelText('Country')).toHaveValue('USA');
expect(screen.getByLabelText('Subscribe to newsletter')).toBeChecked();
});
});
Keyboard Navigation
it('handles keyboard navigation', async () => {
const user = userEvent.setup();
render(<Dropdown items={['Apple', 'Banana', 'Orange']} />);
await user.click(screen.getByRole('button', { name: 'Open' }));
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(screen.getByText('Banana')).toBeInTheDocument();
});
Testing Async Behavior
Waiting for Updates
import { waitFor, waitForElementToBeRemoved } from '@testing-library/react';
describe('DataFetching', () => {
it('shows loading then data', async () => {
render(<UserList />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
expect(await screen.findByText('John')).toBeInTheDocument();
});
it('handles errors', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) =>
res(ctx.status(500))
)
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Error loading users')).toBeInTheDocument();
});
});
});
Testing Hooks
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('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
Testing Context
const renderWithProvider = (ui, { theme = 'light', ...options } = {}) => {
return render(
<ThemeContext.Provider value={{ theme }}>
{ui}
</ThemeContext.Provider>,
options
);
};
describe('ThemedComponent', () => {
it('renders with theme', () => {
renderWithProvider(<Header />, { theme: 'dark' });
expect(screen.getByRole('banner')).toHaveClass('dark');
});
});
Mocking
Mocking Modules
// Mock a module
jest.mock('../api/users', () => ({
fetchUsers: jest.fn(() => Promise.resolve([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
])),
}));
// Mock with implementation
jest.mock('../utils/format', () => ({
formatDate: (date) => `Formatted: ${date}`,
}));
Mocking Functions
it('calls callback with correct data', () => {
const mockCallback = jest.fn();
render(<Button onClick={mockCallback} />);
fireEvent.click(screen.getByRole('button'));
expect(mockCallback).toHaveBeenCalledWith(expect.objectContaining({
timestamp: expect.any(Number),
}));
});
Snapshot Testing
it('matches snapshot', () => {
const { asFragment } = render(<Card title="Hello">Content</Card>);
expect(asFragment()).toMatchSnapshot();
});
// Inline snapshots
it('matches inline snapshot', () => {
const { container } = render(<Button>Click</Button>);
expect(container).toMatchInlineSnapshot(`
<div>
<button class="btn">Click</button>
</div>
`);
});
Integration Testing
describe('Todo App', () => {
it('adds, completes, and removes todos', async () => {
const user = userEvent.setup();
render(<TodoApp />);
// Add todo
await user.type(screen.getByPlaceholderText('Add todo'), 'Buy milk');
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(screen.getByText('Buy milk')).toBeInTheDocument();
// Complete todo
await user.click(screen.getByRole('checkbox', { name: /buy milk/i }));
expect(screen.getByText('Buy milk')).toHaveClass('completed');
// Remove todo
await user.click(screen.getByRole('button', { name: /delete buy milk/i }));
expect(screen.queryByText('Buy milk')).not.toBeInTheDocument();
});
});
Best Practices
- Test behavior, not implementation
- Use accessible queries (getByRole, getByLabelText)
- Avoid testing library details
- Use userEvent over fireEvent
- Test async behavior properly
- Keep tests isolated
// Bad - testing implementation
expect(component.state('count')).toBe(1);
// Good - testing behavior
expect(screen.getByText('Count: 1')).toBeInTheDocument();
// Bad - using container.querySelector
expect(container.querySelector('.btn-primary')).toBeInTheDocument();
// Good - using accessible query
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
Coverage
// package.json
{
"scripts": {
"test:coverage": "jest --coverage"
},
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
Conclusion
Jest and React Testing Library provide powerful tools for testing React applications. Focus on testing user behavior, use accessible queries, and maintain good test coverage for reliable applications.