Skip
Arish's avatar

28. Testing React Components


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 jsdom
javascript
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 array

Testing 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.jsx

Testing gives you confidence that your code works correctly!