React Testing Library encourages testing components the way users interact with them. Here's how to write effective tests.
Query Priority#
1import { render, screen } from '@testing-library/react';
2
3// Queries ordered by priority (most to least accessible)
4function TestComponent() {
5 return (
6 <form>
7 {/* 1. getByRole - Most preferred */}
8 <button>Submit</button>
9 <input type="text" />
10
11 {/* 2. getByLabelText - For form elements */}
12 <label htmlFor="email">Email</label>
13 <input id="email" type="email" />
14
15 {/* 3. getByPlaceholderText - When no label */}
16 <input placeholder="Search..." />
17
18 {/* 4. getByText - For non-interactive elements */}
19 <p>Welcome message</p>
20
21 {/* 5. getByDisplayValue - For filled inputs */}
22 <input value="current value" readOnly />
23
24 {/* 6. getByAltText - For images */}
25 <img src="logo.png" alt="Company Logo" />
26
27 {/* 7. getByTitle - Rarely used */}
28 <span title="Close">×</span>
29
30 {/* 8. getByTestId - Last resort */}
31 <div data-testid="custom-element">Custom</div>
32 </form>
33 );
34}
35
36test('uses appropriate queries', () => {
37 render(<TestComponent />);
38
39 // Prefer role queries
40 expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
41 expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument();
42
43 // Use text for static content
44 expect(screen.getByText(/welcome message/i)).toBeInTheDocument();
45
46 // Use testId only when necessary
47 expect(screen.getByTestId('custom-element')).toBeInTheDocument();
48});User Events#
1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3
4// Setup userEvent with options
5function setup(jsx: React.ReactElement) {
6 return {
7 user: userEvent.setup(),
8 ...render(jsx),
9 };
10}
11
12// Form interaction
13test('submits form with user data', async () => {
14 const handleSubmit = jest.fn();
15 const { user } = setup(<LoginForm onSubmit={handleSubmit} />);
16
17 // Type in inputs
18 await user.type(screen.getByLabelText(/email/i), 'test@example.com');
19 await user.type(screen.getByLabelText(/password/i), 'password123');
20
21 // Click submit
22 await user.click(screen.getByRole('button', { name: /login/i }));
23
24 expect(handleSubmit).toHaveBeenCalledWith({
25 email: 'test@example.com',
26 password: 'password123',
27 });
28});
29
30// Keyboard interactions
31test('navigates with keyboard', async () => {
32 const { user } = setup(<NavigableList items={['A', 'B', 'C']} />);
33
34 // Tab through elements
35 await user.tab();
36 expect(screen.getByText('A')).toHaveFocus();
37
38 await user.tab();
39 expect(screen.getByText('B')).toHaveFocus();
40
41 // Arrow keys
42 await user.keyboard('{ArrowDown}');
43
44 // Enter to select
45 await user.keyboard('{Enter}');
46});
47
48// Complex interactions
49test('drag and drop', async () => {
50 const { user } = setup(<DragDropList />);
51
52 const draggable = screen.getByText('Item 1');
53 const dropzone = screen.getByTestId('dropzone');
54
55 await user.pointer([
56 { keys: '[MouseLeft>]', target: draggable },
57 { coords: { x: 200, y: 200 } },
58 { target: dropzone },
59 { keys: '[/MouseLeft]' },
60 ]);
61});Async Testing#
1import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
2
3// Wait for element to appear
4test('loads data asynchronously', async () => {
5 render(<UserProfile userId="123" />);
6
7 // Loading state
8 expect(screen.getByText(/loading/i)).toBeInTheDocument();
9
10 // Wait for data
11 const userName = await screen.findByText('John Doe');
12 expect(userName).toBeInTheDocument();
13});
14
15// Wait for element to disappear
16test('removes loading indicator', async () => {
17 render(<UserProfile userId="123" />);
18
19 await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
20
21 expect(screen.getByText('John Doe')).toBeInTheDocument();
22});
23
24// Custom waitFor conditions
25test('waits for multiple conditions', async () => {
26 render(<Dashboard />);
27
28 await waitFor(() => {
29 expect(screen.getByText('Users: 10')).toBeInTheDocument();
30 expect(screen.getByText('Orders: 25')).toBeInTheDocument();
31 });
32});
33
34// Avoid false positives with findBy
35test('handles async errors', async () => {
36 // Mock API error
37 server.use(
38 rest.get('/api/user', (req, res, ctx) => {
39 return res(ctx.status(500));
40 })
41 );
42
43 render(<UserProfile userId="123" />);
44
45 // Use findBy for async elements
46 const errorMessage = await screen.findByRole('alert');
47 expect(errorMessage).toHaveTextContent(/failed to load/i);
48});Testing Hooks#
1import { renderHook, act } from '@testing-library/react';
2
3// Test custom hook
4function useCounter(initialValue = 0) {
5 const [count, setCount] = useState(initialValue);
6
7 const increment = () => setCount((c) => c + 1);
8 const decrement = () => setCount((c) => c - 1);
9 const reset = () => setCount(initialValue);
10
11 return { count, increment, decrement, reset };
12}
13
14test('useCounter increments', () => {
15 const { result } = renderHook(() => useCounter(0));
16
17 expect(result.current.count).toBe(0);
18
19 act(() => {
20 result.current.increment();
21 });
22
23 expect(result.current.count).toBe(1);
24});
25
26// Hook with dependencies
27test('useCounter with initial value', () => {
28 const { result, rerender } = renderHook(
29 ({ initial }) => useCounter(initial),
30 { initialProps: { initial: 10 } }
31 );
32
33 expect(result.current.count).toBe(10);
34
35 // Update props
36 rerender({ initial: 20 });
37
38 act(() => {
39 result.current.reset();
40 });
41
42 expect(result.current.count).toBe(20);
43});
44
45// Hook with context
46test('hook with provider', () => {
47 const wrapper = ({ children }: { children: React.ReactNode }) => (
48 <ThemeProvider theme="dark">{children}</ThemeProvider>
49 );
50
51 const { result } = renderHook(() => useTheme(), { wrapper });
52
53 expect(result.current.theme).toBe('dark');
54});Mocking#
1// Mock modules
2jest.mock('./api', () => ({
3 fetchUser: jest.fn(),
4}));
5
6import { fetchUser } from './api';
7
8test('handles API data', async () => {
9 (fetchUser as jest.Mock).mockResolvedValue({ name: 'John' });
10
11 render(<UserProfile />);
12
13 await screen.findByText('John');
14});
15
16// Mock with MSW (Mock Service Worker)
17import { rest } from 'msw';
18import { setupServer } from 'msw/node';
19
20const server = setupServer(
21 rest.get('/api/user/:id', (req, res, ctx) => {
22 return res(ctx.json({ id: req.params.id, name: 'John Doe' }));
23 })
24);
25
26beforeAll(() => server.listen());
27afterEach(() => server.resetHandlers());
28afterAll(() => server.close());
29
30test('fetches and displays user', async () => {
31 render(<UserProfile userId="123" />);
32
33 expect(await screen.findByText('John Doe')).toBeInTheDocument();
34});
35
36// Override handlers per test
37test('handles errors', async () => {
38 server.use(
39 rest.get('/api/user/:id', (req, res, ctx) => {
40 return res(ctx.status(500), ctx.json({ error: 'Server error' }));
41 })
42 );
43
44 render(<UserProfile userId="123" />);
45
46 expect(await screen.findByRole('alert')).toHaveTextContent(/error/i);
47});Testing Patterns#
1// Page Object Pattern
2class LoginPage {
3 get emailInput() {
4 return screen.getByLabelText(/email/i);
5 }
6
7 get passwordInput() {
8 return screen.getByLabelText(/password/i);
9 }
10
11 get submitButton() {
12 return screen.getByRole('button', { name: /login/i });
13 }
14
15 async login(user: ReturnType<typeof userEvent.setup>, email: string, password: string) {
16 await user.type(this.emailInput, email);
17 await user.type(this.passwordInput, password);
18 await user.click(this.submitButton);
19 }
20}
21
22test('login flow', async () => {
23 const { user } = setup(<LoginForm />);
24 const page = new LoginPage();
25
26 await page.login(user, 'test@example.com', 'password');
27
28 expect(await screen.findByText(/welcome/i)).toBeInTheDocument();
29});
30
31// Render with providers utility
32function renderWithProviders(
33 ui: React.ReactElement,
34 {
35 preloadedState = {},
36 store = configureStore({ reducer, preloadedState }),
37 ...renderOptions
38 } = {}
39) {
40 function Wrapper({ children }: { children: React.ReactNode }) {
41 return (
42 <Provider store={store}>
43 <ThemeProvider>
44 <Router>{children}</Router>
45 </ThemeProvider>
46 </Provider>
47 );
48 }
49
50 return {
51 store,
52 user: userEvent.setup(),
53 ...render(ui, { wrapper: Wrapper, ...renderOptions }),
54 };
55}
56
57// Accessibility testing
58import { axe, toHaveNoViolations } from 'jest-axe';
59
60expect.extend(toHaveNoViolations);
61
62test('component is accessible', async () => {
63 const { container } = render(<Navigation />);
64
65 const results = await axe(container);
66 expect(results).toHaveNoViolations();
67});Common Assertions#
1import '@testing-library/jest-dom';
2
3test('element states', () => {
4 render(<Form />);
5
6 // Visibility
7 expect(screen.getByText('Visible')).toBeVisible();
8 expect(screen.getByText('Hidden')).not.toBeVisible();
9
10 // Enabled/Disabled
11 expect(screen.getByRole('button')).toBeEnabled();
12 expect(screen.getByRole('textbox')).toBeDisabled();
13
14 // Checked
15 expect(screen.getByRole('checkbox')).toBeChecked();
16
17 // Values
18 expect(screen.getByRole('textbox')).toHaveValue('initial');
19 expect(screen.getByRole('textbox')).toHaveDisplayValue('initial');
20
21 // Classes
22 expect(screen.getByText('Error')).toHaveClass('error');
23
24 // Attributes
25 expect(screen.getByRole('link')).toHaveAttribute('href', '/home');
26
27 // Focus
28 expect(screen.getByRole('textbox')).toHaveFocus();
29
30 // Form validity
31 expect(screen.getByRole('textbox')).toBeInvalid();
32 expect(screen.getByRole('textbox')).toHaveErrorMessage(/required/i);
33});Best Practices#
Queries:
✓ Prefer role queries
✓ Use accessible names
✓ Avoid testId when possible
✓ Use findBy for async elements
User Events:
✓ Use userEvent over fireEvent
✓ Always await user actions
✓ Test keyboard accessibility
✓ Test realistic user flows
Assertions:
✓ Assert on user-visible output
✓ Avoid testing implementation
✓ Test accessibility
✓ Keep assertions focused
Organization:
✓ One assertion focus per test
✓ Use describe blocks for grouping
✓ Extract common setup
✓ Use meaningful test names
Conclusion#
React Testing Library encourages testing from the user's perspective. Prioritize accessible queries, use userEvent for interactions, and assert on visible output rather than implementation details. This approach leads to more maintainable tests that give confidence in your application's behavior.