Skip to content

How to Test React Components with Vitest 5 and Testing Library

A React 19 test setup on Vitest 5.0.2 and Testing Library 16, run from an empty folder, plus the two traps that broke it: cleanup and fake timers.

· · 8 min read
A wooden rack of glass test tubes filled with brightly colored liquids

Quick Take

Seven dev packages, three config files, and two failures you won't find in the setup guides. Both come from Testing Library quietly assuming it runs under Jest, and both have a one-line fix.

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-event and @testing-library/jest-dom. Set environment: 'jsdom' and a setup file. In that file, call cleanup() in afterEach, because Testing Library only does it for you when a global afterEach exists and Vitest doesn't provide globals by default. For debounced inputs, use vi.useFakeTimers({ shouldAdvanceTime: true }), or user.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:

PackageVersionWhy it's there
vitest5.0.2test runner (5.0.0 shipped September 3)
react, react-dom19.3.0the thing under test
@vitejs/plugin-react6.1.1JSX transform for test files
jsdom30.1.1simulated document and window
@testing-library/react16.3.3render, screen, cleanup
@testing-library/dom10.4.2peer dependency of the above since v16
@testing-library/user-event14.6.7realistic typing and clicking
@testing-library/jest-dom7.0.1toBeInTheDocument() and friends
typescript7.0.2type-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.

A man sweeping the floor of a large empty room with a long broom
Photo by Chris Diamond on Unsplash

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.

Share this Post on X Bluesky

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.

A glass hourglass with black sand running into the lower chamber on white
Photo by Wilhelm Gunkel on Unsplash

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.

Frequently Asked Questions

Do I need globals: true in Vitest to use React Testing Library?
No. Testing Library only uses globals to register its automatic cleanup, because it looks for a global afterEach. With Vitest's default globals: false, add afterEach(() => { cleanup() }) to your setup file and everything else works with explicit imports from vitest. Without either one, every render stays in the document and later tests query the wrong elements.
Why does my test time out when I use vi.useFakeTimers() with user-event?
Testing Library's async wrapper finishes each interaction by waiting on a real setTimeout(0), and it only advances fake time itself when it detects Jest's fake timers through a global named jest. Under Vitest that global doesn't exist, so the promise waits on a timer nobody advances. Use vi.useFakeTimers({ shouldAdvanceTime: true }) and pass vi.advanceTimersByTime to userEvent.setup({ advanceTimers }).
Should I use jsdom or happy-dom with Vitest for React tests?
Both are supported by Vitest as test environments. This setup uses jsdom 30.1.1, which declares Node ^22.22.2, ^24.15.0 or >=26 in its engines field, a higher floor than Vitest 5 itself (22.12). If your CI image sits on an older Node 22 minor, check that before blaming the test runner for install errors.