Testing React Components
Testing ensures your components work correctly and helps prevent regressions.
Setup
For Vite projects:
bash
1npm install -D vitest @testing-library/react @testing-library/jest-dom jsdomjavascript
1// vite.config.js
2import { defineConfig } from 'vite'
3import react from '@vitejs/plugin-react'
4
5export default defineConfig({
6 plugins: [react()],
7 test: {
8 globals: true,
9 environment: 'jsdom',
10 setupFiles: './src/test/setup.js',
11 },
12})javascript
1// src/test/setup.js
2import '@testing-library/jest-dom'Writing Your First Test
jsx
1// Button.jsx
2function Button({ onClick, children }) {
3 return (
4 <button onClick={onClick} className="btn">
5 {children}
6 </button>
7 )
8}
9
10// Button.test.jsx
11import { render, screen, fireEvent } from '@testing-library/react'
12import { describe, it, expect, vi } from 'vitest'
13import Button from './Button'
14
15describe('Button', () => {
16 it('renders children correctly', () => {
17 render(<Button>Click me</Button>)
18 expect(screen.getByText('Click me')).toBeInTheDocument()
19 })
20
21 it('calls onClick when clicked', () => {
22 const handleClick = vi.fn()
23 render(<Button onClick={handleClick}>Click me</Button>)
24
25 fireEvent.click(screen.getByText('Click me'))
26
27 expect(handleClick).toHaveBeenCalledTimes(1)
28 })
29})Queries
Finding elements in the DOM:
jsx
1import { render, screen } from '@testing-library/react'
2
3// By text content
4screen.getByText('Hello')
5screen.getByText(/hello/i) // Regex, case insensitive
6
7// By role (preferred)
8screen.getByRole('button')
9screen.getByRole('button', { name: 'Submit' })
10screen.getByRole('heading', { level: 1 })
11screen.getByRole('textbox')
12screen.getByRole('checkbox')
13
14// By label
15screen.getByLabelText('Email')
16
17// By placeholder
18screen.getByPlaceholderText('Enter email')
19
20// By test ID (last resort)
21screen.getByTestId('custom-element')
22
23// Query variants
24screen.getByRole('button') // Throws if not found
25screen.queryByRole('button') // Returns null if not found
26screen.findByRole('button') // Async, waits for element
27screen.getAllByRole('button') // Returns arrayTesting User Interactions
jsx
1import { render, screen } from '@testing-library/react'
2import userEvent from '@testing-library/user-event'
3
4it('allows user to type in input', async () => {
5 const user = userEvent.setup()
6 render(<TextInput />)
7
8 const input = screen.getByRole('textbox')
9 await user.type(input, 'Hello World')
10
11 expect(input).toHaveValue('Hello World')
12})
13
14it('submits form with correct data', async () => {
15 const user = userEvent.setup()
16 const handleSubmit = vi.fn()
17
18 render(<LoginForm onSubmit={handleSubmit} />)
19
20 await user.type(screen.getByLabelText('Email'), 'test@example.com')
21 await user.type(screen.getByLabelText('Password'), 'password123')
22 await user.click(screen.getByRole('button', { name: 'Login' }))
23
24 expect(handleSubmit).toHaveBeenCalledWith({
25 email: 'test@example.com',
26 password: 'password123'
27 })
28})Testing State Changes
jsx
1function Counter() {
2 const [count, setCount] = useState(0)
3
4 return (
5 <div>
6 <span data-testid="count">{count}</span>
7 <button onClick={() => setCount(c => c + 1)}>Increment</button>
8 <button onClick={() => setCount(c => c - 1)}>Decrement</button>
9 </div>
10 )
11}
12
13describe('Counter', () => {
14 it('increments count', async () => {
15 const user = userEvent.setup()
16 render(<Counter />)
17
18 expect(screen.getByTestId('count')).toHaveTextContent('0')
19
20 await user.click(screen.getByText('Increment'))
21
22 expect(screen.getByTestId('count')).toHaveTextContent('1')
23 })
24
25 it('decrements count', async () => {
26 const user = userEvent.setup()
27 render(<Counter />)
28
29 await user.click(screen.getByText('Decrement'))
30
31 expect(screen.getByTestId('count')).toHaveTextContent('-1')
32 })
33})Testing Async Behavior
jsx
1function AsyncComponent() {
2 const [data, setData] = useState(null)
3 const [loading, setLoading] = useState(true)
4
5 useEffect(() => {
6 fetch('/api/data')
7 .then(res => res.json())
8 .then(data => {
9 setData(data)
10 setLoading(false)
11 })
12 }, [])
13
14 if (loading) return <p>Loading...</p>
15 return <p>{data.message}</p>
16}
17
18// Test
19import { render, screen, waitFor } from '@testing-library/react'
20
21it('loads and displays data', async () => {
22 // Mock fetch
23 global.fetch = vi.fn(() =>
24 Promise.resolve({
25 json: () => Promise.resolve({ message: 'Hello World' })
26 })
27 )
28
29 render(<AsyncComponent />)
30
31 expect(screen.getByText('Loading...')).toBeInTheDocument()
32
33 await waitFor(() => {
34 expect(screen.getByText('Hello World')).toBeInTheDocument()
35 })
36})Mocking
jsx
1// Mock a module
2vi.mock('./api', () => ({
3 fetchUsers: vi.fn(() => Promise.resolve([
4 { id: 1, name: 'John' }
5 ]))
6}))
7
8// Mock a function
9const mockFn = vi.fn()
10mockFn.mockReturnValue('mocked')
11mockFn.mockResolvedValue('async mocked')
12
13// Spy on a method
14const spy = vi.spyOn(object, 'method')Testing with Context
jsx
1function renderWithProviders(ui, { theme = 'light', ...options } = {}) {
2 function Wrapper({ children }) {
3 return (
4 <ThemeProvider theme={theme}>
5 <AuthProvider>
6 {children}
7 </AuthProvider>
8 </ThemeProvider>
9 )
10 }
11
12 return render(ui, { wrapper: Wrapper, ...options })
13}
14
15it('renders with theme', () => {
16 renderWithProviders(<ThemedComponent />, { theme: 'dark' })
17 expect(screen.getByTestId('theme')).toHaveTextContent('dark')
18})Snapshot Testing
jsx
1import { render } from '@testing-library/react'
2
3it('matches snapshot', () => {
4 const { container } = render(<Button>Click me</Button>)
5 expect(container).toMatchSnapshot()
6})Running Tests
bash
1# Run tests
2npm test
3
4# Run with coverage
5npm test -- --coverage
6
7# Watch mode
8npm test -- --watch
9
10# Run specific file
11npm test Button.test.jsxTesting gives you confidence that your code works correctly!
