Back to Blog
ReactTestingJestTesting Library

React Testing Strategies

Build comprehensive React tests. From unit tests to integration to end-to-end testing patterns.

B
Bootspring Team
Engineering
May 24, 2021
6 min read

Effective testing ensures reliable React applications. Here's a comprehensive approach.

Testing Pyramid#

/\ / \ E2E Tests (few) /----\ - Critical user flows / \ - Slow but realistic /--------\ Integration Tests (some) / \ - Component interactions /------------\ Unit Tests (many) / \ - Fast, isolated /__________________\

Unit Testing Components#

1import { render, screen } from '@testing-library/react'; 2import userEvent from '@testing-library/user-event'; 3import { Button } from './Button'; 4 5describe('Button', () => { 6 it('renders with text', () => { 7 render(<Button>Click me</Button>); 8 9 expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument(); 10 }); 11 12 it('calls onClick when clicked', async () => { 13 const handleClick = jest.fn(); 14 const user = userEvent.setup(); 15 16 render(<Button onClick={handleClick}>Click me</Button>); 17 18 await user.click(screen.getByRole('button')); 19 20 expect(handleClick).toHaveBeenCalledTimes(1); 21 }); 22 23 it('is disabled when loading', () => { 24 render(<Button loading>Submit</Button>); 25 26 expect(screen.getByRole('button')).toBeDisabled(); 27 }); 28 29 it('shows loading spinner', () => { 30 render(<Button loading>Submit</Button>); 31 32 expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true'); 33 expect(screen.getByTestId('spinner')).toBeInTheDocument(); 34 }); 35});

Testing Hooks#

1import { renderHook, act } from '@testing-library/react'; 2import { useCounter } from './useCounter'; 3 4describe('useCounter', () => { 5 it('initializes with default value', () => { 6 const { result } = renderHook(() => useCounter()); 7 8 expect(result.current.count).toBe(0); 9 }); 10 11 it('initializes with provided value', () => { 12 const { result } = renderHook(() => useCounter(10)); 13 14 expect(result.current.count).toBe(10); 15 }); 16 17 it('increments count', () => { 18 const { result } = renderHook(() => useCounter()); 19 20 act(() => { 21 result.current.increment(); 22 }); 23 24 expect(result.current.count).toBe(1); 25 }); 26 27 it('decrements count', () => { 28 const { result } = renderHook(() => useCounter(5)); 29 30 act(() => { 31 result.current.decrement(); 32 }); 33 34 expect(result.current.count).toBe(4); 35 }); 36 37 it('resets to initial value', () => { 38 const { result } = renderHook(() => useCounter(10)); 39 40 act(() => { 41 result.current.increment(); 42 result.current.increment(); 43 result.current.reset(); 44 }); 45 46 expect(result.current.count).toBe(10); 47 }); 48});

Testing Async Operations#

1import { render, screen, waitFor } from '@testing-library/react'; 2import userEvent from '@testing-library/user-event'; 3import { UserProfile } from './UserProfile'; 4 5// Mock fetch 6global.fetch = jest.fn(); 7 8describe('UserProfile', () => { 9 beforeEach(() => { 10 (fetch as jest.Mock).mockClear(); 11 }); 12 13 it('shows loading state initially', () => { 14 (fetch as jest.Mock).mockImplementation(() => new Promise(() => {})); 15 16 render(<UserProfile userId="1" />); 17 18 expect(screen.getByText(/loading/i)).toBeInTheDocument(); 19 }); 20 21 it('displays user data after loading', async () => { 22 (fetch as jest.Mock).mockResolvedValue({ 23 ok: true, 24 json: async () => ({ name: 'John Doe', email: 'john@example.com' }), 25 }); 26 27 render(<UserProfile userId="1" />); 28 29 await waitFor(() => { 30 expect(screen.getByText('John Doe')).toBeInTheDocument(); 31 }); 32 33 expect(screen.getByText('john@example.com')).toBeInTheDocument(); 34 }); 35 36 it('displays error on fetch failure', async () => { 37 (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); 38 39 render(<UserProfile userId="1" />); 40 41 await waitFor(() => { 42 expect(screen.getByText(/error/i)).toBeInTheDocument(); 43 }); 44 }); 45});

Testing Forms#

1import { render, screen, waitFor } from '@testing-library/react'; 2import userEvent from '@testing-library/user-event'; 3import { ContactForm } from './ContactForm'; 4 5describe('ContactForm', () => { 6 it('submits form with valid data', async () => { 7 const handleSubmit = jest.fn(); 8 const user = userEvent.setup(); 9 10 render(<ContactForm onSubmit={handleSubmit} />); 11 12 await user.type(screen.getByLabelText(/name/i), 'John Doe'); 13 await user.type(screen.getByLabelText(/email/i), 'john@example.com'); 14 await user.type(screen.getByLabelText(/message/i), 'Hello there!'); 15 16 await user.click(screen.getByRole('button', { name: /submit/i })); 17 18 await waitFor(() => { 19 expect(handleSubmit).toHaveBeenCalledWith({ 20 name: 'John Doe', 21 email: 'john@example.com', 22 message: 'Hello there!', 23 }); 24 }); 25 }); 26 27 it('shows validation errors', async () => { 28 const user = userEvent.setup(); 29 30 render(<ContactForm onSubmit={jest.fn()} />); 31 32 await user.click(screen.getByRole('button', { name: /submit/i })); 33 34 await waitFor(() => { 35 expect(screen.getByText(/name is required/i)).toBeInTheDocument(); 36 expect(screen.getByText(/email is required/i)).toBeInTheDocument(); 37 }); 38 }); 39 40 it('validates email format', async () => { 41 const user = userEvent.setup(); 42 43 render(<ContactForm onSubmit={jest.fn()} />); 44 45 await user.type(screen.getByLabelText(/email/i), 'invalid-email'); 46 await user.tab(); // Trigger blur 47 48 await waitFor(() => { 49 expect(screen.getByText(/invalid email/i)).toBeInTheDocument(); 50 }); 51 }); 52});

Integration Testing#

1import { render, screen, waitFor } from '@testing-library/react'; 2import userEvent from '@testing-library/user-event'; 3import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4import { App } from './App'; 5import { server } from './mocks/server'; 6import { rest } from 'msw'; 7 8// Setup MSW 9beforeAll(() => server.listen()); 10afterEach(() => server.resetHandlers()); 11afterAll(() => server.close()); 12 13function renderApp() { 14 const queryClient = new QueryClient({ 15 defaultOptions: { 16 queries: { retry: false }, 17 }, 18 }); 19 20 return render( 21 <QueryClientProvider client={queryClient}> 22 <App /> 23 </QueryClientProvider> 24 ); 25} 26 27describe('App Integration', () => { 28 it('displays user list and allows viewing details', async () => { 29 const user = userEvent.setup(); 30 31 renderApp(); 32 33 // Wait for user list to load 34 await waitFor(() => { 35 expect(screen.getByText('John Doe')).toBeInTheDocument(); 36 }); 37 38 // Click on user 39 await user.click(screen.getByText('John Doe')); 40 41 // Verify details are shown 42 await waitFor(() => { 43 expect(screen.getByText('john@example.com')).toBeInTheDocument(); 44 }); 45 }); 46 47 it('handles API errors gracefully', async () => { 48 server.use( 49 rest.get('/api/users', (req, res, ctx) => { 50 return res(ctx.status(500)); 51 }) 52 ); 53 54 renderApp(); 55 56 await waitFor(() => { 57 expect(screen.getByText(/error loading users/i)).toBeInTheDocument(); 58 }); 59 60 // Verify retry button exists 61 expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); 62 }); 63});

Mock Service Worker#

1// mocks/handlers.ts 2import { rest } from 'msw'; 3 4export const handlers = [ 5 rest.get('/api/users', (req, res, ctx) => { 6 return res( 7 ctx.json([ 8 { id: 1, name: 'John Doe', email: 'john@example.com' }, 9 { id: 2, name: 'Jane Doe', email: 'jane@example.com' }, 10 ]) 11 ); 12 }), 13 14 rest.get('/api/users/:id', (req, res, ctx) => { 15 const { id } = req.params; 16 return res( 17 ctx.json({ 18 id: Number(id), 19 name: 'John Doe', 20 email: 'john@example.com', 21 }) 22 ); 23 }), 24 25 rest.post('/api/users', async (req, res, ctx) => { 26 const body = await req.json(); 27 return res( 28 ctx.status(201), 29 ctx.json({ id: 3, ...body }) 30 ); 31 }), 32]; 33 34// mocks/server.ts 35import { setupServer } from 'msw/node'; 36import { handlers } from './handlers'; 37 38export const server = setupServer(...handlers);

Testing Context#

1import { render, screen } from '@testing-library/react'; 2import userEvent from '@testing-library/user-event'; 3import { ThemeProvider, useTheme } from './ThemeContext'; 4 5function TestComponent() { 6 const { theme, toggleTheme } = useTheme(); 7 return ( 8 <div> 9 <span>Theme: {theme}</span> 10 <button onClick={toggleTheme}>Toggle</button> 11 </div> 12 ); 13} 14 15describe('ThemeContext', () => { 16 it('provides default theme', () => { 17 render( 18 <ThemeProvider> 19 <TestComponent /> 20 </ThemeProvider> 21 ); 22 23 expect(screen.getByText('Theme: light')).toBeInTheDocument(); 24 }); 25 26 it('toggles theme', async () => { 27 const user = userEvent.setup(); 28 29 render( 30 <ThemeProvider> 31 <TestComponent /> 32 </ThemeProvider> 33 ); 34 35 await user.click(screen.getByRole('button')); 36 37 expect(screen.getByText('Theme: dark')).toBeInTheDocument(); 38 }); 39});

Snapshot Testing#

1import { render } from '@testing-library/react'; 2import { Card } from './Card'; 3 4describe('Card', () => { 5 it('matches snapshot', () => { 6 const { container } = render( 7 <Card title="Test Card" description="Test description" /> 8 ); 9 10 expect(container).toMatchSnapshot(); 11 }); 12 13 it('matches inline snapshot', () => { 14 const { container } = render(<Card title="Simple" />); 15 16 expect(container).toMatchInlineSnapshot(` 17 <div> 18 <div class="card"> 19 <h2>Simple</h2> 20 </div> 21 </div> 22 `); 23 }); 24});

Test Utilities#

1// test-utils.tsx 2import { render, RenderOptions } from '@testing-library/react'; 3import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4import { ThemeProvider } from './ThemeContext'; 5import { AuthProvider } from './AuthContext'; 6 7interface WrapperProps { 8 children: React.ReactNode; 9} 10 11function AllProviders({ children }: WrapperProps) { 12 const queryClient = new QueryClient({ 13 defaultOptions: { 14 queries: { retry: false }, 15 }, 16 }); 17 18 return ( 19 <QueryClientProvider client={queryClient}> 20 <AuthProvider> 21 <ThemeProvider>{children}</ThemeProvider> 22 </AuthProvider> 23 </QueryClientProvider> 24 ); 25} 26 27function customRender( 28 ui: React.ReactElement, 29 options?: Omit<RenderOptions, 'wrapper'> 30) { 31 return render(ui, { wrapper: AllProviders, ...options }); 32} 33 34export * from '@testing-library/react'; 35export { customRender as render };

Best Practices#

Guidelines: ✓ Test behavior, not implementation ✓ Use accessible queries (getByRole, getByLabelText) ✓ Avoid testing internal state ✓ Keep tests focused and readable Performance: ✓ Use MSW for API mocking ✓ Run tests in parallel ✓ Reset state between tests ✓ Use beforeEach/afterEach properly Coverage: ✓ Aim for meaningful coverage ✓ Test error states ✓ Test loading states ✓ Test edge cases

Conclusion#

Effective React testing combines unit tests for components and hooks, integration tests for feature flows, and E2E tests for critical paths. Use Testing Library's accessible queries, MSW for API mocking, and focus on testing user behavior rather than implementation details.

Share this article

Help spread the word about Bootspring