Back to Blog
ReactTestingJestTesting Library

React Testing Best Practices

Write effective React tests. From component testing to integration tests to testing hooks.

B
Bootspring Team
Engineering
December 3, 2020
6 min read

Effective tests give confidence without slowing development. Here's how to test React applications.

Testing Philosophy#

1// Test behavior, not implementation 2// Bad: Testing implementation details 3test('button has onClick handler', () => { 4 const wrapper = shallow(<Button />); 5 expect(wrapper.instance().handleClick).toBeDefined(); 6}); 7 8// Good: Testing behavior 9test('calls onSubmit when clicked', async () => { 10 const handleSubmit = jest.fn(); 11 render(<Button onSubmit={handleSubmit}>Submit</Button>); 12 13 await userEvent.click(screen.getByRole('button')); 14 15 expect(handleSubmit).toHaveBeenCalledTimes(1); 16});

Component Testing#

1import { render, screen } from '@testing-library/react'; 2import userEvent from '@testing-library/user-event'; 3 4// Basic render test 5test('renders greeting', () => { 6 render(<Greeting name="Alice" />); 7 8 expect(screen.getByText(/hello, alice/i)).toBeInTheDocument(); 9}); 10 11// Testing interactions 12test('increments counter on click', async () => { 13 const user = userEvent.setup(); 14 render(<Counter />); 15 16 const button = screen.getByRole('button', { name: /increment/i }); 17 await user.click(button); 18 19 expect(screen.getByText('Count: 1')).toBeInTheDocument(); 20}); 21 22// Testing form submission 23test('submits form with user data', async () => { 24 const user = userEvent.setup(); 25 const handleSubmit = jest.fn(); 26 27 render(<LoginForm onSubmit={handleSubmit} />); 28 29 await user.type(screen.getByLabelText(/email/i), 'test@example.com'); 30 await user.type(screen.getByLabelText(/password/i), 'password123'); 31 await user.click(screen.getByRole('button', { name: /sign in/i })); 32 33 expect(handleSubmit).toHaveBeenCalledWith({ 34 email: 'test@example.com', 35 password: 'password123', 36 }); 37}); 38 39// Testing conditional rendering 40test('shows error message on invalid input', async () => { 41 const user = userEvent.setup(); 42 render(<EmailInput />); 43 44 const input = screen.getByLabelText(/email/i); 45 await user.type(input, 'invalid-email'); 46 await user.tab(); // Blur to trigger validation 47 48 expect(screen.getByText(/invalid email/i)).toBeInTheDocument(); 49});

Async Testing#

1import { render, screen, waitFor } from '@testing-library/react'; 2 3// Testing loading states 4test('shows loading then data', async () => { 5 render(<UserProfile userId="1" />); 6 7 // Initially shows loading 8 expect(screen.getByText(/loading/i)).toBeInTheDocument(); 9 10 // Wait for data 11 await waitFor(() => { 12 expect(screen.getByText('Alice')).toBeInTheDocument(); 13 }); 14 15 // Loading should be gone 16 expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); 17}); 18 19// Testing with findBy (waits automatically) 20test('loads and displays user', async () => { 21 render(<UserProfile userId="1" />); 22 23 const userName = await screen.findByText('Alice'); 24 expect(userName).toBeInTheDocument(); 25}); 26 27// Testing error states 28test('shows error on fetch failure', async () => { 29 server.use( 30 rest.get('/api/user', (req, res, ctx) => { 31 return res(ctx.status(500)); 32 }) 33 ); 34 35 render(<UserProfile userId="1" />); 36 37 await waitFor(() => { 38 expect(screen.getByText(/error loading user/i)).toBeInTheDocument(); 39 }); 40});

Mocking#

1// Mock modules 2jest.mock('./api', () => ({ 3 fetchUser: jest.fn(), 4})); 5 6import { fetchUser } from './api'; 7 8beforeEach(() => { 9 (fetchUser as jest.Mock).mockClear(); 10}); 11 12test('fetches user on mount', async () => { 13 (fetchUser as jest.Mock).mockResolvedValue({ name: 'Alice' }); 14 15 render(<UserProfile userId="1" />); 16 17 await waitFor(() => { 18 expect(fetchUser).toHaveBeenCalledWith('1'); 19 }); 20}); 21 22// Mock child components 23jest.mock('./HeavyComponent', () => ({ 24 HeavyComponent: () => <div data-testid="mock-heavy">Mocked</div>, 25})); 26 27// Mock hooks 28jest.mock('./useAuth', () => ({ 29 useAuth: () => ({ 30 user: { id: '1', name: 'Alice' }, 31 isAuthenticated: true, 32 }), 33})); 34 35// Mock with implementation 36const mockNavigate = jest.fn(); 37jest.mock('react-router-dom', () => ({ 38 ...jest.requireActual('react-router-dom'), 39 useNavigate: () => mockNavigate, 40})); 41 42test('navigates on success', async () => { 43 render(<LoginForm />); 44 // ... submit form 45 46 await waitFor(() => { 47 expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); 48 }); 49});

Testing Hooks#

1import { renderHook, act } from '@testing-library/react'; 2 3// Basic hook test 4test('useCounter increments', () => { 5 const { result } = renderHook(() => useCounter()); 6 7 act(() => { 8 result.current.increment(); 9 }); 10 11 expect(result.current.count).toBe(1); 12}); 13 14// Hook with dependencies 15test('useCounter starts with initial value', () => { 16 const { result } = renderHook(() => useCounter(10)); 17 18 expect(result.current.count).toBe(10); 19}); 20 21// Hook with changing props 22test('useCounter resets on prop change', () => { 23 const { result, rerender } = renderHook( 24 ({ initial }) => useCounter(initial), 25 { initialProps: { initial: 0 } } 26 ); 27 28 act(() => { 29 result.current.increment(); 30 }); 31 32 expect(result.current.count).toBe(1); 33 34 rerender({ initial: 10 }); 35 36 expect(result.current.count).toBe(10); 37}); 38 39// Async hook 40test('useFetch returns data', async () => { 41 const { result } = renderHook(() => useFetch('/api/data')); 42 43 expect(result.current.loading).toBe(true); 44 45 await waitFor(() => { 46 expect(result.current.loading).toBe(false); 47 }); 48 49 expect(result.current.data).toEqual({ id: 1 }); 50}); 51 52// Hook with wrapper (for context) 53test('useUser returns user from context', () => { 54 const wrapper = ({ children }) => ( 55 <UserProvider value={{ name: 'Alice' }}> 56 {children} 57 </UserProvider> 58 ); 59 60 const { result } = renderHook(() => useUser(), { wrapper }); 61 62 expect(result.current.name).toBe('Alice'); 63});

Testing with Context#

1// Create test wrapper 2function renderWithProviders( 3 ui: React.ReactElement, 4 { 5 initialState = {}, 6 ...renderOptions 7 } = {} 8) { 9 function Wrapper({ children }: { children: React.ReactNode }) { 10 return ( 11 <ThemeProvider> 12 <AuthProvider initialState={initialState}> 13 <Router> 14 {children} 15 </Router> 16 </AuthProvider> 17 </ThemeProvider> 18 ); 19 } 20 21 return render(ui, { wrapper: Wrapper, ...renderOptions }); 22} 23 24// Use in tests 25test('shows user name', () => { 26 renderWithProviders(<UserMenu />, { 27 initialState: { user: { name: 'Alice' } }, 28 }); 29 30 expect(screen.getByText('Alice')).toBeInTheDocument(); 31});

Integration Tests#

1// Test full user flow 2test('complete checkout flow', async () => { 3 const user = userEvent.setup(); 4 5 renderWithProviders(<App />); 6 7 // Add item to cart 8 await user.click(screen.getByRole('button', { name: /add to cart/i })); 9 10 // Go to cart 11 await user.click(screen.getByRole('link', { name: /cart/i })); 12 13 // Proceed to checkout 14 await user.click(screen.getByRole('button', { name: /checkout/i })); 15 16 // Fill shipping info 17 await user.type(screen.getByLabelText(/address/i), '123 Main St'); 18 await user.click(screen.getByRole('button', { name: /continue/i })); 19 20 // Fill payment info 21 await user.type(screen.getByLabelText(/card number/i), '4242424242424242'); 22 await user.click(screen.getByRole('button', { name: /place order/i })); 23 24 // Verify success 25 await waitFor(() => { 26 expect(screen.getByText(/order confirmed/i)).toBeInTheDocument(); 27 }); 28});

MSW for API Mocking#

1import { rest } from 'msw'; 2import { setupServer } from 'msw/node'; 3 4// Setup server 5const server = setupServer( 6 rest.get('/api/users', (req, res, ctx) => { 7 return res(ctx.json([ 8 { id: 1, name: 'Alice' }, 9 { id: 2, name: 'Bob' }, 10 ])); 11 }), 12 13 rest.post('/api/users', async (req, res, ctx) => { 14 const { name } = await req.json(); 15 return res(ctx.json({ id: 3, name })); 16 }) 17); 18 19beforeAll(() => server.listen()); 20afterEach(() => server.resetHandlers()); 21afterAll(() => server.close()); 22 23// Use in tests 24test('creates new user', async () => { 25 const user = userEvent.setup(); 26 27 render(<CreateUserForm />); 28 29 await user.type(screen.getByLabelText(/name/i), 'Charlie'); 30 await user.click(screen.getByRole('button', { name: /create/i })); 31 32 await waitFor(() => { 33 expect(screen.getByText(/user created/i)).toBeInTheDocument(); 34 }); 35}); 36 37// Override handlers in specific tests 38test('handles server error', async () => { 39 server.use( 40 rest.post('/api/users', (req, res, ctx) => { 41 return res(ctx.status(500), ctx.json({ error: 'Server error' })); 42 }) 43 ); 44 45 render(<CreateUserForm />); 46 // ... test error handling 47});

Snapshot Testing#

1// Use sparingly, for stable UI 2test('renders correctly', () => { 3 const { container } = render(<Icon name="star" />); 4 expect(container.firstChild).toMatchSnapshot(); 5}); 6 7// Inline snapshots 8test('formats date correctly', () => { 9 expect(formatDate(new Date('2024-01-15'))).toMatchInlineSnapshot( 10 `"January 15, 2024"` 11 ); 12});

Accessibility Testing#

1import { axe, toHaveNoViolations } from 'jest-axe'; 2 3expect.extend(toHaveNoViolations); 4 5test('has no accessibility violations', async () => { 6 const { container } = render(<Form />); 7 const results = await axe(container); 8 9 expect(results).toHaveNoViolations(); 10}); 11 12// Test specific accessibility 13test('form inputs have labels', () => { 14 render(<LoginForm />); 15 16 expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); 17 expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); 18}); 19 20test('buttons are keyboard accessible', async () => { 21 const user = userEvent.setup(); 22 const handleClick = jest.fn(); 23 24 render(<Button onClick={handleClick}>Click me</Button>); 25 26 const button = screen.getByRole('button'); 27 button.focus(); 28 29 await user.keyboard('{Enter}'); 30 31 expect(handleClick).toHaveBeenCalled(); 32});

Best Practices#

Principles: ✓ Test behavior, not implementation ✓ Write tests from user perspective ✓ Use semantic queries (getByRole, getByLabelText) ✓ Avoid testing internal state Organization: ✓ One assertion per test when possible ✓ Use descriptive test names ✓ Group related tests with describe ✓ Keep tests independent Performance: ✓ Mock expensive operations ✓ Use beforeEach for setup ✓ Clean up after tests ✓ Run tests in parallel Coverage: ✓ Focus on critical paths ✓ Test error states ✓ Test edge cases ✓ Don't chase 100% coverage

Conclusion#

Effective React testing focuses on user behavior rather than implementation details. Use Testing Library's semantic queries, mock external dependencies with MSW, and test hooks with renderHook. Write integration tests for critical flows and keep tests maintainable by avoiding implementation details.

Share this article

Help spread the word about Bootspring