React: Unit Testing (Vitest + React Testing Library)
Last updated: 2026-08-26
While refactoring an old component, Tom “accidentally” changed the internal state logic, causing UI issues on three pages that depended on that component. Because there were no unit tests, this issue wasn’t discovered until QA testing, wasting the entire team’s iteration time. Tom decided to introduce Vitest and React Testing Library to the project to use automated testing to ensure that every change wouldn’t break existing functionality.
1. What You'll Learn
- Installing and configuring Vitest (jsdom environment, setupFiles)
- Coordinated use of the three major testing APIs: render, screen, and userEvent
- Testing Component Props Validation and Event Callbacks
- Testing the loading state of asynchronous components (findBy / waitFor)
- Mock external dependencies (API requests, modules)
2. Conceptual Diagrams
The following diagram illustrates the role and relationships of unit tests in React component development:
flowchart LR
A[Creating Components] --> B[Writing Tests]
B --> C{Run Test}
C -->|Through| D[Submit Code]
C -->|Failure| E[Positioning Bug]
E --> F{Error Type}
F -->|Rendering Issues| G[getByText / getByRole]
F -->|Interaction Issues| H[userEvent.click]
F -->|Asynchronous Issues| I[findByText / waitFor]
F -->|External Dependencies| J[vi.mock / vi.fn]
G --> A
H --> A
I --> A
J --> A
style B fill:#e3f2fd,stroke:#1565c0
style D fill:#e8f5e9,stroke:#2e7d32
style E fill:#fff3e0,stroke:#e65100
3. A Real-Life Scenario
Tom's team has a UserCard component that displays user information. It accepts a user object and a onFollow callback, and changes the button style based on the isFollowing state.
During a refactoring, Tom changed the initial values of the internal state, causing isFollowing to default to true—as a result, all user cards were set to display "Following" by default. This bug wasn't discovered until the product manager's acceptance testing.
If tests had been in place at the time, such a regression issue would have been detected within seconds during the npm test phase. Tom decided to add unit tests for all core components.
(1) Environment Setup — Setting Up the Testing Infrastructure
First, install all dependencies:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
Configure Vitest (add the test field to vite.config.ts):
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
// Usage jsdom Simulate a browser environment
environment: 'jsdom',
// Global Registration describe / test / expect,No manual import required
globals: true,
// Configuration files that run before the test starts
setupFiles: './src/test/setup.ts',
},
})
Create a setup file:
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
// This line allows toBeInTheDocument()、toHaveTextContent() Assertions are available
Add a test script to package.json:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
(2) Three Core APIs
React Testing Library’s testing philosophy: Don’t test implementation details; test only what users can see and interact with.
| API | Purpose | Features |
|---|---|---|
render(component) |
Rendering Components to the Virtual DOM | Return Container References and Helper Methods |
screen |
Entry point for global element lookup | Provides three types of methods: getBy, findBy, and queryBy |
userEvent |
Simulate user actions | More realistic than fireEvent |
Core Principle: Use
getByRolefirst (semantic),getByTextsecond, andgetByTestIdlast.
▶ Example 1: Testing the rendering and interactivity of the Counter component
Output:
Test: render(<Counter />) → screen.getByText("Count: 0") → fireEvent.click(button) → "Count: 1". All assertions pass
First, let's write a simple Counter component:
// Counter.tsx
import { useState } from 'react'
interface CounterProps {
initialCount?: number
step?: number
label?: string
}
export function Counter({
initialCount = 0,
step = 1,
label = 'Count',
}: CounterProps) {
const [count, setCount] = useState(initialCount)
return (
<div>
<p>
{label}:{count}
</p>
<button onClick={() => setCount(c => c + step)}>+{step}</button>
<button onClick={() => setCount(c => c - step)} disabled={count <= 0}>
-{step}
</button>
{count >= 10 && (
<p role="alert">Maximum Value Reached Alert</p>
)}
</div>
)
}
Writing Tests:
// Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
describe('Counter Components', () => {
// Test 1:Initial Rendering
test('Display the initial counter value', () => {
render(<Counter initialCount={5} />)
// getByText — Search by text content
expect(screen.getByText('Count:5')).toBeInTheDocument()
})
// Test 2:Click the "Add" button
test('Click +1 The count increases after the button is pressed', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} step={1} />)
const incrementBtn = screen.getByRole('button', { name: '+1' })
await user.click(incrementBtn)
expect(screen.getByText('Count:1')).toBeInTheDocument()
})
// Test 3:The count is 0 "Time Decrease" Button Disabled
test('The count is 0 "Time Decrease" Button Disabled', () => {
render(<Counter initialCount={0} />)
const decrementBtn = screen.getByRole('button', { name: '-1' })
expect(decrementBtn).toBeDisabled()
})
// Test 4:Display a reminder when the threshold is reached
test('Count reached 10 Display reminders', async () => {
const user = userEvent.setup()
render(<Counter initialCount={9} step={1} />)
// No reminder at the beginning
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
// Click to add 1
await user.click(screen.getByRole('button', { name: '+1' }))
// Now there's a reminder
expect(screen.getByRole('alert')).toHaveTextContent('Maximum Value Reached Alert')
})
// Test 5:Custom label and step
test('Supports customization label and step', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} step={5} label="Number of steps" />)
expect(screen.getByText('Number of steps:0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '+5' }))
expect(screen.getByText('Number of steps:5')).toBeInTheDocument()
})
})
This test demonstrates four modes:
getByText— Find elements that contain the specified text (the simplest and most direct method)getByRole— Search by ARIA role; thenameoption provides an exact match for the button textqueryByRole— Searches for an element that may not exist, returningnullinstead of throwing an exceptiontoBeDisabled()/toHaveTextContent()— Semantic assertions provided by jest-dom
(3) Testing Props and Event Callbacks
Components typically receive data and callback functions through props. The goal of testing is to verify that the callbacks are called correctly and that the parameters are correct.
▶ Example 2: Testing the Props and Events of the TodoItem Component
Output:
Todo list: add items, toggle completion, delete items. Unit test: renders correctly, handles interactions, matches expected output
// TodoItem.tsx
interface Todo {
id: number
text: string
completed: boolean
}
interface TodoItemProps {
todo: Todo
onToggle: (id: number) => void
onDelete: (id: number) => void
}
export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 12px',
background: todo.completed ? '#f6ffed' : '#fff',
borderRadius: 6,
border: '1px solid #f0f0f0',
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
aria-label={`Mark ${todo.text}`}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#999' : '#333',
}}
>
{todo.text}
</span>
<button
onClick={() => onDelete(todo.id)}
aria-label={`Delete ${todo.text}`}
style={{
border: 'none',
background: '#ff4d4f',
color: '#fff',
borderRadius: 4,
padding: '2px 8px',
cursor: 'pointer',
fontSize: 12,
}}
>
Delete
</button>
</div>
)
}
Output:
TypeScript props: interface ButtonProps { text: string; onClick: () => void; color?: string }. Editor auto-completes, compile-time errors on misuse.
// TodoItem.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TodoItem } from './TodoItem'
describe('TodoItem Components', () => {
const mockTodo = {
id: 42,
text: 'Study React Test',
completed: false,
}
test('Format the to-do list text', () => {
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
expect(screen.getByText('Study React Test')).toBeInTheDocument()
})
test('Completed items are displayed with a strikethrough', () => {
render(
<TodoItem
todo={{ ...mockTodo, completed: true }}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
const text = screen.getByText('Study React Test')
expect(text).toHaveStyle('text-decoration: line-through')
})
test('Triggered by clicking the checkbox onToggle', async () => {
const onToggle = vi.fn()
const user = userEvent.setup()
render(
<TodoItem
todo={mockTodo}
onToggle={onToggle}
onDelete={vi.fn()}
/>
)
await user.click(screen.getByRole('checkbox'))
expect(onToggle).toHaveBeenCalledTimes(1)
expect(onToggle).toHaveBeenCalledWith(42) // The validation parameters are todo.id
})
test('Triggered by clicking the Delete button onDelete', async () => {
const onDelete = vi.fn()
const user = userEvent.setup()
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={onDelete}
/>
)
await user.click(screen.getByRole('button', { name: /Delete/ }))
expect(onDelete).toHaveBeenCalledWith(42)
})
test('Unfinished items are not struck through', () => {
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
const text = screen.getByText('Study React Test')
// Note:Inline styles text-decoration as 'none',rather than not having this property
expect(text).not.toHaveStyle('text-decoration: line-through')
})
})
Key Uses of the Mock Function vi.fn():
| API | Function |
|---|---|
vi.fn() |
Create an empty mock function |
toHaveBeenCalledTimes(n) |
Verified n times |
toHaveBeenCalledWith(...) |
Verify the parameters during the call |
vi.fn().mockResolvedValue(x) |
Mock asynchronous success response |
vi.fn().mockRejectedValue(e) |
Mocking Asynchronous Failure Responses |
(4) Testing Asynchronous Components
Many components first display "Loading..." while loading, and then display the content once the data arrives. You need to use findBy (asynchronous wait) to test these types of scenarios.
▶ Example 3: Testing the Asynchronous Data Loading Component
Output:
Displays "{user.name}". state: user, loading, error. uses useeffect
// UserProfile.tsx
interface UserProfileProps {
userId: number
}
interface UserData {
id: number
name: string
email: string
}
// Simulation API Call
async function fetchUser(id: number): Promise<UserData> {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('Failed to load')
return res.json()
}
export function UserProfile({ userId }: UserProfileProps) {
const [user, setUser] = useState<UserData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const data = await fetchUser(userId)
if (!cancelled) setUser(data)
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Unknown error')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [userId])
if (loading) return <div aria-label="Loading...">Loading......</div>
if (error) return <div role="alert">Error:{error}</div>
if (!user) return <div>No data</div>
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
Output:
Data fetching: ⏳ "Loading..." → ✅ data displayed → or ❌ "Error: Network request failed" with retry button
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react'
import { UserProfile } from './UserProfile'
// Mock out fetchUser module
vi.mock('./UserProfile', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// Rewrite fetchUser Implementation
fetchUser: vi.fn(),
}
})
// A Better Approach:Separately mock API Module
// vi.mock('../api', () => ({
// fetchUser: vi.fn()
// }))
describe('UserProfile Asynchronous Components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('Display "Loading" while loading', () => {
// let fetch All along pending
vi.spyOn(global, 'fetch').mockImplementation(
() => new Promise(() => {}) // Never resolve
)
render(<UserProfile userId={1} />)
expect(screen.getByLabelText('Loading...')).toBeInTheDocument()
})
test('Display user information after successful loading', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
// Mock fetch Return successful data
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => mockUser,
} as Response)
render(<UserProfile userId={1} />)
// findByText — Waiting Asynchronously for an Element to Appear(Default Timeout 1000ms)
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
})
test('Display an error message when loading fails', async () => {
// Mock fetch Back 500
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
} as Response)
render(<UserProfile userId={1} />)
// Wait for an error message to appear
expect(await screen.findByRole('alert')).toHaveTextContent('Error:')
})
test('The state is not updated when the component is unmounted(Preventing Memory Leaks)', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
let resolvePromise!: (value: any) => void
vi.spyOn(global, 'fetch').mockReturnValue(
new Promise((resolve) => {
resolvePromise = resolve
})
)
const { unmount } = render(<UserProfile userId={1} />)
// in fetch Uninstall Components Before Completion
unmount()
// At this moment resolve,But the component has been uninstalled,Probably not. setState
resolvePromise({
ok: true,
json: async () => mockUser,
} as Response)
// I didn't make a mistake = Test Passed
})
})
A Comparison of the Three Core Methods of Asynchronous Testing:
| Method | Synchronous/Asynchronous | When the element does not exist | Default timeout | Use case |
|---|---|---|---|---|
getByText |
Synchronous | Throw an error immediately | - | The element must exist |
queryByText |
Synchronize | Back to null |
- | Element does not exist |
findByText |
Asynchronous (Promise) | Throw an error after timeout | 1000ms | Wait for asynchronous rendering |
(5) Best Practices for Mocking External Dependencies
In real-world projects, components often depend on APIs, routing, and state management. Mocking these dependencies is key to testing.
Method 1: Mock the global fetch
// Replace the global variable before each test fetch
beforeEach(() => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ data: 'mock' }),
} as Response)
})
afterEach(() => {
vi.restoreAllMocks() // Restore to Original fetch
})
Method 2: The Mock Module
// api.ts — Real Module
export async function fetchUsers() {
const res = await fetch('/api/users')
return res.json()
}
// Testing... Mock
vi.mock('../api', () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: 'Mock User' },
])
}))
Method 3: Mock React Router
import { MemoryRouter } from 'react-router-dom'
test('Rendering in the route context', () => {
render(
<MemoryRouter initialEntries={['/users/1']}>
<UserDetailPage />
</MemoryRouter>
)
})
▶ Example 4: Testing a Custom Hook
Output:
Subheading: "{user.name}". Displays: "(null) const [loading, setLoading] = useState(true) const ". State: user (setter: setUser), loading (setter: setLoading), error (setter: setError). useEffect manages side effects. Async data fetching/loading states
import { renderHook, act } from '@testing-library/react'
import { useState, useCallback } from 'react'
function useCounter(initial = 0) {
const [count, setCount] = useState(initial)
const increment = useCallback(() => setCount(c => c + 1), [])
const decrement = useCallback(() => setCount(c => c - 1), [])
const reset = useCallback(() => setCount(initial), [initial])
return { count, increment, decrement, reset }
}
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
test('increments counter', () => {
const { result } = renderHook(() => useCounter())
act(() => result.current.increment())
expect(result.current.count).toBe(1)
})
test('decrements counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => result.current.decrement())
expect(result.current.count).toBe(4)
})
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10))
act(() => result.current.increment())
act(() => result.current.reset())
expect(result.current.count).toBe(10)
})
})
Output:
Test: render(<Button text="Click" />) → screen.getByText("Click") → expect(element).toBeInTheDocument(). Pass ✓
▶ Example 5: Integration Testing—Form Submission Process
Output:
State: count (setter: setCount). useCallback memoizes handler
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
async function handleSubmit(e) {
e.preventDefault()
if (!email.includes('@')) { setError('Invalid email'); return }
if (password.length < 6) { setError('Password too short'); return }
setError('')
await onSubmit({ email, password })
}
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" data-testid="email" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" data-testid="password" />
{error && <p data-testid="error">{error}</p>}
<button type="submit">Login</button>
</form>
)
}
describe('LoginForm integration', () => {
test('shows error for invalid email', async () => {
render(<LoginForm onSubmit={jest.fn()} />)
await userEvent.type(screen.getByTestId('email'), 'invalid')
await userEvent.type(screen.getByTestId('password'), 'password123')
await userEvent.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByTestId('error')).toHaveTextContent('Invalid email')
})
test('calls onSubmit with valid data', async () => {
const onSubmit = jest.fn().mockResolvedValue(undefined)
render(<LoginForm onSubmit={onSubmit} />)
await userEvent.type(screen.getByTestId('email'), 'alice@test.com')
await userEvent.type(screen.getByTestId('password'), 'secure123')
await userEvent.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ email: 'alice@test.com', password: 'secure123' })
})
})
})
Output:
Login form with email/password. Invalid email → red border + "The email address format is incorrect." Password <6 → "Password must be at least 6 chars". Submit → console.log({email, password})
❓ FAQ
getBy when the element is guaranteed to exist (an error is thrown if it’s not found); use queryBy when the element may not exist (returns null); use findBy when the element is rendered asynchronously (returns a Promise; an error is thrown after the timeout). Each method has corresponding Role/Text/TestId variants, such as getByRole, findByText, and queryByTestId.userEvent or fireEvent?userEvent whenever possible. fireEvent is a low-level API that directly triggers DOM events. userEvent simulates a complete sequence of user actions on top of fireEvent (for example, a "click" includes mousedown → mouseup → click), which more closely matches actual browser behavior. Only for operations not supported by userEvent should you fall back to fireEvent.vi.mock('./ExpensiveChart', () => () => <div>Mock Chart</div>).beforeEach?afterEach(() => { vi.clearAllMocks() }). Vitest automatically unloads components after each render, but you must manually clean up the mock state. If you’re using a global fetch mock, use vi.restoreAllMocks() to restore the original implementation.--coverage parameter can generate an Istanbul coverage report.📖 Summary
- Vitest + React Testing Library is the standard combination for React unit testing
- The three major APIs—render, screen, and userEvent—cover the entire process of component rendering, lookup, and interaction.
- The three query methods—getBy (synchronous, guaranteed to exist), queryBy (synchronous, may not exist), and findBy (asynchronous, pending)—each serve a specific purpose.
- vi.fn() creates a mock function to verify callback calls; vi.spyOn() mocks global functions
- When testing asynchronous components, use
findByorwaitForto wait for the UI to render asynchronously - Good tests do not focus on implementation details; they only verify user-perceivable behavior.
📝 Exercises
- Write comprehensive tests for a
Buttoncomponent: Verify that clicking it triggersonClickanddisabled, that it becomes unclickable, that the correct text is displayed, and that the custom className takes effect. Cover at least 4 test cases. - Write tests for a
UserProfilecomponent: The test should verify that the loading state displays "loading," the success state displays user information (with asynchronous handling), and the failure state displays an error message. Usevi.spyOnto mock the fetch API. - Write integration tests for a
TodoAppcomponent (including TodoList, TodoItem, and the AddTodo form): Test the three functions—adding a new to-do, marking a to-do as completed, and deleting a to-do—to verify that the list UI updates correctly.