React applications are built from many small units: components, hooks, forms, API interactions, and user flows. Testing gives confidence that these pieces keep working as the codebase changes. A good React test suite helps you catch regressions early, refactor safely, and describe how the UI should behave from the user's point of view.
The most practical mindset is to test behavior, not internal implementation. Users do not care whether a component uses useState, useReducer, or a custom hook internally. They care whether clicking the button updates the screen, whether the form validates correctly, and whether loading and error states are shown when expected.
| Layer | Purpose | Typical tools |
|---|---|---|
| Unit tests | Test small isolated logic such as helpers or hooks | Vitest, Jest |
| Component tests | Test UI behavior for one component | React Testing Library |
| Integration tests | Test multiple pieces working together | RTL + mock API or MSW |
| End-to-end tests | Test full user flows in the browser | Playwright, Cypress |
React Testing Library follows a simple philosophy: the more your tests resemble the way your software is used, the more confidence they can give you. That is why its queries focus on visible text, labels, roles, and interactions instead of private component internals.
npm install -D vitest jsdom
npm install -D @testing-library/react @testing-library/user-event
npm install -D @testing-library/jest-dom
import { defineConfig } from 'vite'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.js',
},
})
import '@testing-library/jest-dom'
import { useState } from 'react'
function Counter({ initialCount = 0, onCountChange }) {
const [count, setCount] = useState(initialCount)
function updateCount(nextCount) {
const safeCount = Math.max(0, nextCount)
setCount(safeCount)
onCountChange?.(safeCount)
}
return (
<div>
<p>Count: {count}</p>
<button onClick={() => updateCount(count - 1)}>-</button>
<button onClick={() => updateCount(count + 1)}>+</button>
<button onClick={() => updateCount(0)}>Reset</button>
</div>
)
}
export default Counter
import { describe, it, expect, vi } 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', () => {
render(<Counter initialCount={5} />)
expect(screen.getByText('Count: 5')).toBeInTheDocument()
})
it('increments when plus button is clicked', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '+' }))
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
it('does not go below zero', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: '-' }))
expect(screen.getByText('Count: 0')).toBeInTheDocument()
})
it('calls onCountChange with latest value', async () => {
const user = userEvent.setup()
const onCountChange = vi.fn()
render(<Counter onCountChange={onCountChange} />)
await user.click(screen.getByRole('button', { name: '+' }))
expect(onCountChange).toHaveBeenCalledWith(1)
})
})
import { useEffect, useState } from 'react'
function UserList() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
fetch('/api/users')
.then(response => response.json())
.then(data => {
setUsers(data)
setLoading(false)
})
.catch(() => {
setError('Failed to load users')
setLoading(false)
})
}, [])
if (loading) return <p>Loading users...</p>
if (error) return <p>{error}</p>
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
export default UserList
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import UserList from '../UserList'
describe('UserList', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('shows loading state first', () => {
global.fetch = vi.fn(() => new Promise(() => {}))
render(<UserList />)
expect(screen.getByText(/loading users/i)).toBeInTheDocument()
})
it('renders users after successful fetch', async () => {
global.fetch = vi.fn(() => Promise.resolve({
json: () => Promise.resolve([
{ id: 1, name: 'Aman' },
{ id: 2, name: 'Riya' },
]),
}))
render(<UserList />)
await waitFor(() => {
expect(screen.getByText('Aman')).toBeInTheDocument()
})
expect(screen.getByText('Riya')).toBeInTheDocument()
})
it('shows an error message when fetch fails', async () => {
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')))
render(<UserList />)
await waitFor(() => {
expect(screen.getByText(/failed to load users/i)).toBeInTheDocument()
})
})
})
| Mistake | Why it is weak | Better approach |
|---|---|---|
| Testing component internals | Tests break during refactors even if behavior is unchanged | Test what the user sees and does |
| Using low-level events for everything | Less realistic than actual user actions | Prefer userEvent |
| Skipping async assertions | Can miss real behavior timing | Use waitFor or findBy queries |
| Mocking too much | Tests become unrealistic and brittle | Mock only external boundaries |
| Ignoring accessibility queries | Misses user-facing semantics | Use roles, labels, and text when possible |
Testing React applications is mainly about gaining confidence in user-facing behavior. A strong test suite checks that components render the right content, respond correctly to interaction, and handle loading and error states predictably.
The most reliable tests usually behave like users: they render a component, find elements through accessible queries, trigger realistic interactions, and verify what appears on the screen. That gives you tests that are both useful and resilient during refactoring.
Explore 500+ free tutorials across 20+ languages and frameworks.