Component Testing Pattern

Test React components effectively with React Testing Library, including user interactions, async operations, context providers, and hooks.

Overview#

Component testing verifies that React components render correctly and respond to user interactions as expected. React Testing Library encourages testing from the user's perspective.

When to use:

  • Testing component rendering and output
  • Verifying user interactions work correctly
  • Testing form validation and submission
  • Testing components with context and hooks

Key features:

  • User-centric testing approach
  • Async operation handling
  • Accessibility-first queries
  • Context provider support

Code Example#

Basic Component Test#

1// __tests__/components/Button.test.tsx 2import { render, screen, fireEvent } from '@testing-library/react' 3import { vi, describe, it, expect } from 'vitest' 4import { Button } from '@/components/ui/Button' 5 6describe('Button', () => { 7 it('renders with text', () => { 8 render(<Button>Click me</Button>) 9 expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument() 10 }) 11 12 it('calls onClick when clicked', () => { 13 const handleClick = vi.fn() 14 render(<Button onClick={handleClick}>Click me</Button>) 15 16 fireEvent.click(screen.getByRole('button')) 17 expect(handleClick).toHaveBeenCalledTimes(1) 18 }) 19 20 it('is disabled when disabled prop is true', () => { 21 render(<Button disabled>Click me</Button>) 22 expect(screen.getByRole('button')).toBeDisabled() 23 }) 24 25 it('renders with different variants', () => { 26 const { rerender } = render(<Button variant="primary">Primary</Button>) 27 expect(screen.getByRole('button')).toHaveClass('bg-blue-600') 28 29 rerender(<Button variant="secondary">Secondary</Button>) 30 expect(screen.getByRole('button')).toHaveClass('bg-gray-200') 31 }) 32 33 it('shows loading state', () => { 34 render(<Button loading>Submit</Button>) 35 expect(screen.getByRole('button')).toBeDisabled() 36 expect(screen.getByTestId('spinner')).toBeInTheDocument() 37 }) 38})

Testing with User Events#

1// __tests__/components/Form.test.tsx 2import { render, screen, waitFor } from '@testing-library/react' 3import userEvent from '@testing-library/user-event' 4import { vi, describe, it, expect } from 'vitest' 5import { ContactForm } from '@/components/ContactForm' 6 7describe('ContactForm', () => { 8 it('submits form with valid data', async () => { 9 const user = userEvent.setup() 10 const onSubmit = vi.fn() 11 12 render(<ContactForm onSubmit={onSubmit} />) 13 14 await user.type(screen.getByLabelText('Name'), 'John Doe') 15 await user.type(screen.getByLabelText('Email'), 'john@example.com') 16 await user.type(screen.getByLabelText('Message'), 'Hello, this is a test message') 17 18 await user.click(screen.getByRole('button', { name: 'Submit' })) 19 20 await waitFor(() => { 21 expect(onSubmit).toHaveBeenCalledWith({ 22 name: 'John Doe', 23 email: 'john@example.com', 24 message: 'Hello, this is a test message' 25 }) 26 }) 27 }) 28 29 it('shows validation errors', async () => { 30 const user = userEvent.setup() 31 render(<ContactForm onSubmit={vi.fn()} />) 32 33 await user.click(screen.getByRole('button', { name: 'Submit' })) 34 35 expect(await screen.findByText('Name is required')).toBeInTheDocument() 36 expect(screen.getByText('Email is required')).toBeInTheDocument() 37 }) 38 39 it('validates email format', async () => { 40 const user = userEvent.setup() 41 render(<ContactForm onSubmit={vi.fn()} />) 42 43 await user.type(screen.getByLabelText('Email'), 'invalid-email') 44 await user.click(screen.getByRole('button', { name: 'Submit' })) 45 46 expect(await screen.findByText('Invalid email address')).toBeInTheDocument() 47 }) 48})

Testing Async Components#

1// __tests__/components/UserList.test.tsx 2import { render, screen, waitFor } from '@testing-library/react' 3import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' 4import { UserList } from '@/components/UserList' 5import { server } from '@/mocks/server' 6import { http, HttpResponse } from 'msw' 7 8beforeAll(() => server.listen()) 9afterEach(() => server.resetHandlers()) 10afterAll(() => server.close()) 11 12describe('UserList', () => { 13 it('renders users after loading', async () => { 14 server.use( 15 http.get('/api/users', () => { 16 return HttpResponse.json([ 17 { id: '1', name: 'Alice' }, 18 { id: '2', name: 'Bob' } 19 ]) 20 }) 21 ) 22 23 render(<UserList />) 24 25 expect(screen.getByText('Loading...')).toBeInTheDocument() 26 27 await waitFor(() => { 28 expect(screen.getByText('Alice')).toBeInTheDocument() 29 expect(screen.getByText('Bob')).toBeInTheDocument() 30 }) 31 }) 32 33 it('shows error message on failure', async () => { 34 server.use( 35 http.get('/api/users', () => { 36 return HttpResponse.json({ error: 'Server error' }, { status: 500 }) 37 }) 38 ) 39 40 render(<UserList />) 41 42 await waitFor(() => { 43 expect(screen.getByText('Failed to load users')).toBeInTheDocument() 44 }) 45 }) 46 47 it('shows empty state when no users', async () => { 48 server.use( 49 http.get('/api/users', () => { 50 return HttpResponse.json([]) 51 }) 52 ) 53 54 render(<UserList />) 55 56 await waitFor(() => { 57 expect(screen.getByText('No users found')).toBeInTheDocument() 58 }) 59 }) 60})

Testing with Context#

1// __tests__/components/ThemeToggle.test.tsx 2import { render, screen } from '@testing-library/react' 3import userEvent from '@testing-library/user-event' 4import { vi, describe, it, expect } from 'vitest' 5import { ThemeProvider, useTheme } from '@/contexts/ThemeContext' 6import { ThemeToggle } from '@/components/ThemeToggle' 7 8function renderWithTheme(component: React.ReactNode, initialTheme = 'light') { 9 return render( 10 <ThemeProvider defaultTheme={initialTheme}> 11 {component} 12 </ThemeProvider> 13 ) 14} 15 16describe('ThemeToggle', () => { 17 it('toggles theme on click', async () => { 18 const user = userEvent.setup() 19 renderWithTheme(<ThemeToggle />) 20 21 const button = screen.getByRole('button') 22 expect(button).toHaveAttribute('aria-label', 'Switch to dark mode') 23 24 await user.click(button) 25 expect(button).toHaveAttribute('aria-label', 'Switch to light mode') 26 }) 27 28 it('starts with correct theme from context', () => { 29 renderWithTheme(<ThemeToggle />, 'dark') 30 31 const button = screen.getByRole('button') 32 expect(button).toHaveAttribute('aria-label', 'Switch to light mode') 33 }) 34}) 35 36// Test with custom wrapper 37const AllProviders = ({ children }: { children: React.ReactNode }) => ( 38 <ThemeProvider> 39 <AuthProvider> 40 {children} 41 </AuthProvider> 42 </ThemeProvider> 43) 44 45const customRender = (ui: React.ReactElement) => 46 render(ui, { wrapper: AllProviders })

Testing Hooks#

1// __tests__/hooks/useCounter.test.ts 2import { renderHook, act } from '@testing-library/react' 3import { describe, it, expect } from 'vitest' 4import { useCounter } from '@/hooks/useCounter' 5 6describe('useCounter', () => { 7 it('initializes with default value', () => { 8 const { result } = renderHook(() => useCounter()) 9 expect(result.current.count).toBe(0) 10 }) 11 12 it('initializes with provided value', () => { 13 const { result } = renderHook(() => useCounter(10)) 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(5)) 39 40 act(() => { 41 result.current.increment() 42 result.current.increment() 43 result.current.reset() 44 }) 45 46 expect(result.current.count).toBe(5) 47 }) 48}) 49 50// Testing hooks with dependencies 51describe('useDebounce', () => { 52 beforeEach(() => { 53 vi.useFakeTimers() 54 }) 55 56 afterEach(() => { 57 vi.useRealTimers() 58 }) 59 60 it('debounces value changes', () => { 61 const { result, rerender } = renderHook( 62 ({ value }) => useDebounce(value, 500), 63 { initialProps: { value: 'hello' } } 64 ) 65 66 expect(result.current).toBe('hello') 67 68 rerender({ value: 'world' }) 69 expect(result.current).toBe('hello') // Still old value 70 71 act(() => { 72 vi.advanceTimersByTime(500) 73 }) 74 75 expect(result.current).toBe('world') 76 }) 77})

Testing Server Components#

1// __tests__/components/UserProfile.test.tsx 2// For Server Components, test the rendered output 3import { render, screen } from '@testing-library/react' 4import { vi, describe, it, expect } from 'vitest' 5 6// Mock the data fetching 7vi.mock('@/lib/users', () => ({ 8 getUser: vi.fn().mockResolvedValue({ 9 id: '1', 10 name: 'John Doe', 11 email: 'john@example.com' 12 }) 13})) 14 15// Import after mocking 16import { UserProfile } from '@/components/UserProfile' 17 18describe('UserProfile', () => { 19 it('renders user information', async () => { 20 const Component = await UserProfile({ userId: '1' }) 21 render(Component) 22 23 expect(screen.getByText('John Doe')).toBeInTheDocument() 24 expect(screen.getByText('john@example.com')).toBeInTheDocument() 25 }) 26})

Testing Modal Components#

1// __tests__/components/Modal.test.tsx 2import { render, screen, waitFor } from '@testing-library/react' 3import userEvent from '@testing-library/user-event' 4import { vi, describe, it, expect } from 'vitest' 5import { Modal } from '@/components/Modal' 6 7describe('Modal', () => { 8 it('renders when open', () => { 9 render( 10 <Modal isOpen={true} onClose={vi.fn()}> 11 <p>Modal content</p> 12 </Modal> 13 ) 14 15 expect(screen.getByRole('dialog')).toBeInTheDocument() 16 expect(screen.getByText('Modal content')).toBeInTheDocument() 17 }) 18 19 it('does not render when closed', () => { 20 render( 21 <Modal isOpen={false} onClose={vi.fn()}> 22 <p>Modal content</p> 23 </Modal> 24 ) 25 26 expect(screen.queryByRole('dialog')).not.toBeInTheDocument() 27 }) 28 29 it('calls onClose when clicking backdrop', async () => { 30 const user = userEvent.setup() 31 const onClose = vi.fn() 32 33 render( 34 <Modal isOpen={true} onClose={onClose}> 35 <p>Modal content</p> 36 </Modal> 37 ) 38 39 await user.click(screen.getByTestId('modal-backdrop')) 40 expect(onClose).toHaveBeenCalled() 41 }) 42 43 it('calls onClose when pressing Escape', async () => { 44 const user = userEvent.setup() 45 const onClose = vi.fn() 46 47 render( 48 <Modal isOpen={true} onClose={onClose}> 49 <p>Modal content</p> 50 </Modal> 51 ) 52 53 await user.keyboard('{Escape}') 54 expect(onClose).toHaveBeenCalled() 55 }) 56})

Usage Instructions#

  1. Install dependencies: npm install -D @testing-library/react @testing-library/user-event
  2. Set up test environment: Configure jsdom in vitest.config.ts
  3. Write component tests: Focus on user behavior and output
  4. Use proper queries: Prefer accessible queries (getByRole, getByLabelText)
  5. Handle async operations: Use waitFor and findBy queries

Best Practices#

  1. Test from user perspective - Focus on what users see and do
  2. Use accessible queries - Prefer getByRole, getByLabelText
  3. Avoid implementation details - Don't test internal state
  4. Use userEvent over fireEvent - More realistic user simulation
  5. Test loading and error states - Cover all UI states
  6. Keep tests focused - One behavior per test
  7. Use screen queries - Cleaner than destructuring render result