![]()
Testing React components with Jest and React Testing Library (RTL) has become the modern standard for creating maintainable, user-centric tests. Here’s a comprehensive guide to effective unit testing:
Core Testing Principles
1. Testing Philosophy
- User-centric tests: Test what users experience, not implementation details
- Accessibility integration: Tests double as basic a11y checks
- Minimal mocking: Mock only external dependencies, not React components
2. Recommended Testing Structure
src/
components/
Button/
Button.tsx
Button.test.tsx
Button.module.css
__mocks__/ # Manual mocks
test-utils/ # Custom render functions
Basic Test Setup
1. Configuration
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
moduleNameMapper: {
'\\.(css|less)$': 'identity-obj-proxy' // CSS module mock
}
};
2. Custom Render Function
// test-utils/customRender.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { AppProviders } from '../providers/AppProviders';
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AppProviders, ...options });
export * from '@testing-library/react';
export { customRender as render };
Component Testing Patterns
1. Basic Component Test
import { render, screen } from '../test-utils/customRender';
import Button from './Button';
test('renders button with correct text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
2. Testing Props
test('applies primary variant class', () => {
render(<Button variant="primary">Submit</Button>);
expect(screen.getByRole('button')).toHaveClass('primary');
});
3. Testing Events
test('calls onClick handler when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
4. Async Behavior Testing
test('shows loading state when isLoading prop is true', async () => {
render(<Button isLoading>Save</Button>);
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(screen.queryByText(/save/i)).not.toBeInTheDocument();
});
Advanced Testing Scenarios
1. Testing Custom Hooks
import { renderHook } from '@testing-library/react-hooks';
import { useCounter } from './useCounter';
test('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
2. Context Providers Testing
test('displays user name from context', () => {
render(
<UserContext.Provider value={{ name: 'John' }}>
<UserProfile />
</UserContext.Provider>
);
expect(screen.getByText(/john/i)).toBeInTheDocument();
});
3. Form Testing
test('submits form data', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
userEvent.type(screen.getByLabelText(/password/i), 'password123');
userEvent.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() =>
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
})
);
});
Mocking Strategies
1. API Calls Mocking
// __mocks__/axios.ts
export default {
get: jest.fn(() => Promise.resolve({ data: [] }))
};
// Component test
import axios from 'axios';
jest.mock('axios');
test('fetches data on mount', async () => {
(axios.get as jest.Mock).mockResolvedValue({ data: mockData });
render(<DataFetcher />);
expect(axios.get).toHaveBeenCalledWith('/api/data');
await waitFor(() =>
expect(screen.getByText(mockData[0].name)).toBeInTheDocument()
);
});
2. Component Mocking
// __mocks__/components/ChildComponent.tsx
const MockChild = () => <div>Mock Child</div>;
export default MockChild;
// Parent component test
jest.mock('../ChildComponent');
test('renders parent with mocked child', () => {
render(<ParentComponent />);
expect(screen.getByText(/mock child/i)).toBeInTheDocument();
});
Best Practices
- Query Priority:
- Prefer
getByRolefor most queries - Use
getByTextfor static content - Fall back to
getByTestIdfor dynamic elements
- Assertion Patterns:
// Good
expect(element).toBeInTheDocument();
expect(element).toHaveAttribute('aria-hidden', 'true');
// Avoid
expect(element).not.toBeNull();
- Test Organization:
describe('Button component', () => {
describe('when primary variant', () => {
test('renders with primary class', () => { /* ... */ });
});
describe('when clicked', () => {
test('calls onClick handler', () => { /* ... */ });
});
});
Performance Optimization
1. Parallel Testing
// jest.config.js
module.exports = {
maxWorkers: '80%', // Utilize 80% of available cores
testEnvironment: 'jsdom'
};
2. Snapshot Testing (Use Sparingly)
test('matches snapshot', () => {
const { asFragment } = render(<Component />);
expect(asFragment()).toMatchSnapshot();
});
3. Test Data Factories
// test-utils/factories.ts
export const createUser = (overrides = {}) => ({
id: 1,
name: 'Test User',
email: 'test@example.com',
...overrides
});
// Usage in tests
const adminUser = createUser({ role: 'admin' });
Common Pitfalls and Solutions
| Problem | Solution |
|---|---|
| “Not wrapped in act()” warnings | Use async utils like waitFor |
| Can’t find element | Verify accessibility attributes |
| Tests affecting each other | Add afterEach(cleanup) |
| Slow tests | Mock heavy dependencies |
Integration with TypeScript
// Extend Jest matchers
declare global {
namespace jest {
interface Matchers<R> {
toBeInTheDocument(): R;
toHaveAttribute(attr: string, value?: string): R;
}
}
}
// Typed queries
const emailInput = screen.getByRole('textbox', {
name: /email/i
}) as HTMLInputElement;
