React tests are most useful when they describe behavior a user can observe: text appears, a button enables, a form submits, or an error message is shown.
Prefer Testing Library queries that match labels, roles, and visible text. Avoid tests that depend on component internals, hook names, or CSS selectors that can change during a harmless refactor.
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.
React Testing Library is designed around user-visible behavior. Calling setCount directly proves an implementation detail, but clicking the button proves the rendered UI is wired correctly: the button exists, the handler runs, state changes, and the updated text appears.
Data-fetching components render more than once: usually loading first, then success or error. A getBy query runs immediately and fails if the element has not appeared yet. findBy waits for the expected element within a timeout, which matches the async behavior shown in the loading/success/error examples.
A mock that always returns the perfect shape can hide the same failures users see in production: empty arrays, rejected promises, slow responses, and missing fields. The async component example should be tested through loading, success, and error states, not just the happy path.
Explore 500+ free tutorials across 20+ languages and frameworks.