Back to Blog
ReactTestingTesting LibraryJest

React Testing Library Best Practices

Write effective React tests with Testing Library. From queries to user events to async testing patterns.

B
Bootspring Team
Engineering
February 28, 2022
6 min read

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.

Share this article

Help spread the word about Bootspring