
Vitest Testing
- 164 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Author Vitest unit and integration tests with mocks, coverage thresholds, and monorepo-aware config before release.
About
Instructs agents to set up Vitest for TypeScript and JavaScript projects, including test structure, mocking strategies, coverage configuration, and monorepo package boundaries so regressions are caught before deploy.
- Vitest config
- Mocks and stubs
- Coverage gates
- Monorepo tests
- Fast unit runs
Vitest Testing by the numbers
- 164 all-time installs (skills.sh)
- +8 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #858 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill vitest-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 164 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Author Vitest unit and integration tests with mocks, coverage thresholds, and monorepo-aware config before release.
Files
Vitest Testing
Overview
Vitest is a Vite-native unit testing framework that shares the same configuration and plugin ecosystem. Built for speed with native ESM support, hot module replacement for tests, and parallel execution.
When to use: Unit tests, component tests, integration tests, hook tests, in-source tests. Testing React components with React Testing Library. Testing TanStack Query/Router/Form patterns.
When NOT to use: End-to-end testing (use Playwright), visual regression testing (use Percy/Chromatic), load testing (use k6).
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Test structure | describe('suite', () => {}) | Organize related tests |
| Single test | test('behavior', () => {}) or it() | Both are aliases |
| Parameterized tests | test.each([...])('name', (arg) => {}) | Run same test with different inputs |
| Concurrent tests | test.concurrent('name', async () => {}) | Run tests in parallel |
| Skip test | test.skip('name', () => {}) or skipIf(cond) | Conditionally skip tests |
| Only test | test.only('name', () => {}) | Run specific test for debugging |
| Assertions | expect(value).toBe(expected) | Compare primitive values |
| Deep equality | expect(obj).toEqual(expected) | Compare objects/arrays |
| Async assertions | await expect(promise).resolves.toBe(value) | Test promise resolution |
| Before each test | beforeEach(() => {}) | Setup before each test |
| After each test | afterEach(() => {}) | Cleanup after each test |
| Mock function | vi.fn() | Create spy function |
| Mock module | vi.mock('./module') | Replace entire module |
| Spy on method | vi.spyOn(obj, 'method') | Track calls to existing method |
| Clear mocks | vi.clearAllMocks() | Clear call history |
| Fake timers | vi.useFakeTimers() | Control setTimeout/setInterval |
| Render component | render(<Component />) | Mount React component for testing |
| Query by role | screen.getByRole('button') | Find elements by accessibility role |
| User interaction | await user.click(element) | Simulate user events |
| Wait for change | await waitFor(() => expect(...)) | Wait for async changes |
| Find async element | await screen.findByText('text') | Query + wait combined |
| Render hook | renderHook(() => useHook()) | Test custom hooks |
| Update hook props | rerender(newProps) | Re-render hook with new props |
| Snapshot | expect(value).toMatchSnapshot() | Compare against saved snapshot |
| Inline snapshot | expect(value).toMatchInlineSnapshot() | Snapshot stored in test file |
| CLI run once | vitest run | Single run, no watch |
| Run changed tests | vitest --changed | Tests affected by git changes |
| Filter by name | vitest -t "pattern" | Grep test names |
| Soft assertions | expect.soft(value).toBe(x) | Continue on failure, collect all |
| Poll assertions | await expect.poll(() => val).toBe(x) | Retry until passing |
| Test fixtures | const test = base.extend<F>({...}) | Reusable setup via test.extend |
| Hoisted mocks | vi.hoisted(() => ({ fn: vi.fn() })) | Variables for vi.mock factory |
| Shard tests | vitest --shard 1/3 | Split across CI workers |
| Tags | test('name', { tags: ['slow'] }, ...) | Filter with --tags-filter |
| Stub globals | vi.stubGlobal('fetch', vi.fn()) | Replace global objects cleanly |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Using getBy for async content | Use findBy or waitFor for async |
| Testing implementation details | Test behavior and public API |
Using getByTestId as first choice | Prefer getByRole, getByLabelText, getByText |
Missing userEvent.setup() | Always call const user = userEvent.setup() first |
| Shared state between tests | Use beforeEach to reset or create fresh instances |
| Not cleaning up mocks | Use vi.clearAllMocks() in afterEach |
| Mocking too much | Only mock external dependencies and APIs |
| Not disabling retries in tests | Set retry: false for TanStack Query tests |
| Immediate assertions on async | Use await waitFor() or findBy queries |
| Creating QueryClient in render | Create once in wrapper or use useState |
| Testing library code | Trust the library, test your usage |
| Not awaiting user events | All user.* methods return promises |
Using act manually | userEvent and Testing Library handle this |
| Inline select without memoization | Extract to stable function for TanStack Query |
| Variables in vi.mock factory | Use vi.hoisted() to declare mock variables |
Single expect for multiple checks | Use expect.soft() to collect all failures |
setTimeout in tests for async waits | Use expect.poll() or vi.waitFor() |
Manual beforeEach for reusable setup | Use test.extend fixtures for composable setup |
Delegation
- Test discovery: For finding untested code paths, use
Exploreagent - Coverage analysis: For full coverage review, use
Taskagent - E2E testing: If
playwrightskill is available, delegate E2E testing to it - Code review: After writing tests, delegate to
code-revieweragent
References
- Test fundamentals: structure, assertions, lifecycle hooks
- Mocking: vi.fn, vi.mock, vi.spyOn, module mocking
- Component testing: React Testing Library, queries, user-event
- Hook testing: renderHook, async hooks, TanStack patterns
- Test setup: jest-dom matchers, cleanup, custom render, MSW, polyfills
- Configuration: vitest.config.ts, workspace, coverage, reporters
- Advanced patterns: snapshots, concurrent tests, in-source, type testing
- CLI and filtering: commands, watch mode, tags, sharding
- Fixtures and context: test.extend, scoping, auto fixtures, injection
Advanced Patterns
Snapshot Testing
Capture and compare output snapshots:
import { expect, test } from 'vitest';
test('matches snapshot', () => {
const output = generateOutput({ name: 'Alice', age: 30 });
expect(output).toMatchSnapshot();
});First run creates __snapshots__/test-file.test.ts.snap:
exports['matches snapshot 1'] = `
{
"name": "Alice",
"age": 30,
"createdAt": "2024-01-01T00:00:00.000Z"
}
`;Subsequent runs compare against saved snapshot.
Inline Snapshots
Store snapshots in test file:
test('inline snapshot', () => {
const user = { name: 'Alice', age: 30 };
expect(user).toMatchInlineSnapshot(`
{
"name": "Alice",
"age": 30,
}
`);
});Vitest updates the test file on first run or with --update flag.
Updating Snapshots
Update snapshots when output changes:
vitest -u
vitest --updateUpdate specific test:
vitest -u src/components/Button.test.tsxProperty Matchers in Snapshots
Ignore dynamic values:
test('snapshot with dynamic values', () => {
const user = {
id: generateId(),
name: 'Alice',
createdAt: new Date(),
};
expect(user).toMatchSnapshot({
id: expect.any(String),
createdAt: expect.any(Date),
});
});Snapshot Hints
Named snapshots produce distinct keys in the snapshot file, making diffs clearer when a test has multiple toMatchSnapshot calls. Without hints, snapshots are numbered (1, 2, 3...) which is harder to identify:
test('multiple snapshots with hints', () => {
const header = renderHeader();
const footer = renderFooter();
expect(header).toMatchSnapshot('header');
expect(footer).toMatchSnapshot('footer');
});Concurrent Tests
Run tests in parallel for faster execution:
import { describe, expect, test } from 'vitest';
describe.concurrent('parallel suite', () => {
test('test 1', async () => {
const result = await fetchData('1');
expect(result).toBeDefined();
});
test('test 2', async () => {
const result = await fetchData('2');
expect(result).toBeDefined();
});
test('test 3', async () => {
const result = await fetchData('3');
expect(result).toBeDefined();
});
});All tests in the suite run concurrently.
Individual Concurrent Tests
Mark specific tests as concurrent:
test.concurrent('parallel 1', async () => {
await slowOperation();
});
test.concurrent('parallel 2', async () => {
await slowOperation();
});
test('sequential', () => {
expect(true).toBe(true);
});Concurrent Limits
Limit concurrent test execution:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
maxConcurrency: 5,
},
});Sequential Tests
Force sequential execution in concurrent suite:
describe.concurrent('suite', () => {
test('runs in parallel', async () => {});
test.sequential('runs after parallel', async () => {});
test.sequential('runs after previous sequential', async () => {});
});In-Source Testing
Write tests alongside source code:
export function add(a: number, b: number) {
return a + b;
}
if (import.meta.vitest) {
const { describe, expect, it } = import.meta.vitest;
describe('add', () => {
it('adds positive numbers', () => {
expect(add(1, 2)).toBe(3);
});
it('adds negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
});
});
}Enable in vitest.config.ts:
export default defineConfig({
test: {
includeSource: ['src/**/*.ts'],
},
define: {
'import.meta.vitest': 'undefined',
},
});Benefits: tests stay close to code, easy to maintain, tree-shaken in production.
Type Testing
Test TypeScript types at compile time:
import { expectTypeOf } from 'vitest';
test('type assertions', () => {
expectTypeOf({ a: 1, b: 'test' }).toEqualTypeOf<{ a: number; b: string }>();
expectTypeOf('test').toBeString();
expectTypeOf(123).toBeNumber();
expectTypeOf(true).toBeBoolean();
expectTypeOf(null).toBeNull();
expectTypeOf(undefined).toBeUndefined();
expectTypeOf([1, 2, 3]).toBeArray();
expectTypeOf({}).toBeObject();
expectTypeOf(() => {}).toBeFunction();
expectTypeOf<Promise<string>>().resolves.toBeString();
expectTypeOf<string | number>().toMatchTypeOf<string>();
});Assert Types
Runtime and compile-time assertions:
import { assertType } from 'vitest';
test('assert type', () => {
const value: unknown = 'hello';
if (typeof value === 'string') {
assertType<string>(value);
expect(value.toUpperCase()).toBe('HELLO');
}
});Type Test Files
Dedicated .test-d.ts files for type-only testing:
// math.test-d.ts
import { expectTypeOf, test } from 'vitest';
import { add, type Result } from './math';
test('add returns number', () => {
expectTypeOf(add(1, 2)).toEqualTypeOf<number>();
});
test('Result type', () => {
expectTypeOf<Result>().toMatchTypeOf<{ value: number; error?: string }>();
});Enable type checking in config:
export default defineConfig({
test: {
typecheck: {
enabled: true,
},
},
});toEqualTypeOf requires an exact match. toMatchTypeOf allows the type to be a subset (structural subtyping).
Custom Matchers
Extend expect with custom matchers:
import { expect } from 'vitest';
interface CustomMatchers<R = unknown> {
toBeWithinRange(floor: number, ceiling: number): R;
}
declare module 'vitest' {
interface Assertion<T = any> extends CustomMatchers<T> {}
interface AsymmetricMatchersContaining extends CustomMatchers {}
}
expect.extend({
toBeWithinRange(received: number, floor: number, ceiling: number) {
const pass = received >= floor && received <= ceiling;
return {
pass,
message: () =>
pass
? `expected ${received} not to be within range ${floor} - ${ceiling}`
: `expected ${received} to be within range ${floor} - ${ceiling}`,
};
},
});
test('custom matcher', () => {
expect(10).toBeWithinRange(5, 15);
expect(20).not.toBeWithinRange(5, 15);
});Benchmarking
Measure performance:
import { bench, describe } from 'vitest';
describe('sorting algorithms', () => {
bench('sort with Array.sort', () => {
const arr = Array.from({ length: 1000 }, () => Math.random());
arr.sort();
});
bench('sort with custom quicksort', () => {
const arr = Array.from({ length: 1000 }, () => Math.random());
quicksort(arr);
});
});Run benchmarks:
vitest benchBenchmark Options
Configure benchmark behavior:
bench(
'operation',
() => {
heavyOperation();
},
{
time: 1000,
iterations: 100,
warmupIterations: 10,
warmupTime: 100,
},
);Test Context
Share data between hooks and tests:
import { beforeEach, expect, it } from 'vitest';
interface LocalTestContext {
server: TestServer;
user: User;
}
beforeEach<LocalTestContext>(async (context) => {
context.server = await startTestServer();
context.user = await createTestUser();
});
it<LocalTestContext>('makes request', async ({ server, user }) => {
const response = await server.get('/profile', { userId: user.id });
expect(response.status).toBe(200);
});Conditional Tests
Run tests based on environment:
test.skipIf(process.env.CI === 'true')('runs locally only', () => {});
test.runIf(process.platform === 'darwin')('macOS only', () => {});Shuffle Tests
Randomize test order to detect dependencies:
describe.shuffle('random order', () => {
test('test 1', () => {});
test('test 2', () => {});
});Parameterized Suites
describe.for runs an entire suite for each set of parameters:
describe.for([
{ input: 'hello', expected: 'HELLO' },
{ input: 'world', expected: 'WORLD' },
])('toUpperCase($input)', ({ input, expected }) => {
test('converts to uppercase', () => {
expect(input.toUpperCase()).toBe(expected);
});
test('has correct length', () => {
expect(input.toUpperCase()).toHaveLength(expected.length);
});
});test.for is the single-test equivalent:
test.for([
[1, 2, 3],
[2, 3, 5],
])('add(%i, %i) = %i', ([a, b, expected]) => {
expect(a + b).toBe(expected);
});Sequential and Shuffled Suites
describe.sequential forces tests to run in order even inside a concurrent suite. Useful for tests with dependencies:
describe.sequential('database operations', () => {
test('creates table', async () => {});
test('inserts row', async () => {});
test('queries row', async () => {});
});Expected Failures
test.fails inverts the result — the test passes if the assertion fails. Useful for documenting known bugs or incomplete features:
test.fails('not yet implemented', () => {
expect(unfinishedFeature()).toBe(true);
});CLI and Filtering
Running Tests
Start Vitest in watch mode (default for development):
vitestSingle run without watch (CI, scripts):
vitest runRun tests related to changed source files (dependency graph aware):
vitest related src/utils/format.tsList matching test files without running them:
vitest list
vitest list src/components/Run benchmarks:
vitest benchCommon Flags
vitest run --changed # Tests affected by uncommitted git changes
vitest run --changed HEAD~1 # Tests affected since last commit
vitest -t "should validate" # Filter by test name pattern
vitest run --bail 1 # Stop after first failure
vitest run --retry 3 # Retry failed tests up to 3 times
vitest run --reporter=verbose # Detailed output per test
vitest run --reporter=junit # JUnit XML for CI
vitest run --project=unit # Run specific workspace project
vitest run --shard 1/3 # Run first third of tests
vitest run --pool=forks # Use child processes instead of threads
vitest run --no-coverage # Skip coverage even if configuredWatch Mode Keyboard Shortcuts
When Vitest is running in watch mode, press these keys:
| Key | Action |
|---|---|
a | Run all tests |
f | Re-run only failed tests |
p | Filter by filename |
t | Filter by test name |
q | Quit |
Enter | Re-run current test suite |
h | Show help |
The p and t filters accept regex patterns. Press Escape to clear a filter.
Test Filtering
By File Path Pattern
Pass a file pattern as a positional argument:
vitest run src/components/
vitest run Button
vitest run "src/**/*.integration.test.ts"By Test Name
Use -t or --testNamePattern to match describe and test names:
vitest -t "should handle errors"
vitest -t "validation"Combine file and name filters:
vitest run src/auth/ -t "login"Changed Files Only
Run tests affected by git changes:
vitest run --changedCompare against a specific branch:
vitest run --changed mainDependency Graph with vitest related
Find and run tests that import a specific source file, directly or transitively:
vitest related src/utils/format.ts src/utils/date.tsThis traces the import graph to find all test files that depend on the given files.
Tags
Annotate tests with tags for selective execution:
import { describe, test } from 'vitest';
test('connects to database', { tags: ['db'] }, () => {
// ...
});
test('validates input', { tags: ['unit', 'fast'] }, () => {
// ...
});
describe('payment processing', { tags: ['integration', 'slow'] }, () => {
test('charges card', () => {
// ...
});
});Running by Tag
Filter tests by tag expression:
vitest run --tags-filter db
vitest run --tags-filter "unit or fast"
vitest run --tags-filter "integration and not slow"
vitest run --tags-filter "!flaky"Use and, or, not, and ! operators within --tags-filter. List all defined tags with --list-tags.
Strict Tags Config
Require all tests to use pre-defined tags:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
strictTags: true,
tags: [
{ name: 'unit' },
{ name: 'integration' },
{ name: 'slow', timeout: 30_000 },
],
},
});Sharding for CI
Split the test suite across multiple CI workers:
vitest run --shard 1/3
vitest run --shard 2/3
vitest run --shard 3/3Each shard gets a deterministic subset of test files. The denominator is the total number of shards.
GitHub Actions Matrix Example
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1/3, 2/3, 3/3]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
- run: npm ci
- run: npx vitest run --shard ${{ matrix.shard }} --reporter=blob
- uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-${{ strategy.job-index }}
path: .vitest-reports/
merge-reports:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
- run: npm ci
- uses: actions/download-artifact@v4
with:
pattern: blob-report-*
path: .vitest-reports/
merge-multiple: true
- run: npx vitest --merge-reportsThe --reporter=blob flag produces a binary report that --merge-reports combines into a unified result.
Coverage with Sharding
Merge coverage reports from sharded runs:
- run: npx vitest run --shard ${{ matrix.shard }} --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-${{ strategy.job-index }}
path: coverage/Then merge with your coverage tool (e.g., istanbul-merge or c8 merge).
lint-staged Integration
Run only related tests on staged files before committing:
{
"*.{ts,tsx}": ["vitest related --run"]
}The --run flag ensures single-run mode (no watch). vitest related traces the import graph from the staged files to find affected tests.
With ESLint and Prettier
{
"*.{ts,tsx}": ["eslint --fix", "prettier --write", "vitest related --run"]
}Reporter Options
Built-in reporters for different output needs:
vitest run --reporter=default # Grouped by file
vitest run --reporter=verbose # Every test on its own line
vitest run --reporter=dot # Minimal dots
vitest run --reporter=json # JSON to stdout
vitest run --reporter=junit # JUnit XML
vitest run --reporter=html # Interactive HTML report
vitest run --reporter=hanging-process # Debug leaked handlesUse multiple reporters simultaneously:
vitest run --reporter=default --reporter=junit --outputFile.junit=results.xmlConfig-Based Reporters
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
reporters: ['default', 'junit'],
outputFile: {
junit: 'test-results/junit.xml',
},
},
});Project Filtering
In a monorepo with multiple test projects, run tests for specific projects:
vitest run --project=unit
vitest run --project=integration --project=e2eProjects are defined in vitest.config.ts using the projects option (v3.2+):
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
include: ['src/**/*.test.ts'],
},
},
{
test: {
name: 'integration',
include: ['tests/integration/**/*.test.ts'],
},
},
],
},
});Component Testing
Basic Component Test
Render and query components:
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(
screen.getByRole('button', { name: /click me/i }),
).toBeInTheDocument();
});
});Query Priority
Use queries in this order for better accessibility and test maintainability:
1. getByRole — queries by ARIA role (most accessible) 2. getByLabelText — queries form inputs by their label 3. getByPlaceholderText — queries inputs by placeholder 4. getByText — queries by text content 5. getByDisplayValue — queries inputs by current value 6. getByAltText — queries images by alt text 7. getByTitle — queries by title attribute 8. getByTestId — last resort, use only when semantic queries are not possible
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email/i);
screen.getByText(/welcome/i);
screen.getByTestId('custom-element');Query Variants
Three query variants for different scenarios:
screen.getByRole('button');
screen.queryByRole('button');
await screen.findByRole('button');Query types:
getBy*— returns element or throws (use for elements that should exist)queryBy*— returns element or null (use for elements that should not exist)findBy*— returns promise, waits for element (use for async elements)
Multiple elements:
getAllBy*— returns array or throwsqueryAllBy*— returns array (empty if none found)findAllBy*— returns promise with array
User Interactions with user-event
Simulate user behavior:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it } from 'vitest';
it('handles click', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onPress={handleClick}>Click</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});Always call userEvent.setup() before rendering. All user-event methods are async.
Common User Events
const user = userEvent.setup();
await user.click(element);
await user.dblClick(element);
await user.tripleClick(element);
await user.type(input, 'Hello world');
await user.clear(input);
await user.selectOptions(select, ['option1', 'option2']);
await user.deselectOptions(select, ['option1']);
await user.upload(fileInput, file);
await user.hover(element);
await user.unhover(element);
await user.tab();
await user.tab({ shift: true });
await user.keyboard('{Enter}');
await user.keyboard('{Escape}');
await user.keyboard('{ArrowDown}');Testing Form Interactions
it('submits form with valid data', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
});
});Async Testing with waitFor
Wait for async changes:
import { render, screen, waitFor } from '@testing-library/react';
it('loads and displays data', async () => {
render(<UserProfile userId="1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});Using findBy for Async Elements
findBy combines getBy + waitFor:
it('displays loaded data', async () => {
render(<UserProfile userId="1" />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});Equivalent to:
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});Testing with Context Providers
Wrap components in required providers:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function renderWithQuery(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
}
it('fetches and displays user', async () => {
renderWithQuery(<UserProfile userId="1" />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});Reusable Test Wrapper
Create a wrapper for common providers:
import { type ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, type RenderOptions } from '@testing-library/react';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
interface WrapperOptions {
queryClient?: QueryClient;
}
function createWrapper({
queryClient = createTestQueryClient(),
}: WrapperOptions = {}) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
function renderWithProviders(
ui: ReactElement,
options?: WrapperOptions & RenderOptions,
) {
return render(ui, {
wrapper: createWrapper(options),
...options,
});
}
it('uses custom render', async () => {
renderWithProviders(<UserList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});Testing Error States
Verify error handling:
it('displays error message', async () => {
server.use(
http.get('/api/user', () => {
return HttpResponse.json({ message: 'User not found' }, { status: 404 });
}),
);
render(<UserProfile userId="999" />);
expect(await screen.findByText(/user not found/i)).toBeInTheDocument();
});Testing Loading States
Verify loading indicators:
it('shows loading state', () => {
render(<UserProfile userId="1" />);
expect(screen.getByRole('status')).toHaveAttribute('aria-busy', 'true');
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});Testing Conditional Rendering
Use queryBy to assert absence:
it('hides premium content for free users', () => {
render(<Dashboard user={{ tier: 'free' }} />);
expect(screen.queryByText(/premium feature/i)).not.toBeInTheDocument();
});
it('shows premium content for premium users', () => {
render(<Dashboard user={{ tier: 'premium' }} />);
expect(screen.getByText(/premium feature/i)).toBeInTheDocument();
});Testing Accessibility
Verify ARIA attributes and keyboard navigation:
it('has accessible name', () => {
render(<Button>Submit</Button>);
const button = screen.getByRole('button', { name: /submit/i });
expect(button).toBeInTheDocument();
});
it('supports keyboard navigation', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onPress={handleClick}>Click</Button>);
await user.tab();
expect(screen.getByRole('button')).toHaveFocus();
await user.keyboard('{Enter}');
expect(handleClick).toHaveBeenCalled();
});
it('announces to screen readers', () => {
render(<Alert>Important message</Alert>);
expect(screen.getByRole('alert')).toHaveTextContent('Important message');
});Testing with MSW (Mock Service Worker)
Mock API calls at the network level:
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll } from 'vitest';
const server = setupServer(
http.get('/api/user/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Alice' });
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('fetches user data', async () => {
render(<UserProfile userId="1" />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});Testing Component Variants
Test different prop combinations:
describe('Button variants', () => {
it.each([
{ variant: 'primary', class: 'bg-blue-500' },
{ variant: 'secondary', class: 'bg-gray-500' },
{ variant: 'destructive', class: 'bg-red-500' },
])('renders $variant variant', ({ variant, class: className }) => {
render(<Button variant={variant}>Click</Button>);
expect(screen.getByRole('button')).toHaveClass(className);
});
});Testing Debounced Input
Wait for debounced updates:
it('debounces search input', async () => {
const user = userEvent.setup();
const handleSearch = vi.fn();
render(<SearchInput onSearch={handleSearch} debounce={300} />);
const input = screen.getByRole('textbox');
await user.type(input, 'query');
expect(handleSearch).not.toHaveBeenCalled();
await waitFor(() => expect(handleSearch).toHaveBeenCalledWith('query'), {
timeout: 500,
});
});Testing Focus Management
Verify focus behavior:
it('focuses first input on mount', () => {
render(<LoginForm />);
expect(screen.getByLabelText(/email/i)).toHaveFocus();
});
it('moves focus on submit', async () => {
const user = userEvent.setup();
render(<MultiStepForm />);
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.click(screen.getByRole('button', { name: /next/i }));
expect(screen.getByLabelText(/password/i)).toHaveFocus();
});Debugging Tests
View rendered output:
import { screen } from '@testing-library/react';
screen.debug();
screen.debug(screen.getByRole('button'));
screen.logTestingPlaygroundURL();Custom Matchers
Common jest-dom matchers:
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toHaveTextContent('text');
expect(element).toHaveClass('className');
expect(element).toHaveAttribute('attr', 'value');
expect(element).toHaveFocus();
expect(element).toBeDisabled();
expect(element).toBeEnabled();
expect(element).toBeRequired();
expect(element).toBeValid();
expect(element).toBeInvalid();
expect(element).toHaveValue('value');
expect(element).toBeChecked();Configuration
Basic Configuration
Create vitest.config.ts in your project root:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
},
});Shared Config with Vite
Vitest reads your vite.config.ts by default. Add test-specific config:
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
},
});Separate Test Config
Use conditional config or separate files:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});Or merge configs:
import viteConfig from './vite.config';
import { mergeConfig, defineConfig } from 'vitest/config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
},
}),
);Test Environment
Choose the runtime environment:
export default defineConfig({
test: {
environment: 'node',
},
});Available environments:
node— default, Node.js environment (no DOM)jsdom— JSDOM, simulated browser environmenthappy-dom— Happy DOM, faster alternative to JSDOMedge-runtime— Edge runtime environment
Per-file environment:
/**
* @vitest-environment jsdom
*/
import { render } from '@testing-library/react';Global Test APIs
Enable global test APIs (no imports needed):
export default defineConfig({
test: {
globals: true,
},
});Add TypeScript types in tsconfig.json:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}Now use describe, it, expect without imports.
Setup Files
Run code before tests:
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
},
});Files execute in order. See the test-setup reference for setup file contents — jest-dom matchers, cleanup, MSW server lifecycle, and DOM polyfills.
Coverage Configuration
Configure code coverage:
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'**/*.config.{ts,js}',
'**/*.test.{ts,tsx}',
'**/types.ts',
],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
});Coverage providers:
v8— fast, native V8 coverage (default)istanbul— more detailed reports, slower
Run coverage:
vitest --coverageCoverage Enhancements
V8 coverage is faster but Istanbul handles edge cases like decorators better. Ignore specific lines with /* v8 ignore next */ or /* istanbul ignore next */. Set thresholds.autoUpdate: true to auto-update coverage thresholds as coverage improves:
export default defineConfig({
test: {
coverage: {
thresholds: { autoUpdate: true },
},
},
});Projects Configuration (v3.2+)
Define multiple test projects in a single config. The projects option replaces the deprecated workspace and removed vitest.workspace file.
Client / Server Split
Full-stack apps (TanStack Start, Next.js, Remix) need different environments for client and server code:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: [
{
test: {
name: 'client',
environment: 'jsdom',
include: ['src/**/*.test.tsx'],
setupFiles: ['./vitest.setup.ts'],
},
},
{
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.server.test.ts', 'src/server/**/*.test.ts'],
},
},
],
},
});Client tests get jsdom for DOM APIs and React Testing Library. Server tests get Node for server functions, API routes, and database code — no DOM overhead.
Client / Server / Browser Split
Add a browser project for tests that need a real DOM (canvas, Web Workers, browser-specific APIs):
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
test: {
projects: [
{
test: {
name: 'client',
environment: 'jsdom',
include: ['src/**/*.test.tsx'],
setupFiles: ['./vitest.setup.ts'],
},
},
{
test: {
name: 'server',
environment: 'node',
include: ['src/server/**/*.test.ts'],
},
},
{
test: {
name: 'browser',
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
include: ['src/**/*.browser.test.ts'],
},
},
],
},
});Most component tests belong in the client project with jsdom — it's faster. Reserve the browser project for tests that genuinely need a real browser engine.
Monorepo with Glob Patterns
Point to package directories and each package provides its own vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: ['packages/*'],
},
});Running Projects
vitest --project client
vitest --project server
vitest --project client --project serverLegacy Workspace Migration
If migrating from the deprecated vitest.workspace.js (removed in v4), move workspace entries into vitest.config.ts under test.projects.
Browser Mode
Initialize browser mode quickly:
npx vitest init browserAvailable browser providers:
@vitest/browser-playwright— Chromium, Firefox, WebKit@vitest/browser-webdriverio— Chrome, Edge, Firefox, Safari
See the Projects Configuration section above for browser project setup with playwright().
Reporters
Configure test output:
export default defineConfig({
test: {
reporters: ['default', 'html', 'json'],
outputFile: {
html: './test-results/index.html',
json: './test-results/results.json',
},
},
});Built-in reporters:
default— default CLI outputverbose— detailed outputdot— minimal dot outputjson— JSON outputhtml— HTML reportjunit— JUnit XMLtap— TAP formatgithub-actions— GitHub Actions annotationshanging-process— detect hanging processes
Test Timeout
Set global timeout:
export default defineConfig({
test: {
testTimeout: 10000,
hookTimeout: 10000,
},
});Per-test timeout:
test('slow operation', async () => {
await slowOperation();
}, 30000);Include and Exclude Patterns
Control which files are tested:
export default defineConfig({
test: {
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: [
'node_modules',
'dist',
'.next',
'e2e/**',
'**/*.e2e.{test,spec}.{ts,tsx}',
],
},
});Watch Mode Settings
Configure watch behavior:
export default defineConfig({
test: {
watch: true,
watchExclude: ['**/node_modules/**', '**/dist/**'],
},
});Sequence and Pool
Control test execution order:
export default defineConfig({
test: {
sequence: {
shuffle: true,
concurrent: true,
},
pool: 'threads',
poolOptions: {
threads: {
singleThread: false,
isolate: true,
},
},
},
});Pool options:
threads— worker threads (default)forks— child processesvmThreads— VM context in worker threads
Mock Configuration
Configure module mocking:
export default defineConfig({
test: {
mockReset: true,
restoreMocks: true,
clearMocks: true,
},
});mockReset— reset mocks after each testrestoreMocks— restore spies after each testclearMocks— clear call history after each test
For in-source testing patterns, see the advanced-patterns reference.
Provide / Inject
Share values from config to tests:
export default defineConfig({
test: {
provide: {
apiUrl: 'http://localhost:3000',
},
},
});Access in tests with inject('apiUrl'). See the fixtures-and-context reference for full patterns.
TypeScript Configuration
Add types in tsconfig.json:
{
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
}
}Conditional Config
Different config for CI:
export default defineConfig({
test: {
coverage: { enabled: process.env.CI === 'true' },
reporters: process.env.CI ? ['github-actions'] : ['default'],
},
});Fixtures and Context
Basic test.extend
Define reusable, typed fixtures with automatic setup and teardown:
import { test as base, expect } from 'vitest';
interface TodoFixtures {
db: Database;
todos: TodoService;
}
const test = base.extend<TodoFixtures>({
db: async ({}, use) => {
const db = await createTestDatabase();
await use(db);
await db.close();
},
todos: async ({ db }, use) => {
const service = new TodoService(db);
await use(service);
},
});
test('creates a todo', async ({ todos }) => {
const todo = await todos.create({ title: 'Write tests' });
expect(todo.title).toBe('Write tests');
});
test('lists todos', async ({ todos }) => {
await todos.create({ title: 'First' });
await todos.create({ title: 'Second' });
const all = await todos.list();
expect(all).toHaveLength(2);
});Each test gets a fresh db and todos instance. Teardown runs automatically after use() returns.
Fixture Scoping
Control how often fixtures are created:
Per-Test Scope (Default)
const test = base.extend<{ server: TestServer }>({
server: async ({}, use) => {
const server = await startServer();
await use(server);
await server.close();
},
});A new server is created and destroyed for every test.
Per-File Scope
Share a fixture across all tests in a file:
const test = base.extend<{ server: TestServer }>({
server: [
async ({}, use) => {
const server = await startServer();
await use(server);
await server.close();
},
{ scope: 'file' },
],
});The server starts once before the first test and closes after the last test in the file.
Per-Worker Scope
Share across all files in a worker thread:
const test = base.extend<{ browser: Browser }>({
browser: [
async ({}, use) => {
const browser = await chromium.launch();
await use(browser);
await browser.close();
},
{ scope: 'worker' },
],
});Auto Fixtures
Fixtures with auto: true run for every test without being referenced in test parameters:
const test = base.extend<{ logging: void }>({
logging: [
async ({}, use) => {
console.log('Test starting');
await use();
console.log('Test finished');
},
{ auto: true },
],
});
test('runs with auto logging', () => {
expect(true).toBe(true);
});Combine auto with scoping:
const test = base.extend<{ globalSetup: void }>({
globalSetup: [
async ({}, use) => {
await seedDatabase();
await use();
await cleanDatabase();
},
{ auto: true, scope: 'worker' },
],
});Composing Fixtures
Build fixture layers by extending from an already-extended test:
import { test as base, expect } from 'vitest';
interface AuthFixtures {
auth: AuthService;
token: string;
}
const authTest = base.extend<AuthFixtures>({
auth: async ({}, use) => {
const auth = new AuthService();
await use(auth);
},
token: async ({ auth }, use) => {
const token = await auth.createToken({ userId: 'test-user' });
await use(token);
},
});
interface ApiFixtures {
api: ApiClient;
}
const test = authTest.extend<ApiFixtures>({
api: async ({ token }, use) => {
const client = new ApiClient({ token });
await use(client);
await client.disconnect();
},
});
test('fetches user profile', async ({ api }) => {
const profile = await api.get('/profile');
expect(profile.userId).toBe('test-user');
});The api fixture receives token from the auth layer automatically.
test.override
Override fixture values within a describe block:
import { describe, expect } from 'vitest';
const test = base.extend<{ locale: string }>({
locale: async ({}, use) => {
await use('en-US');
},
});
test('default locale', ({ locale }) => {
expect(locale).toBe('en-US');
});
describe('French locale', () => {
test.override({ locale: 'fr-FR' });
test('uses french locale', ({ locale }) => {
expect(locale).toBe('fr-FR');
});
});test.override must be called at the top level of a describe block. It overrides fixture values for all tests in that suite.
Injected Fixtures from Config
Pass values from vitest.config.ts into tests with provide and inject:
Config Side
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
provide: {
apiBaseUrl: 'http://localhost:3000',
dbConnectionString: 'postgresql://test@localhost/testdb',
},
},
});Declare Types
declare module 'vitest' {
export interface ProvidedContext {
apiBaseUrl: string;
dbConnectionString: string;
}
}Test Side
import { inject, test, expect } from 'vitest';
test('uses injected config', () => {
const baseUrl = inject('apiBaseUrl');
expect(baseUrl).toBe('http://localhost:3000');
});Using Injected Values in Fixtures
import { inject, test as base } from 'vitest';
const test = base.extend<{ api: ApiClient }>({
api: async ({}, use) => {
const baseUrl = inject('apiBaseUrl');
const client = new ApiClient(baseUrl);
await use(client);
},
});Migration from beforeEach
Before: Manual Setup/Teardown
import { afterEach, beforeEach, expect, test } from 'vitest';
let db: Database;
let service: UserService;
beforeEach(async () => {
db = await createTestDatabase();
service = new UserService(db);
});
afterEach(async () => {
await db.close();
});
test('creates user', async () => {
const user = await service.create({ name: 'Alice' });
expect(user.name).toBe('Alice');
});
test('finds user', async () => {
await service.create({ name: 'Bob' });
const found = await service.findByName('Bob');
expect(found).toBeDefined();
});After: Fixtures
import { test as base, expect } from 'vitest';
interface UserFixtures {
db: Database;
users: UserService;
}
const test = base.extend<UserFixtures>({
db: async ({}, use) => {
const db = await createTestDatabase();
await use(db);
await db.close();
},
users: async ({ db }, use) => {
await use(new UserService(db));
},
});
test('creates user', async ({ users }) => {
const user = await users.create({ name: 'Alice' });
expect(user.name).toBe('Alice');
});
test('finds user', async ({ users }) => {
await users.create({ name: 'Bob' });
const found = await users.findByName('Bob');
expect(found).toBeDefined();
});Advantages of fixtures over beforeEach:
- No shared mutable state — each test declares what it needs via parameters
- Automatic teardown — cleanup runs after
use()without separateafterEach - Composable — layer fixtures by extending from other extended tests
- Type-safe — TypeScript infers fixture types from the definition
- Lazy — fixtures only run when a test references them by name
- Scoped — choose per-test, per-file, or per-worker lifecycle
Fixture with Parameterized Tests
Use test.for (not test.each) to combine fixtures with parameterized data. test.for provides TestContext as the second argument, giving access to fixtures:
const test = base.extend<{ parser: Parser }>({
parser: async ({}, use) => {
const parser = new Parser({ strict: true });
await use(parser);
},
});
test.for([
{ input: '42', expected: 42 },
{ input: '3.14', expected: 3.14 },
{ input: '-1', expected: -1 },
])('parses "$input"', ({ input, expected }, { parser }) => {
expect(parser.parse(input)).toBe(expected);
});test.each does not support fixture context — always use test.for when combining parameterized data with fixtures.
Hook Testing
Basic Hook Testing
Test custom hooks with renderHook:
import { renderHook } from '@testing-library/react';
import { expect, test } from 'vitest';
import { useCounter } from './useCounter';
test('increments counter', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
result.current.increment();
expect(result.current.count).toBe(1);
});result.current contains the return value of the hook.
Testing Hook Updates
Use act when state updates are needed:
import { renderHook, waitFor } from '@testing-library/react';
test('updates count', async () => {
const { result } = renderHook(() => useCounter());
result.current.increment();
await waitFor(() => {
expect(result.current.count).toBe(1);
});
});Re-rendering Hooks with New Props
Update hook props between renders:
test('updates when props change', () => {
const { result, rerender } = renderHook(({ step }) => useCounter(step), {
initialProps: { step: 1 },
});
expect(result.current.count).toBe(0);
result.current.increment();
expect(result.current.count).toBe(1);
rerender({ step: 5 });
result.current.increment();
expect(result.current.count).toBe(6);
});Testing Async Hooks
Wait for async operations to complete:
import { renderHook, waitFor } from '@testing-library/react';
test('fetches data', async () => {
const { result } = renderHook(() => useFetchUser('1'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ id: '1', name: 'Alice' });
});Testing Hooks with Context
Provide context to hooks:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook } from '@testing-library/react';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
test('uses query client', async () => {
const { result } = renderHook(() => useUserQuery('1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.name).toBe('Alice');
});Testing TanStack Query Hooks
Test hooks that use useQuery:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest';
const server = setupServer(
http.get('/api/user/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Alice' });
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('fetches user with useQuery', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUserQuery('1'), { wrapper });
expect(result.current.isPending).toBe(true);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual({ id: '1', name: 'Alice' });
});Testing Query Refetch
Verify refetch behavior:
test('refetches on demand', async () => {
const { result } = renderHook(() => useUserQuery('1'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.name).toBe('Alice');
server.use(
http.get('/api/user/:id', () => {
return HttpResponse.json({ id: '1', name: 'Bob' });
}),
);
await result.current.refetch();
expect(result.current.data?.name).toBe('Bob');
});Testing Mutations
Test useMutation hooks:
test('creates user with mutation', async () => {
server.use(
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '2', ...body }, { status: 201 });
}),
);
const { result } = renderHook(() => useCreateUserMutation(), {
wrapper: createWrapper(),
});
result.current.mutate({ name: 'Bob' });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual({ id: '2', name: 'Bob' });
});Testing Optimistic Updates
Verify optimistic UI behavior:
test('updates optimistically', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
queryClient.setQueryData(['users'], [{ id: '1', name: 'Alice' }]);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUpdateUserMutation(), { wrapper });
result.current.mutate({ id: '1', name: 'Bob' });
const cachedData = queryClient.getQueryData<
Array<{ id: string; name: string }>
>(['users']);
expect(cachedData?.[0]?.name).toBe('Bob');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});Testing TanStack Form Hooks
Test form validation and submission:
import { renderHook, waitFor } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { useForm } from '@tanstack/react-form';
test('validates form', async () => {
const { result } = renderHook(() =>
useForm({
defaultValues: { email: '', password: '' },
onSubmit: vi.fn(),
}),
);
const emailField = result.current.Field({
name: 'email',
validators: {
onChange: ({ value }) =>
value.includes('@') ? undefined : 'Invalid email',
},
});
emailField.handleChange('invalid');
await waitFor(() => {
expect(emailField.state.meta.errors).toContain('Invalid email');
});
emailField.handleChange('valid@example.com');
await waitFor(() => {
expect(emailField.state.meta.errors).toHaveLength(0);
});
});Testing Hook Cleanup
Verify cleanup functions run:
test('cleans up on unmount', () => {
const cleanup = vi.fn();
const useTestHook = () => {
useEffect(() => {
return cleanup;
}, []);
};
const { unmount } = renderHook(() => useTestHook());
expect(cleanup).not.toHaveBeenCalled();
unmount();
expect(cleanup).toHaveBeenCalledTimes(1);
});Testing Hook Dependencies
Verify effects run on dependency changes:
test('effect runs on dependency change', () => {
const effect = vi.fn();
const useTestHook = (dep: number) => {
useEffect(() => {
effect(dep);
}, [dep]);
};
const { rerender } = renderHook(({ dep }) => useTestHook(dep), {
initialProps: { dep: 1 },
});
expect(effect).toHaveBeenCalledWith(1);
rerender({ dep: 2 });
expect(effect).toHaveBeenCalledWith(2);
expect(effect).toHaveBeenCalledTimes(2);
});Testing Debounced Hooks
Wait for debounced updates:
import { renderHook, waitFor } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
test('debounces value updates', async () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 300),
{ initialProps: { value: 'initial' } },
);
expect(result.current).toBe('initial');
rerender({ value: 'updated' });
expect(result.current).toBe('initial');
await waitFor(() => expect(result.current).toBe('updated'), {
timeout: 500,
});
});Testing Error Handling in Hooks
Verify error states:
test('handles errors', async () => {
server.use(
http.get('/api/user/:id', () => {
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
}),
);
const { result } = renderHook(() => useUserQuery('999'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error?.message).toContain('404');
});Testing Suspense Hooks
Test hooks that suspend:
import { Suspense } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
test('suspends while loading', async () => {
const { result } = renderHook(
() => useSuspenseQuery({ queryKey: ['user', '1'], queryFn }),
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
</QueryClientProvider>
),
},
);
await waitFor(() => {
expect(result.current.data).toBeDefined();
});
});Testing Multiple Hook Instances
Verify hook instances are independent:
test('maintains separate state per instance', () => {
const { result: result1 } = renderHook(() => useCounter());
const { result: result2 } = renderHook(() => useCounter());
result1.current.increment();
expect(result1.current.count).toBe(1);
expect(result2.current.count).toBe(0);
});Mocking
Mock Functions with vi.fn
Create spy functions to track calls and control return values:
import { expect, test, vi } from 'vitest';
test('tracks function calls', () => {
const mockFn = vi.fn();
mockFn('arg1', 'arg2');
mockFn('arg3');
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg3');
});Mock Return Values
Control what mocks return:
const mockFn = vi.fn();
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);
mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(3);
expect(mockFn()).toBe(1);
expect(mockFn()).toBe(2);
expect(mockFn()).toBe(3);
expect(mockFn()).toBe(3);Mock Async Functions
Return promises from mocks:
const mockFetch = vi.fn();
mockFetch.mockResolvedValue({ data: 'success' });
await expect(mockFetch()).resolves.toEqual({ data: 'success' });
mockFetch.mockRejectedValue(new Error('Network error'));
await expect(mockFetch()).rejects.toThrow('Network error');Mock Implementation
Replace function logic:
const mockFn = vi.fn();
mockFn.mockImplementation((a, b) => a + b);
expect(mockFn(2, 3)).toBe(5);
mockFn
.mockImplementationOnce((a, b) => a * b)
.mockImplementation((a, b) => a - b);
expect(mockFn(2, 3)).toBe(6);
expect(mockFn(5, 3)).toBe(2);Module Mocking
Replace entire modules with mocks:
import { expect, test, vi } from 'vitest';
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Alice' }),
updateUser: vi.fn().mockResolvedValue({ success: true }),
}));
import { fetchUser, updateUser } from './api';
test('uses mocked API', async () => {
const user = await fetchUser('1');
expect(user.name).toBe('Alice');
await updateUser('1', { name: 'Bob' });
expect(updateUser).toHaveBeenCalledWith('1', { name: 'Bob' });
});vi.mock calls are hoisted to the top of the file, so they execute before imports.
Hoisted Mock Variables
vi.mock factories are hoisted above imports. Variables declared outside the factory aren't accessible inside it. vi.hoisted() runs in the hoisted scope so returned values can be used in mock factories:
import { vi } from 'vitest';
const { mockFetch } = vi.hoisted(() => ({
mockFetch: vi.fn(),
}));
vi.mock('./api', () => ({
fetchUser: mockFetch,
}));
import { fetchUser } from './api';
test('uses hoisted mock', async () => {
mockFetch.mockResolvedValue({ id: '1', name: 'Alice' });
const user = await fetchUser('1');
expect(user.name).toBe('Alice');
});Partial Module Mocking
Mock some exports while keeping others:
vi.mock('./utils', async () => {
const actual = await vi.importActual<typeof import('./utils')>('./utils');
return {
...actual,
fetchData: vi.fn().mockResolvedValue('mocked data'),
};
});
import { fetchData, formatData } from './utils';
test('mocks fetchData but keeps formatData', async () => {
expect(await fetchData()).toBe('mocked data');
expect(formatData('test')).toBe('TEST');
});Spying on Methods
Track calls to existing object methods:
import { expect, test, vi } from 'vitest';
const calculator = {
add: (a: number, b: number) => a + b,
};
test('spies on method', () => {
const spy = vi.spyOn(calculator, 'add');
const result = calculator.add(2, 3);
expect(result).toBe(5);
expect(spy).toHaveBeenCalledWith(2, 3);
spy.mockRestore();
});Spy Without Calling Original
Replace implementation while spying:
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
console.log('This will not print');
expect(spy).toHaveBeenCalledWith('This will not print');
spy.mockRestore();Auto-Mocking
Automatically mock all module exports:
vi.mock('./api');
import * as api from './api';
test('auto-mocks all exports', () => {
expect(vi.isMockFunction(api.fetchUser)).toBe(true);
expect(vi.isMockFunction(api.updateUser)).toBe(true);
});Auto-mocked functions return undefined by default. Configure them in your tests:
vi.mocked(api.fetchUser).mockResolvedValue({ id: '1', name: 'Alice' });Spy-Only Module Mocking (v2.1+)
Spy on all exports without replacing them. Useful in browser mode where module objects are sealed:
import { vi } from 'vitest';
import * as math from './math';
vi.mock('./math', { spy: true });
test('spies on real implementation', () => {
expect(math.add(1, 2)).toBe(3);
expect(vi.mocked(math.add)).toHaveBeenCalledWith(1, 2);
vi.mocked(math.add).mockReturnValue(0);
expect(math.add(1, 2)).toBe(0);
});Mocking Classes
Mock class constructors and methods:
vi.mock('./Database', () => {
const Database = vi.fn();
Database.prototype.query = vi.fn().mockResolvedValue([]);
Database.prototype.insert = vi.fn().mockResolvedValue({ id: '1' });
return { Database };
});
import { Database } from './Database';
test('mocks class', async () => {
const db = new Database();
const result = await db.query('SELECT * FROM users');
expect(result).toEqual([]);
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
});vi.mocked Helper
Type-safe access to mock methods:
import { vi } from 'vitest';
vi.mock('./api');
import { fetchUser } from './api';
vi.mocked(fetchUser).mockResolvedValue({ id: '1', name: 'Alice' });
test('typed mock', async () => {
const user = await fetchUser('1');
expect(user.name).toBe('Alice');
});vi.mocked provides proper TypeScript types for mock methods.
Clearing and Resetting Mocks
Manage mock state between tests:
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
describe('mock management', () => {
const mockFn = vi.fn();
beforeEach(() => {
mockFn.mockReturnValue('default');
});
afterEach(() => {
vi.clearAllMocks();
});
test('uses mock', () => {
mockFn();
expect(mockFn).toHaveBeenCalledTimes(1);
});
test('has clean mock', () => {
expect(mockFn).not.toHaveBeenCalled();
});
});Mock management methods:
vi.clearAllMocks()— clears call history but keeps implementationsvi.resetAllMocks()— clears history and resets implementationsvi.restoreAllMocks()— restores original implementations for spiesmockFn.mockClear()— clears history for specific mockmockFn.mockReset()— clears history and resets implementationmockFn.mockRestore()— restores original for specific spy
Fake Timers
Control time-based functions:
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('advances timers', () => {
const callback = vi.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
});
test('runs all timers', () => {
const callback = vi.fn();
setTimeout(callback, 1000);
setTimeout(callback, 2000);
vi.runAllTimers();
expect(callback).toHaveBeenCalledTimes(2);
});Timer control methods:
vi.advanceTimersByTime(ms)— advance by specific durationvi.advanceTimersToNextTimer()— advance to next scheduled timervi.runAllTimers()— run all pending timersvi.runOnlyPendingTimers()— run timers scheduled before the callvi.clearAllTimers()— clear all pending timersvi.setSystemTime(date)— set current date for Date.now() and new Date()
Mocking Date
Control Date.now() and new Date():
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01'));
});
afterEach(() => {
vi.useRealTimers();
});
test('uses mocked date', () => {
expect(Date.now()).toBe(new Date('2024-01-01').getTime());
expect(new Date().toISOString()).toBe('2024-01-01T00:00:00.000Z');
vi.setSystemTime(new Date('2024-01-02'));
expect(new Date().toISOString()).toBe('2024-01-02T00:00:00.000Z');
});Async Waiting Utilities
vi.waitFor retries a callback until it stops throwing:
await vi.waitFor(() => {
expect(element.textContent).toBe('loaded');
});vi.waitUntil retries until the callback returns a truthy value:
const result = await vi.waitUntil(() => fetchStatus());Both accept options: { timeout: 5000, interval: 100 }.
Mock Assertions
Verify mock behavior:
const mockFn = vi.fn();
mockFn('a', 'b');
mockFn('c', 'd');
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('a', 'b');
expect(mockFn).toHaveBeenLastCalledWith('c', 'd');
expect(mockFn).toHaveBeenNthCalledWith(1, 'a', 'b');
expect(mockFn).toHaveBeenNthCalledWith(2, 'c', 'd');
expect(mockFn.mock.calls).toEqual([
['a', 'b'],
['c', 'd'],
]);
expect(mockFn.mock.results).toEqual([
{ type: 'return', value: undefined },
{ type: 'return', value: undefined },
]);Mocking Environment Variables
Override process.env:
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
beforeEach(() => {
vi.stubEnv('NODE_ENV', 'test');
vi.stubEnv('API_KEY', 'test-key');
});
afterEach(() => {
vi.unstubAllEnvs();
});
test('uses stubbed env vars', () => {
expect(process.env.NODE_ENV).toBe('test');
expect(process.env.API_KEY).toBe('test-key');
});Mocking Globals
Replace global objects with vi.stubGlobal:
import { afterEach, expect, test, vi } from 'vitest';
afterEach(() => {
vi.unstubAllGlobals();
});
test('stubs fetch', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: 'mocked' }),
}),
);
const response = await fetch('/api/data');
expect(await response.json()).toEqual({ data: 'mocked' });
});Unmocking Modules
Restore original module implementation:
vi.mock('./api');
import { fetchUser } from './api';
test('uses mock', () => {
expect(vi.isMockFunction(fetchUser)).toBe(true);
});
vi.unmock('./api');
test('uses real implementation', async () => {
expect(vi.isMockFunction(fetchUser)).toBe(false);
});vi.unmock is hoisted like vi.mock, so it affects the entire file scope.
Test Fundamentals
Test Structure
Organize tests with describe blocks and individual test cases with test or it:
import { describe, expect, it, test } from 'vitest';
describe('Calculator', () => {
it('adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('subtracts two numbers', () => {
expect(subtract(5, 3)).toBe(2);
});
});Both test and it are aliases. Use whichever reads better for your test descriptions.
Nested Describe Blocks
Group related tests hierarchically:
describe('User authentication', () => {
describe('login', () => {
it('succeeds with valid credentials', () => {
expect(login('user', 'pass')).toBe(true);
});
it('fails with invalid credentials', () => {
expect(login('user', 'wrong')).toBe(false);
});
});
describe('logout', () => {
it('clears session', () => {
logout();
expect(getSession()).toBeNull();
});
});
});Basic Assertions
Common matchers for different value types:
expect(2 + 2).toBe(4);
expect(user.name).toBe('Alice');
expect({ name: 'Alice' }).toEqual({ name: 'Alice' });
expect([1, 2, 3]).toEqual([1, 2, 3]);
expect(result).toBeTruthy();
expect(result).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
expect('hello world').toContain('world');
expect([1, 2, 3]).toContain(2);
expect(number).toBeGreaterThan(5);
expect(number).toBeGreaterThanOrEqual(5);
expect(number).toBeLessThan(10);
expect(number).toBeLessThanOrEqual(10);
expect(() => throwError()).toThrow();
expect(() => throwError()).toThrow('Error message');
expect(() => throwError()).toThrow(CustomError);Use toBe for primitive values (numbers, strings, booleans). Use toEqual for objects and arrays (deep equality).
Async Assertions
Test promises and async functions:
test('resolves to value', async () => {
await expect(fetchData()).resolves.toBe('data');
});
test('rejects with error', async () => {
await expect(fetchData()).rejects.toThrow('Network error');
});
test('async/await pattern', async () => {
const result = await fetchData();
expect(result).toBe('data');
});Lifecycle Hooks
Set up and tear down test state:
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
describe('Database operations', () => {
let db: Database;
beforeEach(async () => {
db = await createTestDatabase();
await db.seed();
});
afterEach(async () => {
await db.cleanup();
});
it('inserts record', async () => {
await db.insert({ name: 'Alice' });
expect(await db.count()).toBe(1);
});
it('deletes record', async () => {
await db.insert({ name: 'Alice' });
await db.delete({ name: 'Alice' });
expect(await db.count()).toBe(0);
});
});Lifecycle hooks:
beforeEachruns before each testafterEachruns after each testbeforeAllruns once before all tests in a describe blockafterAllruns once after all tests in a describe block
Suite-Level Hooks
Run expensive setup once per suite:
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('API integration', () => {
let server: TestServer;
beforeAll(async () => {
server = await startTestServer();
});
afterAll(async () => {
await server.stop();
});
it('responds to GET /users', async () => {
const response = await fetch(`${server.url}/users`);
expect(response.ok).toBe(true);
});
});Parameterized Tests with test.each
Run the same test with different inputs:
test.each([
{ a: 1, b: 1, expected: 2 },
{ a: 1, b: 2, expected: 3 },
{ a: 2, b: 1, expected: 3 },
])('adds $a + $b to equal $expected', ({ a, b, expected }) => {
expect(a + b).toBe(expected);
});Using array syntax:
test.each([
[1, 1, 2],
[1, 2, 3],
[2, 1, 3],
])('adds %i + %i to equal %i', (a, b, expected) => {
expect(a + b).toBe(expected);
});Printf formatting tokens: %s (string), %d (number), %i (integer), %f (float), %j (JSON), %o (object), %% (literal percent).
Test Modifiers
Control which tests run:
test.skip('not ready yet', () => {});
test.only('debug this test', () => {
expect(debugFeature()).toBe(true);
});
test.todo('implement later');
test.skipIf(process.env.CI === 'true')('local only', () => {});
test.runIf(process.platform === 'darwin')('macOS only', () => {});Modifiers work on describe blocks too:
describe.skip('entire suite', () => {
it('will not run', () => {});
});
describe.only('focus on this suite', () => {
it('will run', () => {});
});Concurrent Tests
Run tests in parallel for faster execution:
import { describe, expect, test } from 'vitest';
describe.concurrent('parallel suite', () => {
test('test 1', async () => {
await slowOperation();
expect(result).toBe(true);
});
test('test 2', async () => {
await slowOperation();
expect(result).toBe(true);
});
});Or mark individual tests as concurrent:
test.concurrent('parallel test 1', async () => {
await slowOperation();
});
test.concurrent('parallel test 2', async () => {
await slowOperation();
});Concurrent tests require async functions. Use test.sequential inside a concurrent suite to force specific tests to run sequentially.
Test Context
Access test metadata via the context parameter. For reusable setup, prefer test.extend fixtures over manual context manipulation — see the fixtures-and-context reference.
Assertions with Custom Messages
Add context to assertion failures:
expect(result, 'Expected user to be authenticated').toBe(true);
expect(response.status, `API returned ${response.status}`).toBe(200);Negation
Invert any matcher with .not:
expect(value).not.toBe(null);
expect(array).not.toContain(item);
expect(fn).not.toThrow();Soft Assertions
Soft assertions continue executing after failure and collect all failures. The test reports all failing expectations at once instead of stopping at the first failure:
import { expect, test } from 'vitest';
test('validates all fields', () => {
const user = getUser();
expect.soft(user.name).toBe('Alice');
expect.soft(user.email).toContain('@');
expect.soft(user.age).toBeGreaterThan(0);
});Poll Assertions
expect.poll() retries the callback until the assertion passes or times out. Useful for testing async state changes without manual retry loops:
test('waits for value', async () => {
await expect.poll(() => fetchStatus()).toBe('ready');
await expect
.poll(() => getCount(), { interval: 100, timeout: 5000 })
.toBeGreaterThan(0);
});In-Test Cleanup Hooks
onTestFinished runs after each test completes (pass or fail) — useful for cleanup without afterEach. onTestFailed runs only on failure — useful for diagnostics. Both are scoped to the current test:
import { expect, onTestFailed, onTestFinished, test } from 'vitest';
test('with cleanup', () => {
const resource = acquireResource();
onTestFinished(() => {
resource.release();
});
onTestFailed((result) => {
console.log('Failed:', result.errors);
});
expect(resource.isActive()).toBe(true);
});Type Assertions
Test TypeScript types at compile time:
import { expectTypeOf } from 'vitest';
expectTypeOf({ a: 1 }).toEqualTypeOf<{ a: number }>();
expectTypeOf('test').toBeString();
expectTypeOf(123).toBeNumber();
expectTypeOf<Promise<string>>().resolves.toBeString();Asymmetric Matchers
Match partial values in assertions:
expect(user).toEqual({
id: expect.any(String),
name: 'Alice',
createdAt: expect.any(Date),
});
expect(response).toMatchObject({
status: 200,
data: expect.objectContaining({
userId: '123',
}),
});
expect(array).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'Alice' })]),
);
expect(url).toMatch(/^https?:\/\//);
expect(email).toMatch(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/);Test Setup
Setup files run before your tests via the setupFiles config option. They configure the test environment — extending matchers, registering cleanup, stubbing browser APIs, and bootstrapping mock servers.
jest-dom Matchers
Extend expect with DOM-specific matchers via @testing-library/jest-dom:
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';The /vitest entrypoint auto-extends Vitest's expect. Using the bare @testing-library/jest-dom import targets Jest and won't wire up correctly.
Add the types in tsconfig.json:
{
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
}
}Available matchers after setup:
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toHaveTextContent('text');
expect(element).toHaveTextContent(/regex/);
expect(element).toHaveClass('active');
expect(element).toHaveAttribute('aria-label', 'Close');
expect(element).toHaveFocus();
expect(element).toBeDisabled();
expect(element).toBeEnabled();
expect(element).toBeRequired();
expect(element).toBeValid();
expect(element).toBeInvalid();
expect(element).toHaveValue('input value');
expect(element).toBeChecked();
expect(element).toHaveStyle({ color: 'red' });
expect(element).toHaveAccessibleName('Submit form');
expect(element).toHaveAccessibleDescription('Submits the contact form');
expect(element).toHaveRole('button');
expect(element).toBeEmptyDOMElement();
expect(element).toContainElement(child);
expect(form).toHaveFormValues({ email: 'a@b.com', terms: true });React Testing Library Cleanup
React Testing Library auto-cleans up after each test in most frameworks. For Vitest, register cleanup explicitly:
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});Without cleanup, rendered components leak between tests, causing stale queries and memory issues.
Custom Render with Providers
Create a reusable render function that wraps components with common providers:
// test/utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, type RenderOptions } from '@testing-library/react';
import { type ReactElement } from 'react';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
interface ProviderOptions {
queryClient?: QueryClient;
}
function AllProviders({
children,
queryClient = createTestQueryClient(),
}: ProviderOptions & { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
{ queryClient, ...renderOptions }: ProviderOptions & RenderOptions = {},
) {
return render(ui, {
wrapper: ({ children }) => (
<AllProviders queryClient={queryClient}>{children}</AllProviders>
),
...renderOptions,
});
}
export { createTestQueryClient };Use in tests:
import { renderWithProviders } from '../test/utils';
it('renders user list', async () => {
renderWithProviders(<UserList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});Multiple Providers
Stack additional providers as your app requires:
import { MemoryRouter } from 'react-router-dom';
interface ProviderOptions {
queryClient?: QueryClient;
initialRoute?: string;
}
function AllProviders({
children,
queryClient = createTestQueryClient(),
initialRoute = '/',
}: ProviderOptions & { children: React.ReactNode }) {
return (
<MemoryRouter initialEntries={[initialRoute]}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</MemoryRouter>
);
}MSW Server Setup
Configure Mock Service Worker for API mocking across tests:
// test/server.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);
}),
];
export const server = setupServer(...handlers);Register the server lifecycle in the setup file:
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './test/server';
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => {
server.close();
});onUnhandledRequest: 'error' catches unhandled API calls — preventing silent network requests in tests.
Override handlers per test:
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
it('handles server error', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ error: 'Internal error' }, { status: 500 });
}),
);
renderWithProviders(<UserList />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});Global Mock Resets
Configure automatic mock cleanup in the setup file instead of per-test afterEach:
// vitest.setup.ts
import { afterEach } from 'vitest';
import { vi } from 'vitest';
afterEach(() => {
vi.restoreAllMocks();
});Or configure globally in vitest.config.ts:
export default defineConfig({
test: {
restoreMocks: true,
},
});The config approach is preferred — it covers all tests without a setup file import. restoreMocks restores spies and clears vi.fn() implementations after each test.
DOM Polyfills
Browser APIs missing from jsdom/happy-dom need stubs in the setup file:
// vitest.setup.ts
// matchMedia — required by responsive components
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});// ResizeObserver — required by virtualized lists, charts, auto-sizing
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub);// IntersectionObserver — required by lazy loading, infinite scroll
class IntersectionObserverStub {
readonly root = null;
readonly rootMargin = '';
readonly thresholds: readonly number[] = [];
observe() {}
unobserve() {}
disconnect() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}
vi.stubGlobal('IntersectionObserver', IntersectionObserverStub);// scrollTo — jsdom doesn't implement scroll methods
Element.prototype.scrollTo = vi.fn();
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;jsdom vs happy-dom
Both provide a DOM environment for testing, but with different tradeoffs:
| Feature | jsdom | happy-dom |
|---|---|---|
| Speed | Slower | 2-3x faster |
| Spec compliance | Higher | Lower |
canvas support | Via canvas package | Built-in (basic) |
fetch support | Node 18+ built-in | Built-in |
| CSS parsing | Limited | Limited |
| Community | Larger, more battle-tested | Growing |
Use jsdom when spec compliance matters (accessibility testing, complex DOM manipulation). Use happy-dom when test speed is the priority and you don't hit edge cases.
Switch per-file when needed:
/**
* @vitest-environment happy-dom
*/Multiple Setup Files
Split setup files by concern when different test types need different environments:
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts', './vitest.setup.msw.ts'],
},
});Files execute in order. Common pattern:
vitest.setup.ts— jest-dom, cleanup, polyfills (always needed)vitest.setup.msw.ts— MSW server lifecycle (API tests only)
For project-scoped setup, use the projects config:
export default defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
include: ['src/**/*.test.ts'],
setupFiles: ['./vitest.setup.ts'],
},
},
{
test: {
name: 'integration',
include: ['src/**/*.integration.test.ts'],
setupFiles: ['./vitest.setup.ts', './vitest.setup.msw.ts'],
},
},
],
},
});Complete Setup File
A typical setup file combining the common patterns:
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { server } from './test/server';
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => {
server.close();
});
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});