Vitest and React Testing Library: Setup and Patterns
For Vite-based React applications, Vitest has become the default test runner not because Jest
disappeared overnight, but because the developer experience lines up with how modern frontends are
already built. Vitest reuses Vite’s transform pipeline, which means TypeScript, JSX, aliases, and
the same plugins you rely on in dev apply to tests without a parallel configuration universe.
Native ESM support removes a class of “works in Jest with a hack” problems. The API is
deliberately Jest-compatible—describe, it, expect, vi.fn, mocks, timers—so migration is
usually incremental rather than a rewrite.
That speed and compatibility only pay off if the assertions you write are aimed at the right boundary. React Testing Library complements Vitest by encouraging tests that render components into a document and query the DOM the way assistive technology and users do. Together they form the backbone of the bottom two layers of the testing pyramid: fast feedback on real component behavior.
Configuring Vitest with React and jsdom
Most React apps need a DOM implementation in Node. Vitest’s environment: 'jsdom' option wires that
in per worker. A typical vite.config.ts merges the app’s plugins with a test block:
/// <reference types="vitest" />
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"],
coverage: {
provider: "v8",
thresholds: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
},
});
globals: true lets you skip importing describe and it in every file; whether you enable it is
a team style choice—explicit imports make examples more portable, globals reduce noise in large
suites.
Custom matchers from @testing-library/jest-dom belong in a single setup file so expectations
like toBeInTheDocument() and toBeDisabled() are available everywhere:
import "@testing-library/jest-dom";
Install the usual testing stack alongside Vitest:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
If you use TypeScript path aliases in Vite, reuse them in tests; the point of this setup is that the same module resolution story applies to app code and test code, which cuts down on “import works in the app but not in tests” drift.
Patterns that mirror real usage
The following example exercises a login form for validation, success, and loading behavior. Notice
the combination of role and name queries, userEvent, and assertions on outcomes users
would see—error copy, callback arguments, disabled state during an async submit.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
describe('LoginForm', () => {
it('shows error when submitted with empty fields', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(screen.getByText(/email is required/i)).toBeInTheDocument()
})
it('calls onSubmit with credentials on valid submission', async () => {
const user = userEvent.setup()
const mockSubmit = vi.fn()
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('disables submit button during loading', async () => {
const user = userEvent.setup()
const slowSubmit = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 1000)))
render(<LoginForm onSubmit={slowSubmit} />)
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(screen.getByRole('button', { name: /signing in/i })).toBeDisabled()
})
})
This style of test survives refactors that change internal state shape or split child components, as long as the outward behavior and accessible labels remain consistent—which is exactly the contract you want to enforce.
Async work: findBy, waitFor, and act
React 18 and 19 make asynchronous rendering and effects ordinary. Tests should await queries
that may appear after microtasks or network mocks resolve. findBy* queries wrap waitFor and
return a promise; they read well for “eventually this text shows up.” Use waitFor when you need to
assert a combination of conditions or intermediate states, but avoid turning it into a retry loop
around unrelated expectations—that produces flaky tests.
act is still relevant when you trigger updates outside React Testing Library helpers, but in most
RTL flows the library already wraps updates correctly. Reach for explicit act when integrating
lower-level utilities.
Timers, modules, and TypeScript seams
Components that debounce input, poll for updates, or auto-dismiss toasts are notorious for flaky
tests. Fake timers let you advance time deterministically instead of sprinkling setTimeout
sleeps through the suite. After advancing time, still ask what the user would see—not merely that a
callback fired.
When you must mock a module—analytics, maps, or a Server Action import—prefer vi.mock hoisted at
the top with narrow factories that return typed stubs. Replace boundaries, not every
transitive dependency, or you will test the runner’s imagination more than your app. Explicit return
types on mock factories catch drift when production signatures change.
Organize tests consistently: colocated *.test.tsx or a parallel __tests__ tree, but pick one
convention per package. Large suites benefit from shared render helpers that wrap common
providers so each file does not paste twenty lines of boilerplate—keep those helpers small so
failures stay easy to localize.
Snapshots: use sparingly but deliberately
Large snapshot dumps of entire component trees usually age poorly; they encode implementation noise. When snapshots help, they tend to be small and stable: serialized error messages, structured props passed to a headless child, or the output of a formatting helper. If a snapshot failure does not immediately suggest whether the change is a regression or an intentional UI tweak, the snapshot is too big.
Coverage thresholds as a team agreement
The coverage.thresholds block in Vitest configuration turns an aspiration into a CI gate. The
numbers should reflect what the team is willing to enforce, not fantasy metrics. If thresholds are
set too high, developers bypass them with meaningless tests; if too low, they offer no signal. Pair
thresholds with review expectations: when coverage drops, the pull request should explain whether
the uncovered code is trivial boilerplate or a risky branch that still needs a behavioral test.
Vitest’s speed makes it realistic to run the full unit and component suite on every push. That frequency is what converts testing from a pre-release ritual into continuous design feedback—exactly what advanced React teams expect in 2026.