Testing React Applications
Testing Stack
- Vitest or Jest — test runner
- React Testing Library (RTL) — test components from user's perspective
- @testing-library/user-event — realistic user interactions
- MSW (Mock Service Worker) — mock API calls
- Playwright / Cypress — end-to-end testing
React Testing Library's philosophy: "The more your tests resemble the way your software is used, the more confidence they can give you." Test behavior, not implementation details.
# Install testing dependencies
npm install -D vitest @testing-library/react @testing-library/user-event
npm install -D @testing-library/jest-dom jsdom
# vite.config.js
# test: {
# globals: true,
# environment: 'jsdom',
# setupFiles: './src/test/setup.js',
# }
# src/test/setup.js
# import '@testing-library/jest-dom'
# package.json
# "test": "vitest",
# "test:run": "vitest run",
# "test:ui": "vitest --ui"
# Run tests
npm test # watch mode
npm run test:run # single run
// Counter.test.jsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Counter from '../Counter'
describe('Counter', () => {
it('renders initial count of 0', () => {
render(<Counter />)
// getByText — throws if not found
expect(screen.getByText('Count: 0')).toBeInTheDocument()
})
it('increments count when + button is clicked', async () => {
const user = userEvent.setup()
render(<Counter />)
// userEvent simulates real user interactions
await user.click(screen.getByRole('button', { name: '+' }))
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
it('decrements count when - button is clicked', async () => {
const user = userEvent.setup()
render(<Counter initialCount={5} />)
await user.click(screen.getByRole('button', { name: '-' }))
expect(screen.getByText('Count: 4')).toBeInTheDocument()
})
it('resets to 0 when reset button is clicked', async () => {
const user = userEvent.setup()
render(<Counter initialCount={10} />)
await user.click(screen.getByRole('button', { name: /reset/i }))
expect(screen.getByText('Count: 0')).toBeInTheDocument()
})
it('calls onCountChange when count changes', async () => {
const user = userEvent.setup()
const onCountChange = vi.fn()
render(<Counter onCountChange={onCountChange} />)
await user.click(screen.getByRole('button', { name: '+' }))
expect(onCountChange).toHaveBeenCalledWith(1)
expect(onCountChange).toHaveBeenCalledTimes(1)
})
it('does not go below 0', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '-' }))
expect(screen.getByText('Count: 0')).toBeInTheDocument()
})
})
// UserList.test.jsx — testing async component
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UserList from '../UserList'
const mockUsers = [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
]
describe('UserList', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('shows loading state initially', () => {
global.fetch = vi.fn(() => new Promise(() => {}))
render(<UserList />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
it('renders users after successful fetch', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) })
)
render(<UserList />)
// Wait for async content
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
expect(screen.getByText('Bob')).toBeInTheDocument()
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
it('shows error message on fetch failure', async () => {
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')))
render(<UserList />)
await waitFor(() => {
expect(screen.getByText(/network error/i)).toBeInTheDocument()
})
})
it('filters users by search input', async () => {
const user = userEvent.setup()
global.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) })
)
render(<UserList />)
await waitFor(() => screen.getByText('Alice'))
// Type in search box
await user.type(screen.getByPlaceholderText(/search/i), 'Alice')
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.queryByText('Bob')).not.toBeInTheDocument()
})
it('deletes user when delete button is clicked', async () => {
const user = userEvent.setup()
global.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) })
)
render(<UserList />)
await waitFor(() => screen.getByText('Alice'))
// Find delete button for Alice
const deleteButtons = screen.getAllByRole('button', { name: /delete/i })
await user.click(deleteButtons[0])
expect(screen.queryByText('Alice')).not.toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
})
})
Ready to Level Up Your Skills?
Explore 500+ free tutorials across 20+ languages and frameworks.