Back to Blog
ReactTestingJestTesting Library

Testing React Components Effectively

Write maintainable React tests. From unit tests to integration tests to testing hooks and async behavior.

B
Bootspring Team
Engineering
November 12, 2022
6 min read

Good tests give confidence to refactor and ship. Here's how to test React components in a way that's maintainable and catches real bugs.

Testing Philosophy#

Test behavior, not implementation: - What users see and do - Component outputs - Side effects Avoid testing: - Internal state - Implementation details - Things React already tests

Setup#

1// jest.config.js 2module.exports = { 3 testEnvironment: 'jsdom', 4 setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], 5 moduleNameMapper: { 6 '^@/(.*)$': '<rootDir>/src/$1', 7 }, 8}; 9 10// jest.setup.ts 11import '@testing-library/jest-dom'; 12 13// Mock next/router 14jest.mock('next/navigation', () => ({ 15 useRouter: () => ({ 16 push: jest.fn(), 17 replace: jest.fn(), 18 back: jest.fn(), 19 }), 20 usePathname: () => '/', 21 useSearchParams: () => new URLSearchParams(), 22}));

Basic Component Testing#

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 when loading', () => { 30 render(<Button loading>Submit</Button>); 31 32 expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true'); 33 }); 34});

Testing User Interactions#

1import { render, screen, waitFor } from '@testing-library/react'; 2import userEvent from '@testing-library/user-event'; 3import { LoginForm } from './LoginForm'; 4 5describe('LoginForm', () => { 6 const user = userEvent.setup(); 7 8 it('submits form with user data', async () => { 9 const handleSubmit = jest.fn(); 10 render(<LoginForm onSubmit={handleSubmit} />); 11 12 await user.type(screen.getByLabelText(/email/i), 'test@example.com'); 13 await user.type(screen.getByLabelText(/password/i), 'password123'); 14 await user.click(screen.getByRole('button', { name: /sign in/i })); 15 16 expect(handleSubmit).toHaveBeenCalledWith({ 17 email: 'test@example.com', 18 password: 'password123', 19 }); 20 }); 21 22 it('shows validation errors', async () => { 23 render(<LoginForm onSubmit={jest.fn()} />); 24 25 await user.click(screen.getByRole('button', { name: /sign in/i })); 26 27 expect(await screen.findByText(/email is required/i)).toBeInTheDocument(); 28 expect(await screen.findByText(/password is required/i)).toBeInTheDocument(); 29 }); 30 31 it('disables submit while loading', async () => { 32 const handleSubmit = jest.fn(() => new Promise(() => {})); // Never resolves 33 render(<LoginForm onSubmit={handleSubmit} />); 34 35 await user.type(screen.getByLabelText(/email/i), 'test@example.com'); 36 await user.type(screen.getByLabelText(/password/i), 'password123'); 37 await user.click(screen.getByRole('button', { name: /sign in/i })); 38 39 expect(screen.getByRole('button')).toBeDisabled(); 40 }); 41});

Testing Async Behavior#

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

Testing Hooks#

1import { renderHook, act, waitFor } from '@testing-library/react'; 2import { useCounter } from './useCounter'; 3import { useDebounce } from './useDebounce'; 4 5describe('useCounter', () => { 6 it('initializes with default value', () => { 7 const { result } = renderHook(() => useCounter()); 8 9 expect(result.current.count).toBe(0); 10 }); 11 12 it('initializes with provided value', () => { 13 const { result } = renderHook(() => useCounter(10)); 14 15 expect(result.current.count).toBe(10); 16 }); 17 18 it('increments count', () => { 19 const { result } = renderHook(() => useCounter()); 20 21 act(() => { 22 result.current.increment(); 23 }); 24 25 expect(result.current.count).toBe(1); 26 }); 27 28 it('decrements count', () => { 29 const { result } = renderHook(() => useCounter(5)); 30 31 act(() => { 32 result.current.decrement(); 33 }); 34 35 expect(result.current.count).toBe(4); 36 }); 37}); 38 39describe('useDebounce', () => { 40 jest.useFakeTimers(); 41 42 it('returns initial value immediately', () => { 43 const { result } = renderHook(() => useDebounce('hello', 500)); 44 45 expect(result.current).toBe('hello'); 46 }); 47 48 it('debounces value changes', () => { 49 const { result, rerender } = renderHook( 50 ({ value }) => useDebounce(value, 500), 51 { initialProps: { value: 'hello' } } 52 ); 53 54 rerender({ value: 'world' }); 55 56 // Value hasn't changed yet 57 expect(result.current).toBe('hello'); 58 59 // Fast forward past debounce time 60 act(() => { 61 jest.advanceTimersByTime(500); 62 }); 63 64 expect(result.current).toBe('world'); 65 }); 66});

Testing Context#

1import { render, screen } from '@testing-library/react'; 2import userEvent from '@testing-library/user-event'; 3import { ThemeProvider, useTheme } from './ThemeContext'; 4 5// Test component that uses context 6function TestComponent() { 7 const { theme, toggleTheme } = useTheme(); 8 return ( 9 <div> 10 <span data-testid="theme">{theme}</span> 11 <button onClick={toggleTheme}>Toggle</button> 12 </div> 13 ); 14} 15 16describe('ThemeContext', () => { 17 it('provides default theme', () => { 18 render( 19 <ThemeProvider> 20 <TestComponent /> 21 </ThemeProvider> 22 ); 23 24 expect(screen.getByTestId('theme')).toHaveTextContent('light'); 25 }); 26 27 it('toggles theme', async () => { 28 const user = userEvent.setup(); 29 30 render( 31 <ThemeProvider> 32 <TestComponent /> 33 </ThemeProvider> 34 ); 35 36 await user.click(screen.getByRole('button')); 37 38 expect(screen.getByTestId('theme')).toHaveTextContent('dark'); 39 }); 40}); 41 42// Custom render with providers 43function renderWithProviders(ui: React.ReactElement, options = {}) { 44 return render(ui, { 45 wrapper: ({ children }) => ( 46 <ThemeProvider> 47 <AuthProvider> 48 {children} 49 </AuthProvider> 50 </ThemeProvider> 51 ), 52 ...options, 53 }); 54}

Testing with MSW#

1import { rest } from 'msw'; 2import { setupServer } from 'msw/node'; 3import { render, screen, waitFor } from '@testing-library/react'; 4import { UserList } from './UserList'; 5 6const server = setupServer( 7 rest.get('/api/users', (req, res, ctx) => { 8 return res( 9 ctx.json([ 10 { id: '1', name: 'Alice' }, 11 { id: '2', name: 'Bob' }, 12 ]) 13 ); 14 }) 15); 16 17beforeAll(() => server.listen()); 18afterEach(() => server.resetHandlers()); 19afterAll(() => server.close()); 20 21describe('UserList', () => { 22 it('displays users from API', async () => { 23 render(<UserList />); 24 25 expect(await screen.findByText('Alice')).toBeInTheDocument(); 26 expect(screen.getByText('Bob')).toBeInTheDocument(); 27 }); 28 29 it('handles API error', async () => { 30 server.use( 31 rest.get('/api/users', (req, res, ctx) => { 32 return res(ctx.status(500)); 33 }) 34 ); 35 36 render(<UserList />); 37 38 expect(await screen.findByText(/error loading users/i)).toBeInTheDocument(); 39 }); 40 41 it('handles empty state', async () => { 42 server.use( 43 rest.get('/api/users', (req, res, ctx) => { 44 return res(ctx.json([])); 45 }) 46 ); 47 48 render(<UserList />); 49 50 expect(await screen.findByText(/no users found/i)).toBeInTheDocument(); 51 }); 52});

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"> 8 <p>Card content</p> 9 </Card> 10 ); 11 12 expect(container.firstChild).toMatchSnapshot(); 13 }); 14 15 // Inline snapshots 16 it('renders title correctly', () => { 17 const { container } = render(<Card title="Hello" />); 18 19 expect(container.firstChild).toMatchInlineSnapshot(` 20 <div class="card"> 21 <h2 class="card-title">Hello</h2> 22 </div> 23 `); 24 }); 25});

Best Practices#

Queries: ✓ getByRole > getByText > getByTestId ✓ Use accessible queries ✓ Query what users see ✓ Use findBy for async Structure: ✓ Arrange, Act, Assert ✓ One assertion focus per test ✓ Descriptive test names ✓ Setup common in beforeEach Avoid: ✗ Testing implementation details ✗ Snapshot testing everything ✗ Testing third-party libraries ✗ Overly complex test setups

Conclusion#

Test React components by focusing on user behavior and outcomes. Use Testing Library's queries that encourage accessibility, mock external dependencies with MSW, and avoid testing implementation details. Good tests should survive refactors that don't change behavior.

Share this article

Help spread the word about Bootspring