React Testing with Vitest and React Testing Library — Complete Guide
Advertisement
Introduction
Why This Matters
Tests prevent regressions and give you confidence to refactor. The testing pyramid for React: unit tests for pure functions, component tests with React Testing Library for user-facing behavior, and E2E tests (Playwright or Cypress) for critical user flows. React Testing Library enforces testing behavior, not implementation — making tests resilient to refactoring.
Setup
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true
}
})// src/test/setup.ts
import '@testing-library/jest-dom'Your First Component Test
// components/badge.tsx
type BadgeProps = { count: number; max?: number }
export function Badge({ count, max = 99 }: BadgeProps) {
const display = count > max ? `${max}+` : count
return (
<span data-testid="badge" className="bg-red-500 text-white text-xs rounded-full px-2 py-0.5">
{display}
</span>
)
}// components/badge.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Badge } from './badge'
describe('Badge', () => {
it('displays the count when below max', () => {
render(<Badge count={5} />)
expect(screen.getByTestId('badge')).toHaveTextContent('5')
})
it('displays max+ when count exceeds max', () => {
render(<Badge count={150} max={99} />)
expect(screen.getByTestId('badge')).toHaveTextContent('99+')
})
it('uses 99 as default max', () => {
render(<Badge count={100} />)
expect(screen.getByTestId('badge')).toHaveTextContent('99+')
})
})Testing User Interactions
Always use @testing-library/user-event for user interactions instead of fireEvent — it simulates real browser behavior including focus, keyboard events, and pointer interactions.
// components/counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect } from 'vitest'
import { Counter } from './counter'
describe('Counter', () => {
it('increments count on button click', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} />)
const incrementBtn = screen.getByRole('button', { name: /increment/i })
await user.click(incrementBtn)
await user.click(incrementBtn)
expect(screen.getByText('2')).toBeInTheDocument()
})
it('cannot go below zero when min is 0', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} min={0} />)
const decrementBtn = screen.getByRole('button', { name: /decrement/i })
await user.click(decrementBtn)
expect(screen.getByText('0')).toBeInTheDocument()
})
})Testing Forms with Mocked Actions
// components/login-form.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { LoginForm } from './login-form'
describe('LoginForm', () => {
it('calls onSubmit with email and password', async () => {
const user = userEvent.setup()
const mockSubmit = vi.fn().mockResolvedValue({ success: true })
render(<LoginForm onSubmit={mockSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(mockSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
})
})
it('shows error message on failed login', async () => {
const user = userEvent.setup()
const mockSubmit = vi.fn().mockResolvedValue({ error: 'Invalid credentials' })
render(<LoginForm onSubmit={mockSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'bad@example.com')
await user.type(screen.getByLabelText(/password/i), 'wrongpass')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument()
})
})Mocking fetch and API Calls
// components/user-profile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserProfile } from './user-profile'
describe('UserProfile', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('renders user data after loading', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ id: '1', name: 'Alice Johnson', email: 'alice@example.com' })
} as Response)
render(<UserProfile userId="1" />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Alice Johnson')).toBeInTheDocument()
})
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
})
it('shows error state when fetch fails', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
})
})
})Common Mistakes
- Testing implementation details (internal state, method calls) instead of user-visible behavior
- Using
getByTestIdeverywhere — prefergetByRole,getByLabelText, andgetByTextwhich reflect accessibility - Not awaiting async assertions — use
findBy*queries orwaitForfor async state updates - Testing the entire component tree when a unit test of a small component is sufficient
Best Practices
- Write tests that resemble how users actually use your app — click buttons, type in inputs, read text
- Use
getByRolewith semantic roles for accessible test selectors:getByRole('button', { name: /submit/i }) - Co-locate test files next to their source files:
button.tsxandbutton.test.tsxin the same folder - Use MSW (Mock Service Worker) for integration tests that cover multiple components communicating via API
Key Takeaways
- React Testing Library enforces testing behavior, not implementation — tests survive refactoring
@testing-library/user-eventsimulates real browser interactions more accurately thanfireEventgetByRoleis the preferred query because it tests accessibility alongside functionality- Use
findBy*for elements that appear asynchronously — it retries until the element appears or times out vi.spyOn(global, 'fetch')intercepts fetch calls in Vitest; restore withvi.restoreAllMocks()inbeforeEach- Aim for high coverage of critical user flows, not 100% line coverage of every component
- Co-locate test files with source files to make them easy to find and maintain
- MSW is the industry standard for mocking API responses in integration and E2E tests
Advertisement
Related reading
Testing Guide 2026 — Vitest, Playwright, and React Testing Library5 min readVitest Unit Testing with TypeScript — Complete Guide 20265 min readJest vs Vitest in 2026 — Which Testing Framework Should You Use?5 min readNode.js Testing in 2026 — Vitest, TestContainers, and Testing Without Mocking Everything9 min readReact Hooks - The Complete Guide with Real-World Examples6 min read