Vitest + React Testing Library: Full Setup From Zero
Set up Vitest and React Testing Library from scratch in 2026 — zero guesswork, real config, and patterns that actually hold up in production React projects.
Why Vitest Instead of Jest in 2026
Jest had a good run. For years it was the default — you scaffolded a React project, added babel-jest, fought with moduleNameMapper for five minutes, and moved on. That workflow isn't broken, but Vitest is genuinely better now and you'd be doing yourself a disservice not to switch. Cold-start time alone is reason enough: Vitest 2.x runs your first test suite in under 500ms on a modern machine because it reuses Vite's transform pipeline instead of spinning up a separate transpiler.
The other thing that matters: config parity. If you're already using Vite (and most React projects are), your vite.config.ts drives everything — aliases, env vars, plugins — and Vitest inherits all of it automatically. No duplicate moduleNameMapper in a separate jest.config.js. That symmetry saves real time when you're debugging a failing test that mysteriously can't resolve @/components/Button.
Honestly, the DX gap closed completely around Vitest 1.3 in early 2025. The watch mode is snappy, the error output is clean, and the API is close enough to Jest that any existing describe/it/expect muscle memory transfers directly. Look, you can keep using Jest and it'll work fine — but for new projects there's no compelling reason to.
Installing the Stack
You need three packages: vitest, @testing-library/react, and @testing-library/jest-dom. The last one gives you the readable matchers (toBeInTheDocument, toHaveTextContent, etc.) that make assertions readable by humans. You'll also want @testing-library/user-event for simulating realistic keyboard and mouse interactions rather than raw DOM event firing.
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event @vitejs/plugin-react jsdomWorth noting: jsdom is the default environment Vitest uses when you're running browser-API code (DOM, window, etc.). It's a simulated browser in Node — not perfect, but good enough for 95% of component tests. If you're testing something that legitimately needs a real browser, look at @vitest/browser with Playwright underneath it, but that's a more complex setup you probably don't need on day one.
One more thing — make sure @vitejs/plugin-react is in your dev dependencies. It handles the JSX transform that Vitest needs to parse .tsx files. If you're already using it in your vite.config.ts for the dev server, you're set.
Configuring Vitest
You have two valid approaches: add a test block directly in your vite.config.ts, or create a separate vitest.config.ts. For most projects the inline approach is cleaner. The separate file only makes sense when your test config needs meaningfully different plugins than your dev build.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': '/src',
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
});The globals: true flag is what lets you write describe, it, and expect without importing them — just like Jest. If you prefer explicit imports for editor autocomplete reasons, skip it and add import { describe, it, expect } from 'vitest' at the top of your test files. Either way works. Quick aside: set globals: true in your tsconfig.json types array too so TypeScript doesn't complain about undefined globals.
// tsconfig.json (relevant addition)
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}The setupFiles entry points to a file that runs before each test file. That's where you import @testing-library/jest-dom so its matchers are available globally without importing them every time.
The Setup File and Global Matchers
Create src/test/setup.ts (the path matches what you put in setupFiles above). This file does one job: extend Vitest's expect with Testing Library's DOM matchers.
// src/test/setup.ts
import '@testing-library/jest-dom';That's really it. One line. The import side-effects patch expect so matchers like toBeVisible(), toBeDisabled(), toHaveClass(), and toHaveValue() are available everywhere. In practice, forgetting this file is responsible for 80% of the "Property 'toBeInTheDocument' does not exist" TypeScript errors people post about in Discord.
If you use vi.mock a lot, you might also add vi.clearAllMocks() here inside a beforeEach so mocks don't bleed between tests. That said, it's better to clean up in the test file itself when possible — global beforeEach hooks make test isolation harder to reason about at scale.
Writing Your First Real Tests
Let's test an actual component — not a contrived counter, but something closer to what you'd ship. Here's a Button component with a loading state, a disabled prop, and an onClick handler. This is the kind of thing you'd find in any Empire UI component, and it's worth practicing on because it covers the three things most unit tests care about: rendered output, user interaction, and conditional state.
// src/components/Button.tsx
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
isLoading?: boolean;
disabled?: boolean;
variant?: 'primary' | 'ghost';
}
export function Button({
children,
onClick,
isLoading = false,
disabled = false,
variant = 'primary',
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled || isLoading}
aria-busy={isLoading}
className={`btn btn-${variant}`}
>
{isLoading ? 'Loading...' : children}
</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 children when not loading', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('shows loading text and disables button when isLoading', () => {
render(<Button isLoading>Click me</Button>);
const btn = screen.getByRole('button');
expect(btn).toBeDisabled();
expect(btn).toHaveTextContent('Loading...');
expect(btn).toHaveAttribute('aria-busy', 'true');
});
it('fires onClick when clicked', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('does not fire onClick when disabled', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick} disabled>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});Notice userEvent.setup() instead of the old userEvent.click() pattern from v13. As of @testing-library/user-event v14 (released in 2022 but still the current major in 2026), you call userEvent.setup() to get a user instance, then call methods on that. It simulates real browser event sequencing — pointer down, pointer up, click — rather than dispatching synthetic events directly. That difference catches a whole class of bugs that the old API missed.
Running everything is just npx vitest in watch mode or npx vitest run for a single CI pass. Add "test": "vitest run" and "test:watch": "vitest" to your package.json scripts and you're done.
Mocking, Modules, and Async Components
Mocking in Vitest is cleaner than Jest in one specific way: vi.mock() is hoisted automatically, same as Jest, but you can also use vi.spyOn() at the module level without the hoisting gotchas. The API is identical — vi.fn(), vi.mock('module-path'), vi.spyOn(object, 'method') — so switching from Jest doesn't require relearning anything.
// Mocking a module
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
import * as api from '../lib/api';
vi.mock('../lib/api');
beforeEach(() => {
vi.clearAllMocks();
});
it('displays user name after fetch', async () => {
vi.mocked(api.getUser).mockResolvedValue({ id: '1', name: 'Ada Lovelace' });
render(<UserProfile userId="1" />);
await waitFor(() =>
expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
);
});For async components — especially React 19 Server Components or components using Suspense — wrap your render in act or use waitFor. The @testing-library/react 15.x release shipped first-class Suspense support so you don't need a custom wrapper anymore in most cases. If you're testing components that touch React.use() or async data fetching, waitFor is your friend.
In practice, the most common mocking need isn't an entire module — it's a single fetch call. Use vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve(data) })) in your setup and restore it with vi.unstubAllGlobals() in an afterEach. That pattern keeps your tests hermetic without reaching for a network-layer library on day one.
Coverage, CI, and Keeping Tests Useful
Coverage reports are only valuable if you actually look at them. Run npx vitest run --coverage to generate an HTML report in coverage/index.html. Vitest's built-in v8 provider is accurate and fast — it hooks into V8's native coverage instrumentation rather than instrumenting your source at the AST level, so you get real branch coverage numbers at near-zero overhead.
For CI (GitHub Actions, GitLab CI, whatever you use), the minimum viable config is just npx vitest run. That exits with a non-zero code on any test failure. Add --coverage --coverage.thresholds.lines=80 if you want to gate merges on coverage percentage. 80% lines is a reasonable floor; 100% is usually not worth the maintenance cost unless you're writing a library.
# .github/workflows/test.yml
name: Test
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 testOne thing worth getting right early: don't test implementation details. Query by role and label, not by class name or internal state. That's the whole philosophy behind React Testing Library — your tests should break when behavior changes, not when you rename a CSS class. If you're testing components from Empire UI or any design system, test the rendered output and user interactions, not the internal hooks or state shape.
That said, Vitest + React Testing Library is just the unit layer. For full confidence you'll also want at least a handful of end-to-end tests with Playwright or Cypress covering your critical paths. The component testing guide on the blog walks through where to draw that line between unit, integration, and E2E.
FAQ
CRA uses webpack under the hood, not Vite, so there's no automatic config reuse. You'd need to eject or migrate to Vite first — the migration is usually worth it, but it's not a five-minute job on a large project.
No. Vitest uses Vite's esbuild transform for TypeScript and JSX, which is faster than Babel and requires zero extra config. You only need Babel if you're using Babel-specific transforms that esbuild doesn't support.
fireEvent dispatches a single synthetic DOM event. userEvent simulates the full sequence of browser events a real user triggers (pointerdown, focus, input, pointerup, click, etc.) — it catches more bugs and is preferred for interaction tests.
Wrap your render call in the provider: render(<ThemeProvider><Button /></ThemeProvider>). Alternatively, create a custom renderWithProviders wrapper function in your test utils file so you don't repeat the boilerplate in every test file.