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#
- Install dependencies:
npm install -D @testing-library/react @testing-library/user-event - Set up test environment: Configure jsdom in vitest.config.ts
- Write component tests: Focus on user behavior and output
- Use proper queries: Prefer accessible queries (getByRole, getByLabelText)
- Handle async operations: Use waitFor and findBy queries
Best Practices#
- Test from user perspective - Focus on what users see and do
- Use accessible queries - Prefer getByRole, getByLabelText
- Avoid implementation details - Don't test internal state
- Use userEvent over fireEvent - More realistic user simulation
- Test loading and error states - Cover all UI states
- Keep tests focused - One behavior per test
- Use screen queries - Cleaner than destructuring render result
Related Patterns#
- Vitest - Test runner setup
- Unit Testing - Testing utility functions
- Mocking - Mocking dependencies
- Snapshots - Snapshot testing