Vitest Testing
This skill makes an AI agent write and configure Vitest test suites: a correct vitest.config.ts, module mocking with vi.mock and the vi.hoisted escape hatch, spies and fake timers, inline snapshots, V8 coverage gates, and projects config for monorepos. Trigger it on any Vite-based project, any repo with vitest in devDependencies, or when migrating from Jest.
Core Principles
- Vitest reuses your Vite config — do not duplicate resolution logic. Aliases, plugins, and transforms from
vite.config.tsapply to tests automatically. A separate Babel/transform setup is a Jest habit; drop it. vi.mockis hoisted; factory variables are not. The mock factory runs before imports, so referencing top-level variables inside it throws. Usevi.hoisted()when the factory needs shared handles.- Prefer
vi.fninjected via parameters overvi.mockof whole modules. Module mocking is a sledgehammer; dependency injection keeps tests honest and refactor-safe. - Inline snapshots over file snapshots for small values.
toMatchInlineSnapshotputs the expectation in the test where reviewers see it; file snapshots get blindly--updated. - Coverage thresholds live in config and fail the run. A coverage report nobody gates on is wallpaper. Gate
lines,functions, andbranches— branch coverage is where the bugs hide. - Use the default
nodeenvironment unless you render DOM.jsdom/happy-domcost startup time per file; set them per-file with a docblock, not globally.
Setup
npm install --save-dev vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false, // explicit imports; keeps files greppable and TS-clean
environment: 'node',
include: ['src/**/*.test.ts'],
setupFiles: ['./test/setup.ts'],
restoreMocks: true, // undo spy implementations between tests
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
include: ['src/**'],
exclude: ['src/**/*.test.ts', 'src/types/**', 'src/main.ts'],
thresholds: {
lines: 85,
functions: 85,
branches: 75,
statements: 85,
},
},
},
});
// package.json scripts
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
Watch mode is the default vitest command and only reruns tests affected by the changed module graph — keep it running while developing.
Patterns
vi.fn, Spies, and Dependency Injection
// src/notifier.ts
export type SendEmail = (to: string, subject: string) => Promise<void>;
export async function notifyOnFailure(
jobName: string,
failures: number,
sendEmail: SendEmail,
): Promise<boolean> {
if (failures === 0) return false;
await sendEmail('oncall@example.com', `${jobName} failed ${failures} times`);
return true;
}
// src/notifier.test.ts
import { describe, expect, it, vi } from 'vitest';
import { notifyOnFailure } from './notifier';
describe('notifyOnFailure', () => {
it('emails oncall with the failure count in the subject', async () => {
const sendEmail = vi.fn().mockResolvedValue(undefined);
const sent = await notifyOnFailure('nightly-sync', 3, sendEmail);
expect(sent).toBe(true);
expect(sendEmail).toHaveBeenCalledExactlyOnceWith(
'oncall@example.com',
'nightly-sync failed 3 times',
);
});
it('stays silent when there are no failures', async () => {
const sendEmail = vi.fn();
await expect(notifyOnFailure('nightly-sync', 0, sendEmail)).resolves.toBe(false);
expect(sendEmail).not.toHaveBeenCalled();
});
});
vi.mock with vi.hoisted (the hoisting trap, solved)
import { beforeEach, expect, it, vi } from 'vitest';
import { getInvoice } from './invoice-service';
// Factory is hoisted above imports — capture handles via vi.hoisted
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
vi.mock('./billing-client', () => ({
fetchInvoice: fetchMock,
}));
beforeEach(() => {
fetchMock.mockReset();
});
it('retries once on a 503 from the billing client', async () => {
fetchMock
.mockRejectedValueOnce(new Error('503 Service Unavailable'))
.mockResolvedValueOnce({ id: 'inv_42', total: 1999 });
const invoice = await getInvoice('inv_42');
expect(invoice.total).toBe(1999);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
Partial mocks keep the rest of a module real:
vi.mock('./config', async (importOriginal) => {
const actual = await importOriginal<typeof import('./config')>();
return { ...actual, isFeatureEnabled: vi.fn().mockReturnValue(true) };
});
Fake Timers
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { debounce } from './debounce';
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('fires once after the trailing edge of 300ms', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
vi.advanceTimersByTime(299);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(fn).toHaveBeenCalledTimes(1);
});
Snapshots and Error Assertions
import { expect, it } from 'vitest';
import { formatReport, parseDuration } from './report';
it('formats a compact summary line', () => {
expect(formatReport({ passed: 12, failed: 1, skipped: 2 })).toMatchInlineSnapshot(
`"12 passed | 1 failed | 2 skipped"`,
);
});
it('throws a typed error on malformed durations', () => {
expect(() => parseDuration('5parsecs')).toThrowErrorMatchingInlineSnapshot(
`[RangeError: unknown duration unit "parsecs"]`,
);
});
Monorepo Projects and In-Source Tests
// vitest.config.ts at the monorepo root
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: [
{ test: { name: 'shared', root: './packages/shared', environment: 'node' } },
{ test: { name: 'web', root: './packages/web', environment: 'jsdom' } },
],
},
});
vitest run --project shared # one package
vitest run # everything, parallelized
In-source tests for small internal utilities (stripped from production builds by define: { 'import.meta.vitest': 'undefined' }):
// src/slug.ts
export function slugify(input: string): string {
return input.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
if (import.meta.vitest) {
const { expect, it } = import.meta.vitest;
it('collapses punctuation runs into single hyphens', () => {
expect(slugify(' Hello, World! ')).toBe('hello-world');
});
}
Best Practices
- Set
restoreMocks: trueglobally instead of sprinklingvi.restoreAllMocks()in everyafterEach. - Use
vitest related src/pricing.tsin pre-commit hooks to run only tests touching changed files. - Assert promise rejections with
await expect(p).rejects.toThrow(...)— a bareexpect(p).rejectswithout await can pass before settlement. - Pin the environment per file when only some tests need DOM:
// @vitest-environment jsdomat the top of the file. - Prefer
test.eachfor input tables over copy-pasted tests; each row reports as its own case. - When migrating from Jest:
vireplacesjest,vi.mockfactories must return the module shape explicitly (no automock), andjest.requireActualbecomesimportOriginal.
Anti-Patterns
- Referencing top-level variables inside a
vi.mockfactory. Hoisting makes themundefinedat factory time — the error message mentions hoisting, believe it. Usevi.hoisted. globals: trueplus missing TS types. If you enable globals, add"types": ["vitest/globals"]to tsconfig, or imports break silently in editors.- Giant
.toMatchSnapshot()on full API responses. Hundred-line snapshots get rubber-stamp updated. Snapshot small, stable slices; assert dynamic fields with matchers. vi.mockof the module under test. You end up testing your own mock. Mock dependencies, never the subject.- Forgetting
vi.useRealTimers()cleanup — fake timers leak into later tests and hang anything that genuinely waits. - Re-implementing Vite aliases inside
test.aliaswhen they already exist invite.config.ts; drift between the two breaks resolution in tests only.
When to Trigger This Skill
- The project is Vite-based or has
vitestindevDependencies. - The user asks to add unit tests, mock a module, fake timers, or snapshot output in a TS/JS repo without Jest.
- Setting up coverage gates or monorepo test projects with package-specific environments.
- Migrating a Jest suite to Vitest (jest → vi API mapping, mock factory differences).
- Tests fail with hoisting errors, environment mismatches, or leaking mocks — the classic Vitest misconfigurations.