Vitest in Practice: Setup, Mocking Pitfalls, React Component Tests and Coverage
Key takeaways
Vitest runs your tests through the same Vite pipeline your app uses, so aliases, TypeScript and plugins work without a second config. Most of the time you lose with it goes to a few specific traps: hoisted vi.mock factories, mocks leaking between tests, and Testing Library cleanup that silently stops running. This guide covers the setup and those traps.
Why Vitest?
If you have ever set up Jest in a Vite project, you know the friction: Vite handles your TypeScript, path aliases, CSS imports and ESM, but Jest does not know about any of that, so you end up recreating it with Babel or ts-jest, a moduleNameMapper for every alias and asset type, and a separate config that drifts from the real build. Vitest removes that duplication by running tests through Vite’s own transform pipeline. Whatever works in vite dev works in tests.
The API is intentionally close to Jest’s (describe, it, expect, vi.fn() instead of jest.fn()), so the learning curve is mostly about the places where it differs, which is where this guide spends its time.
Where Vitest fits well
- Any project already built with Vite: React, Vue, Svelte, Solid, and frameworks built on Vite such as Nuxt, SvelteKit and Astro.
- Libraries written as native ESM, which Jest still only supports behind an experimental flag.
- Monorepos where each package already has a Vite config.
Where Jest may still be the better choice
- A large, working Jest suite in a non-Vite project. Migration is usually straightforward but not free, and “it is faster” rarely justifies it on its own.
- Projects that depend on Jest-specific transformers or custom environments you would have to rewrite.
Speed is often the headline, and watch mode in particular feels fast because Vitest only re-runs tests affected by the changed file’s module graph. But the gains depend heavily on your suite. A suite dominated by jsdom setup or slow component renders will not become dramatically faster just by switching runners, so benchmark your own tests before promising a number.
Setup
npm install -D vitest
# For React + DOM testing
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vite.config.ts
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true, // describe/it/expect without imports
environment: 'jsdom', // or 'node' for backend code
setupFiles: ['./src/test/setup.ts'],
restoreMocks: true, // restore vi.fn/vi.spyOn state after each test
unstubGlobals: true, // undo vi.stubGlobal after each test
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
reporter: ['text', 'html', 'lcov'],
thresholds: { lines: 80, functions: 80, branches: 70 },
},
},
});
The triple-slash reference on the first line matters. Without it, TypeScript reports Object literal may only specify known properties, and 'test' does not exist in type 'UserConfigExport', because Vite’s own types know nothing about the test key. The alternative is to import defineConfig from vitest/config instead of vite.
globals: true also needs a type declaration, or your editor will underline every describe and expect:
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
}
}
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'; // adds toBeInTheDocument() etc. to Vitest's expect
Import the /vitest entry point, not bare @testing-library/jest-dom. The bare import extends a global expect that Jest provides; with Vitest the matchers either fail to register or register without types, and you get Property 'toBeInTheDocument' does not exist on type 'Assertion'.
// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}
vitest with no arguments starts watch mode in a terminal and single-run mode when it detects CI (process.env.CI). --ui and --coverage each need an extra package (@vitest/ui, @vitest/coverage-v8); Vitest prompts to install them the first time.
Basic Test Syntax
// src/utils/math.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { add, divide } from './math';
describe('math utilities', () => {
it('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('throws on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
Explicit imports from vitest work whether or not globals is on. Some teams prefer them because it is clear where expect comes from and nothing leaks into the global type scope; the trade-off is one extra line per file and a Testing Library caveat covered below.
Files matching **/*.{test,spec}.?(c|m)[jt]s?(x) are picked up by default. If your end-to-end tests also use .spec.ts (Playwright does by convention), exclude their folder in test.exclude, or Vitest will try to run them and fail on Playwright’s test import.
Common Matchers
// Equality
expect(value).toBe(expected); // Object.is — same primitive or same reference
expect(value).toEqual(expected); // recursive equality, ignores undefined properties
expect(value).toStrictEqual(expected); // also checks undefined props and class types
// Numbers
expect(0.1 + 0.2).toBeCloseTo(0.3); // never toBe for floats
expect(5).toBeGreaterThan(4);
// Strings, arrays, objects
expect('Hello world').toContain('world');
expect([1, 2, 3]).toHaveLength(3);
expect({ name: 'Alice', role: 'admin' }).toMatchObject({ name: 'Alice' });
// Async
await expect(fetchThing()).resolves.toBe('result');
await expect(fetchThing()).rejects.toThrow('Error message');
// Spies
const fn = vi.fn();
fn('arg1');
expect(fn).toHaveBeenCalledWith('arg1');
expect(fn).toHaveBeenCalledTimes(1);
The difference between toEqual and toStrictEqual shows up in API tests: toEqual({ id: 1 }) passes for { id: 1, deletedAt: undefined }, which may hide a serialization difference you care about.
Forgetting the await in front of expect(...).resolves / .rejects is the async mistake I see most. The test finishes before the promise settles and passes no matter what. Vitest warns about unawaited assertions in recent versions, but only if you read the output.
Mocking
vi.fn() — mock functions
const mockFn = vi.fn();
mockFn.mockReturnValue(42);
mockFn.mockResolvedValue({ data: 'result' }); // async success
mockFn.mockRejectedValue(new Error('Failed')); // async failure
mockFn.mockImplementation((x: number) => x * 2);
mockFn
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default');
vi.mock() — module mocks, and why they are hoisted
// src/services/user.test.ts
import { vi, describe, it, expect } from 'vitest';
import { sendEmail } from '../lib/email';
import { createUser } from './user';
vi.mock('../lib/email', () => ({
sendEmail: vi.fn().mockResolvedValue({ success: true }),
}));
describe('createUser', () => {
it('sends a welcome email', async () => {
await createUser({ name: 'Alice', email: '[email protected]' });
expect(sendEmail).toHaveBeenCalledWith({ to: '[email protected]', subject: 'Welcome!' });
});
});
Even though vi.mock is written after the imports, Vitest moves it to the top of the file before anything runs. It has to: ES module imports are evaluated before the module body, so a mock registered “later” would be too late. The consequence surprises everyone once:
// Fails: ReferenceError: Cannot access 'mockSend' before initialization
const mockSend = vi.fn();
vi.mock('../lib/email', () => ({ sendEmail: mockSend }));
The factory now runs before const mockSend exists. The fix is vi.hoisted, which is hoisted together with the mock:
const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn() }));
vi.mock('../lib/email', () => ({ sendEmail: mockSend }));
Other module-mock details that differ from Jest:
-
Partial mocks use
await vi.importActual()(async), notjest.requireActual:vi.mock('../lib/date', async (importOriginal) => { const actual = await importOriginal<typeof import('../lib/date')>(); return { ...actual, now: vi.fn(() => new Date('2026-01-01')) }; }); -
Default exports must be returned under a
defaultkey.vi.mock('./logger', () => ({ log: vi.fn() }))for a module withexport default loggergives youundefinedfor the default import, and Vitest throws an error pointing at the missingdefaultkey. -
The path is resolved like an import, relative to the test file, and aliases work. But it must match the module your code actually imports. Mocking
'../lib/email'does nothing if the code under test imports'@/lib/email/index'and those resolve to different files.
vi.spyOn() — replace one method
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// ... run code
expect(errorSpy).toHaveBeenCalledWith('Something went wrong');
With restoreMocks: true in the config, spies are restored after each test. Without it, a console.error silenced in one test stays silenced for the rest of the file — which is how real errors disappear from test output.
Mocking fetch
import { vi, describe, it, expect } from 'vitest';
import { fetchUser } from './api';
describe('fetchUser', () => {
it('returns user data on success', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Alice' }),
});
vi.stubGlobal('fetch', mockFetch);
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
});
it('throws on 404', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
await expect(fetchUser(999)).rejects.toThrow('User not found');
});
});
vi.stubGlobal plus unstubGlobals: true puts the real fetch back after each test. Assigning global.fetch = vi.fn() directly works too, but nothing undoes it. Each test file runs in its own module context by default, so the leak stays inside one file — which is exactly why it is confusing: a test fails only when it runs after a particular other test in the same file.
For anything beyond a couple of calls, hand-built response objects get brittle (the code starts calling res.headers.get() and the mock does not have headers). MSW intercepts at the network level and returns real Response objects, which scales better for API-heavy components.
React Component Testing
// src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('increments when the button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('accepts a starting value', () => {
render(<Counter initialCount={5} />);
expect(screen.getByText('Count: 5')).toBeInTheDocument();
});
});
Testing Library unmounts rendered components after each test by registering an afterEach hook — but only if a global afterEach exists when it is imported. With globals: true that works automatically. With globals: false, cleanup silently never runs: every render adds to the same document, and by the third test you get Found multiple elements with the role "button". If you keep globals off, add this to the setup file:
// src/test/setup.ts (when globals: false)
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
afterEach(() => cleanup());
Prefer userEvent over fireEvent. fireEvent.click dispatches a single click event; user.click performs the sequence a browser does (pointer down, focus, pointer up, click), so it catches bugs like a button that is covered or disabled.
Async components
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import { UserProfile } from './UserProfile';
import * as api from '../services/api';
vi.mock('../services/api');
describe('UserProfile', () => {
it('shows loading, then user data', async () => {
vi.mocked(api.fetchUser).mockResolvedValue({ id: 1, name: 'Alice', email: '[email protected]' });
render(<UserProfile userId={1} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('shows an error state', async () => {
vi.mocked(api.fetchUser).mockRejectedValue(new Error('Network error'));
render(<UserProfile userId={1} />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
});
vi.mock('../services/api') without a factory automocks the module: every export becomes a vi.fn() returning undefined. vi.mocked() is only a type helper so TypeScript knows those functions have mockResolvedValue.
findBy* is waitFor + getBy* in one call and reads better for “wait until this appears”. Keep waitFor for assertions that are not about an element appearing, and do not put side effects (like clicks) inside a waitFor callback — it may run them several times.
The warning An update to UserProfile inside a test was not wrapped in act(...) usually means the test finished while the component was still updating — a promise resolved after the last assertion. Awaiting the final UI state with findBy* normally makes it go away; wrapping things in act manually rarely is the right fix.
Forms
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
it('submits email and password', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: '[email protected]', password: 'password123' });
});
getByLabelText doubles as an accessibility check: if it cannot find the input, a screen reader cannot associate the label with it either.
Fake timers with user-event
Debounced inputs and toasts that disappear after a delay are where tests usually hang:
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('searches after the debounce delay', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Search />);
await user.type(screen.getByRole('searchbox'), 'vitest');
await vi.advanceTimersByTimeAsync(300);
expect(await screen.findByText(/results for vitest/i)).toBeInTheDocument();
});
Without advanceTimers, user-event’s internal delays wait on timers that never fire because they are faked, and the test times out after five seconds with no useful message. This is one of the more time-consuming failures to diagnose the first time, because nothing in the error points at timers.
Testing Hooks
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
renderHook is part of @testing-library/react since v13.1; the separate @testing-library/react-hooks package is deprecated for React 18+. Read result.current after the act — holding a reference to result.current from before the update gives you the stale value.
If a hook is only ever used by one component, testing the component usually gives you more confidence for less code. Dedicated hook tests pay off for shared hooks with non-trivial logic.
Snapshot Testing
it('matches snapshot', () => {
const { container } = render(<Button variant="primary" disabled>Submit</Button>);
expect(container.firstChild).toMatchSnapshot();
});
vitest run -u # update snapshots after an intentional change
Snapshots are cheap to write and cheap to approve, which is their weakness. A large component snapshot fails on every markup change, reviewers learn to run -u without reading the diff, and the test stops catching anything. Keep snapshots small and targeted (a serialized config object, an error message), or use toMatchInlineSnapshot() so the expected output sits in the test and shows up in code review.
Coverage
vitest run --coverage
% Coverage report from v8
-------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-------------|---------|----------|---------|---------|
All files | 82.45 | 74.13 | 85.71 | 82.45 |
math.ts | 100 | 100 | 100 | 100 |
format.ts | 90 | 77.77 | 100 | 90 |
-------------|---------|----------|---------|---------|
Set coverage.include. Without it, only files imported by at least one test appear in the report, so a module with no tests at all is invisible rather than showing 0% — the report looks better than reality.
v8 coverage uses the engine’s built-in counters and is the default and faster option; istanbul instruments the source and is occasionally more precise on branch counting. Treat thresholds as a floor that prevents regressions, not a goal: 100% line coverage with no meaningful assertions is easy to reach and proves little.
CI Integration
# .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: 22
cache: 'npm'
- run: npm ci
- run: npm run test:coverage # runs the suite once and enforces thresholds
- uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
Run the suite once, with coverage. Running test:run and then test:coverage as separate steps doubles CI time for no extra information.
For large suites, vitest run --shard=1/3 splits files across parallel jobs; combine the reports afterwards with --merge-reports using the blob reporter.
How Vitest runs your tests
- Transform: every file goes through Vite’s plugin pipeline, so aliases,
import.meta.env, and framework plugins behave as in dev. - Isolation: test files run in parallel in separate workers. Since Vitest 2 the default pool is
forks(child processes), chosen for compatibility with native modules;threads(worker_threads) can be faster but some native addons crash in it. Tests within one file run sequentially and share module state. - Environment:
jsdomandhappy-domsimulate the DOM in Node. They do not do layout, so anything depending on element sizes,IntersectionObserveror real CSS needs mocking — or Vitest Browser Mode, which runs tests in a real browser via Playwright.
Unit tests with Vitest do not replace end-to-end tests. They verify components and logic in isolation; a real browser against a running app (see Playwright E2E testing) catches the integration problems they cannot.
Migrating from Jest
Most of it is search-and-replace, plus a few semantic differences:
| Jest | Vitest |
|---|---|
jest.fn(), jest.spyOn() | vi.fn(), vi.spyOn() |
jest.mock(path, factory) | vi.mock(path, factory) — factory cannot use outer variables; use vi.hoisted |
jest.requireActual(path) | await vi.importActual(path) — async |
jest.useFakeTimers() | vi.useFakeTimers() |
testEnvironment: 'jsdom' | test.environment: 'jsdom' |
moduleNameMapper | not needed; uses Vite resolve.alias |
@testing-library/jest-dom | @testing-library/jest-dom/vitest |
Jest allows factory variables whose names start with mock; Vitest has no such exception. That single difference accounts for most of the errors when a Jest suite is first run under Vitest.
Quick Reference
vitest # watch mode (single run in CI)
vitest run # single run
vitest run src/components # filter by path
vitest -t "Counter" # filter by test name
vitest run --coverage
vitest run -u # update snapshots
vitest --ui # browser UI (needs @vitest/ui)
it.skip('skipped', () => {});
it.only('focused', () => {});
it.todo('write this later');
it.each([[1, 2, 3], [2, 3, 5]])('add(%i, %i) = %i', (a, b, sum) => {
expect(add(a, b)).toBe(sum);
});