Vitest with React: Unit Testing Guide with Examples (2026)
Vitest is a Vite-native test runner that replaces Jest for React projects. Set up with npm install -D vitest @testing-library/react @testing-library/user-event jsdom. Configure vitest.config.ts with environment: 'jsdom' and globals: true. Then write tests with render(), screen, and userEvent from React Testing Library — the API is nearly identical to Jest+RTL.
Key Takeaways
Vitest is a drop-in Jest replacement with much faster startup. If you're using Jest with React Testing Library, migrating to Vitest requires minimal changes — same test syntax, same assertions, same RTL API.
Set environment: 'jsdom' in vitest.config.ts. React components need a browser-like DOM environment. Without this, document and window are undefined.
Use @testing-library/react with Vitest. React Testing Library works identically whether your test runner is Jest or Vitest. render(), screen, fireEvent, waitFor all work the same.
@testing-library/user-event is more realistic than fireEvent. userEvent.click() simulates real browser events (mousedown, mouseup, click). Use userEvent.setup() for async interactions.
Vitest's vi object replaces Jest's jest object. vi.fn(), vi.spyOn(), vi.mock(), vi.useFakeTimers() — same patterns, different namespace.
Installation
# With Vite project (recommended)
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
<span class="hljs-comment"># Or with Yarn
yarn add -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
Configuration
vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // Browser-like DOM
globals: true, // No need to import describe/it/expect
setupFiles: './src/test-setup.ts', // Global test setup
css: true, // Parse CSS (for CSS Modules)
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
},
},
})
Or extend vite.config.ts
If you have an existing vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test-setup.ts',
},
})
test-setup.ts
// src/test-setup.ts
import '@testing-library/jest-dom'
// Adds custom matchers: toBeInTheDocument, toHaveTextContent, etc.
package.json scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}
vitest— watch mode (re-runs on file change)vitest run— single run (for CI)vitest --ui— opens browser-based test UI
Writing Your First React Component Test
Simple Component
// src/components/Button.tsx
interface ButtonProps {
label: string
onClick: () => void
disabled?: boolean
}
export function Button({ label, onClick, disabled = false }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled} className="btn">
{label}
</button>
)
}
// src/components/Button.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
describe('Button', () => {
it('renders with label', () => {
render(<Button label="Save" onClick={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it('calls onClick when clicked', async () => {
const handleClick = vi.fn()
render(<Button label="Save" onClick={handleClick} />)
await userEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledOnce()
})
it('does not call onClick when disabled', async () => {
const handleClick = vi.fn()
render(<Button label="Save" onClick={handleClick} disabled />)
await userEvent.click(screen.getByRole('button'))
expect(handleClick).not.toHaveBeenCalled()
})
})
React Testing Library API
render() and screen
import { render, screen } from '@testing-library/react'
// render() mounts the component
const { container, rerender, unmount } = render(<MyComponent prop="value" />)
// screen exposes query methods
screen.getByText('Hello World') // Throws if not found
screen.queryByText('Hello World') // Returns null if not found
screen.findByText('Hello World') // Returns Promise (waits for element)
screen.getAllByRole('button') // Returns array, throws if empty
screen.queryAllByText('item') // Returns array, empty if none
screen.findAllByRole('listitem') // Returns Promise array
Query Priority (RTL Recommendation)
Use queries in this order (most to least accessible):
// 1. Role (best — matches what users/screen readers see)
screen.getByRole('button', { name: 'Submit' })
screen.getByRole('textbox', { name: 'Email' })
screen.getByRole('checkbox', { name: 'Remember me' })
// 2. Label text
screen.getByLabelText('Email address')
// 3. Placeholder
screen.getByPlaceholderText('Enter email')
// 4. Text content
screen.getByText('Welcome back')
// 5. Display value (for select/input)
screen.getByDisplayValue('Option 1')
// 6. Alt text (images)
screen.getByAltText('User profile photo')
// 7. Title
screen.getByTitle('Close dialog')
// 8. Test ID (last resort)
screen.getByTestId('submit-button')
userEvent vs fireEvent
import userEvent from '@testing-library/user-event'
import { fireEvent } from '@testing-library/react'
// ✅ userEvent — simulates real browser interaction
const user = userEvent.setup()
await user.click(button)
await user.type(input, 'hello@example.com')
await user.keyboard('{Enter}')
await user.selectOptions(select, 'Option 2')
await user.clear(input)
await user.tab() // Tab to next element
// ⚠️ fireEvent — fires DOM events directly (less realistic)
fireEvent.click(button)
fireEvent.change(input, { target: { value: 'text' } })
Use userEvent for realistic interaction. Use fireEvent only when userEvent isn't available for a specific event type.
Form Testing
// src/components/LoginForm.tsx
export function LoginForm({ onSubmit }: { onSubmit: (data: { email: string; password: string }) => void }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!email || !password) {
setError('All fields are required')
return
}
onSubmit({ email, password })
}
return (
<form onSubmit={handleSubmit}>
<label>
Email
<input type="email" value={email} onChange={e => setEmail(e.target.value)} />
</label>
<label>
Password
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
</label>
{error && <div role="alert">{error}</div>}
<button type="submit">Sign In</button>
</form>
)
}
// src/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
describe('LoginForm', () => {
it('submits with valid credentials', async () => {
const handleSubmit = vi.fn()
render(<LoginForm onSubmit={handleSubmit} />)
await userEvent.type(screen.getByLabelText('Email'), 'user@example.com')
await userEvent.type(screen.getByLabelText('Password'), 'password123')
await userEvent.click(screen.getByRole('button', { name: 'Sign In' }))
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
})
})
it('shows error when fields are empty', async () => {
render(<LoginForm onSubmit={vi.fn()} />)
await userEvent.click(screen.getByRole('button', { name: 'Sign In' }))
expect(screen.getByRole('alert')).toHaveTextContent('All fields are required')
})
it('does not submit when fields are empty', async () => {
const handleSubmit = vi.fn()
render(<LoginForm onSubmit={handleSubmit} />)
await userEvent.click(screen.getByRole('button', { name: 'Sign In' }))
expect(handleSubmit).not.toHaveBeenCalled()
})
})
Async Components and API Calls
// src/components/UserProfile.tsx
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data)
setLoading(false)
})
}, [userId])
if (loading) return <div>Loading...</div>
if (!user) return <div>User not found</div>
return <div><h1>{user.name}</h1><p>{user.email}</p></div>
}
// src/components/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './UserProfile'
import { fetchUser } from '../api/users'
vi.mock('../api/users')
const mockFetchUser = vi.mocked(fetchUser)
describe('UserProfile', () => {
it('shows loading state initially', () => {
mockFetchUser.mockReturnValue(new Promise(() => {})) // Never resolves
render(<UserProfile userId="123" />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('displays user data after fetch', async () => {
mockFetchUser.mockResolvedValue({ id: '123', name: 'Jane Doe', email: 'jane@example.com' })
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument()
})
expect(screen.getByText('jane@example.com')).toBeInTheDocument()
})
it('shows not found when fetch fails', async () => {
mockFetchUser.mockRejectedValue(new Error('Not found'))
render(<UserProfile userId="999" />)
await waitFor(() => {
expect(screen.getByText('User not found')).toBeInTheDocument()
})
})
})
Mocking in Vitest
Mock Functions
const fn = vi.fn()
fn('arg1', 'arg2')
expect(fn).toHaveBeenCalledOnce()
expect(fn).toHaveBeenCalledWith('arg1', 'arg2')
expect(fn).toHaveReturnedWith(undefined)
// With return value
const add = vi.fn().mockReturnValue(42)
const asyncFn = vi.fn().mockResolvedValue({ data: 'result' })
const errorFn = vi.fn().mockRejectedValue(new Error('Failed'))
// Different return on each call
vi.fn()
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default')
Module Mocking
// Mock entire module
vi.mock('../api/users', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
updateUser: vi.fn().mockResolvedValue({ success: true }),
}))
// Auto-mock (vitest creates automatic mocks)
vi.mock('../api/users')
const { fetchUser } = await import('../api/users')
vi.mocked(fetchUser).mockResolvedValue({ id: '1', name: 'Test User' })
// Partial mock
vi.mock('../utils/format', async (importOriginal) => {
const original = await importOriginal<typeof import('../utils/format')>()
return {
...original,
formatDate: vi.fn().mockReturnValue('Jan 1, 2026'),
}
})
Spy on Methods
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// Test code that calls console.error
renderWithError()
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Error:'))
consoleSpy.mockRestore()
Fake Timers
it('shows timeout message after 5 seconds', async () => {
vi.useFakeTimers()
render(<PaymentProcessor />)
expect(screen.queryByText('Taking longer than expected')).not.toBeInTheDocument()
vi.advanceTimersByTime(5000)
await waitFor(() => {
expect(screen.getByText('Taking longer than expected')).toBeInTheDocument()
})
vi.useRealTimers()
})
Testing Custom Hooks
// src/hooks/useCounter.ts
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial)
return {
count,
increment: () => setCount(c => c + 1),
decrement: () => setCount(c => c - 1),
reset: () => setCount(initial),
}
}
// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('initializes with 0 by default', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increments count', () => {
const { result } = renderHook(() => useCounter())
act(() => result.current.increment())
expect(result.current.count).toBe(1)
})
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5))
act(() => result.current.decrement())
expect(result.current.count).toBe(4)
})
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(5))
act(() => result.current.increment())
act(() => result.current.reset())
expect(result.current.count).toBe(5)
})
})
Rendering with Providers
When components need React Router, Redux, or other context providers:
// src/test-utils.tsx
import { render as rtlRender, RenderOptions } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import { createStore } from './store'
function AllProviders({ children }: { children: React.ReactNode }) {
return (
<Provider store={createStore()}>
<MemoryRouter>
{children}
</MemoryRouter>
</Provider>
)
}
export function render(ui: React.ReactElement, options?: RenderOptions) {
return rtlRender(ui, { wrapper: AllProviders, ...options })
}
export * from '@testing-library/react'
// Use custom render in tests
import { render, screen } from '../test-utils' // Not from @testing-library/react
it('shows dashboard for logged-in user', async () => {
render(<Dashboard />)
expect(screen.getByText('Welcome')).toBeInTheDocument()
})
Snapshot Testing
import { render } from '@testing-library/react'
import { Button } from './Button'
it('matches snapshot', () => {
const { container } = render(<Button label="Submit" onClick={vi.fn()} />)
expect(container.firstChild).toMatchSnapshot()
})
Update snapshots when component changes intentionally:
vitest run --update-snapshots
CI Configuration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run test:run
- run: npm run test:coverage
Vitest vs Jest for React
| Feature | Vitest | Jest |
|---|---|---|
| Startup time | ~100ms | ~3s |
| Config file | vitest.config.ts (Vite-based) |
jest.config.ts |
| Watch mode | ✅ Fast HMR-based | ✅ Slower polling |
| ESM support | ✅ Native | ⚠️ Requires transform |
| TypeScript | ✅ No extra config | ⚠️ Requires ts-jest or babel |
| Coverage | @vitest/coverage-v8 |
jest-coverage |
| Mocking API | vi.fn(), vi.mock() |
jest.fn(), jest.mock() |
| Browser testing | ✅ Vitest Browser Mode | ❌ Separate tool needed |
| Migration effort | Low (same RTL API) | — |
Migration from Jest
Most Jest tests work in Vitest with these changes:
// Replace jest.* with vi.*
jest.fn() → vi.fn()
jest.spyOn() → vi.spyOn()
jest.mock() → vi.mock()
jest.clearAllMocks → vi.clearAllMocks
jest.useFakeTimers → vi.useFakeTimers
// Optional: enable globals so you don't need to import these
// jest: available globally by default
// vitest: set globals: true in config, then they work the same
What Vitest Doesn't Cover: E2E Testing
Vitest + React Testing Library tests components in isolation. They don't cover:
- Real browser rendering (CSS, layout)
- User flows across multiple pages
- Third-party integrations (payment forms, OAuth)
- Network conditions and loading states
- Mobile touch interactions
For end-to-end coverage, combine Vitest with HelpMeTest:
# Vitest handles: unit tests, component tests
# HelpMeTest handles: full user journeys in real browsers
Test: User can complete checkout
Go to https://myapp.com/products
Add "Blue Widget" to cart
Click Checkout
Fill in shipping address
Complete payment
Verify order confirmation shows order number
HelpMeTest runs in real Chrome — no jsdom, no mocking, no Selenium setup.
Summary
Vitest + React Testing Library setup:
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
// vitest.config.ts
export default defineConfig({
test: { environment: 'jsdom', globals: true, setupFiles: './src/test-setup.ts' }
})
// src/test-setup.ts
import '@testing-library/jest-dom'
// Component test
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
it('submits form', async () => {
render(<MyForm onSubmit={vi.fn()} />)
await userEvent.type(screen.getByLabelText('Email'), 'test@example.com')
await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com' })
})