To test React components with Vitest, you need Testing Library, a jsdom environment, one setup file that loads the DOM matchers and calls cleanup after each test, and user interactions driven through @testing-library/user-event. I built exactly that from an empty folder for this guide, on Node 22.23.2, and ran every snippet below against the versions in the table. Two of my first four tests failed. Neither failure was in the component.
Quick take: Install
vitest,jsdom,@testing-library/react,@testing-library/user-eventand@testing-library/jest-dom. Setenvironment: 'jsdom'and a setup file. In that file, callcleanup()inafterEach, because Testing Library only does it for you when a globalafterEachexists and Vitest doesn't provide globals by default. For debounced inputs, usevi.useFakeTimers({ shouldAdvanceTime: true }), oruser.type()hangs until the test times out.
Which Packages Do You Actually Need?
Here's what ran, checked against the npm registry on September 29, 2026:
| Package | Version | Why it's there |
|---|---|---|
vitest | 5.0.2 | test runner (5.0.0 shipped September 3) |
react, react-dom | 19.3.0 | the thing under test |
@vitejs/plugin-react | 6.1.1 | JSX transform for test files |
jsdom | 30.1.1 | simulated document and window |
@testing-library/react | 16.3.3 | render, screen, cleanup |
@testing-library/dom | 10.4.2 | peer dependency of the above since v16 |
@testing-library/user-event | 14.6.7 | realistic typing and clicking |
@testing-library/jest-dom | 7.0.1 | toBeInTheDocument() and friends |
typescript | 7.0.2 | type-checking the tests |
npm install -D vitest @vitejs/plugin-react jsdom \
@testing-library/react @testing-library/dom \
@testing-library/user-event @testing-library/jest-dom
One version floor catches people. Vitest 5 accepts Node 22.12 and up, but jsdom 30 declares ^22.22.2 || ^24.15.0 || >=26.0.0 in its engines field. A CI image pinned to an early Node 22 minor passes the Vitest check and then fails on the environment package. If you're still on Node 20, the Vitest 5 migration guide covers that upgrade first.
The Config Is Three Small Files
// 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'],
},
})
// src/test-setup.ts
import '@testing-library/jest-dom/vitest'
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
afterEach(() => {
cleanup()
})
The /vitest entry point registers the jest-dom matchers with Vitest's expect and carries the type augmentation with it. Because the setup file is inside include, the tsconfig.json needs "jsx": "react-jsx" and nothing test-specific: tsc --noEmit on TypeScript 7.0.2 passed with "types": []. Want a baseline for the rest of that file? The tsconfig generator produces one that matches your runtime.
Why Did the Second Test See the First Test's DOM?
My first draft of test-setup.ts had only the jest-dom import. The first test passed. The next two failed, and the failure output showed why: the <body> held two copies of the component, the first one still showing "Ada Lovelace" from the previous test, and getByLabelText had grabbed its input.
The React Testing Library docs spell out the condition: cleanup runs automatically only if your framework injects a global afterEach(). Vitest's globals option defaults to false, and the Vitest docs name @testing-library/react as a library that relies on globals for auto cleanup. So nothing unmounts anything. Setting globals: true also fixes it; I checked, 4 of 4 passed. I'd still write the afterEach line instead. One explicit line beats a codebase where describe and expect appear from nowhere.
What Should a Component Test Assert?
Behavior a user could notice, found the way a user finds it. Here's a debounced search box with loading, empty and error states:
// src/UserSearch.tsx
import { useEffect, useState } from 'react'
export type User = { id: number; name: string }
type Props = {
fetchUsers: (query: string) => Promise<User[]>
debounceMs?: number
}
export function UserSearch({ fetchUsers, debounceMs = 300 }: Props) {
const [query, setQuery] = useState('')
const [users, setUsers] = useState<User[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (query.trim() === '') {
setUsers(null)
return
}
let cancelled = false
const timer = setTimeout(() => {
fetchUsers(query)
.then((result) => {
if (!cancelled) {
setUsers(result)
setError(null)
}
})
.catch(() => {
if (!cancelled) {
setError('Search failed. Try again.')
}
})
}, debounceMs)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [query, fetchUsers, debounceMs])
return (
<div>
<label htmlFor="user-search">Search users</label>
<input id="user-search" value={query} onChange={(e) => setQuery(e.target.value)} />
{error && <p role="alert">{error}</p>}
{users && users.length === 0 && <p>No users found</p>}
{users && users.length > 0 && (
<ul aria-label="Results">
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
)}
</div>
)
}
// src/UserSearch.test.tsx
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserSearch } from './UserSearch'
describe('UserSearch', () => {
it('shows results for a query', async () => {
const fetchUsers = vi.fn().mockResolvedValue([
{ id: 1, name: 'Ada Lovelace' },
{ id: 2, name: 'Alan Turing' },
])
const user = userEvent.setup()
render(<UserSearch fetchUsers={fetchUsers} debounceMs={0} />)
await user.type(screen.getByLabelText('Search users'), 'a')
const list = await screen.findByRole('list', { name: 'Results' })
expect(list).toHaveTextContent('Ada Lovelace')
expect(fetchUsers).toHaveBeenCalledWith('a')
})
it('renders the empty state', async () => {
const fetchUsers = vi.fn().mockResolvedValue([])
const user = userEvent.setup()
render(<UserSearch fetchUsers={fetchUsers} debounceMs={0} />)
await user.type(screen.getByLabelText('Search users'), 'zz')
expect(await screen.findByText('No users found')).toBeInTheDocument()
})
it('announces a failed request', async () => {
const fetchUsers = vi.fn().mockRejectedValue(new Error('500'))
const user = userEvent.setup()
render(<UserSearch fetchUsers={fetchUsers} debounceMs={0} />)
await user.type(screen.getByLabelText('Search users'), 'a')
expect(await screen.findByRole('alert')).toHaveTextContent('Search failed')
})
})
Three habits carry most of the weight. Query by role and label, never by class name, so a test that can't find the input is also telling you a screen reader can't. Use findBy for anything that appears after a promise; it retries until the element shows up. And pass the data dependency in as a prop, which lets vi.fn() stand in without module mocking. Notice what's absent: snapshots.
A component snapshot proves the markup didn't change, which is precisely the thing you meant to change.
The empty state and the error path are the tests people skip, and they're the ones that catch regressions. The testing playbook for AI-generated components goes further on that gap.
Why Did Fake Timers Hang user-event?
To prove the debounce works, you want one request for a whole word, not five. The obvious test calls vi.useFakeTimers() and passes vi.advanceTimersByTime to userEvent.setup(), exactly as the user-event docs describe. It hung for 5,000 ms and timed out. So did delay: null, which the user-event docs advise against anyway.
The cause sits in @testing-library/react's async wrapper. After each interaction it waits on a real setTimeout(resolve, 0), and it only advances fake time itself when jestFakeTimersAreEnabled() returns true. That helper starts with typeof jest !== 'undefined'. Under Vitest there's no jest global, so the promise waits on a timer that nothing will ever advance. What worked:
// src/UserSearch.debounce.test.tsx
import { afterEach, expect, it, vi } from 'vitest'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserSearch } from './UserSearch'
afterEach(() => {
vi.useRealTimers()
})
it('sends one request for a whole word', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const fetchUsers = vi.fn().mockResolvedValue([])
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<UserSearch fetchUsers={fetchUsers} debounceMs={300} />)
await user.type(screen.getByLabelText('Search users'), 'grace')
await act(() => vi.advanceTimersByTimeAsync(300))
expect(fetchUsers).toHaveBeenCalledTimes(1)
expect(fetchUsers).toHaveBeenCalledWith('grace')
})
shouldAdvanceTime: true lets fake time drift forward with real time, so the wrapper's zero-delay timer eventually fires. The trade-off is honest to name: the clock isn't frozen any more. Typing five characters takes a few milliseconds, far under the 300 ms window, so the assertion holds, but a debounce of 5 ms would be flaky under this approach. Keep useRealTimers() in afterEach, not at the bottom of the test, or one failing assertion leaks fake time into every test after it.
With both fixes the suite ran 4 of 4 green, in 0.74 to 1.28 seconds across three runs, and 61 to 71 percent of each run was environment setup rather than tests. jsdom is the cost; the assertions are nearly free.
Where Does jsdom Stop Being Enough?
jsdom doesn't do layout. Anything that depends on measured size, IntersectionObserver, real focus order across iframes, or CSS actually applying belongs in a real browser. Vitest 5's Browser Mode runs the same render and screen code in Chromium, and its new Trace View is the reason to try it; the Vitest 5 features rundown covers that. Coming from Jest? The component tests above port almost unchanged. The differences are the two traps in this article, plus the default-behavior changes from the migration guide linked at the top, clearMocks above all.