| name | Vitest Testing |
| description | Write fast unit and integration tests with Vitest — vitest.config.ts setup, vi.fn and vi.mock module mocking, fake timers, snapshots, V8 coverage with thresholds, workspaces for monorepos, and in-source testing. |
| version | 1.0.0 |
| author | thetestingacademy |
| license | MIT |
| tags | ["vitest","unit-testing","mocking","snapshots","coverage","vite","typescript","monorepo","watch-mode"] |
| testingTypes | ["unit","integration","mocking"] |
| frameworks | ["vitest","vite"] |
| languages | ["typescript","javascript"] |
| domains | ["web","api"] |
| agents | ["claude-code","cursor","github-copilot","windsurf","codex","aider","continue","cline","zed","bolt","gemini-cli","amp"] |
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.ts apply to tests automatically. A separate Babel/transform setup is a Jest habit; drop it.
vi.mock is hoisted; factory variables are not. The mock factory runs before imports, so referencing top-level variables inside it throws. Use vi.hoisted() when the factory needs shared handles.
- Prefer
vi.fn injected via parameters over vi.mock of whole modules. Module mocking is a sledgehammer; dependency injection keeps tests honest and refactor-safe.
- Inline snapshots over file snapshots for small values.
toMatchInlineSnapshot puts 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, and branches — branch coverage is where the bugs hide.
- Use the default
node environment unless you render DOM. jsdom/happy-dom cost startup time per file; set them per-file with a docblock, not globally.
Setup
npm install --save-dev vitest @vitest/coverage-v8
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['src/**/*.test.ts'],
setupFiles: ['./test/setup.ts'],
restoreMocks: true,
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,
},
},
},
});
{
"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
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;
}
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';
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
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
vitest run
In-source tests for small internal utilities (stripped from production builds by define: { 'import.meta.vitest': 'undefined' }):
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: true globally instead of sprinkling vi.restoreAllMocks() in every afterEach.
- Use
vitest related src/pricing.ts in pre-commit hooks to run only tests touching changed files.
- Assert promise rejections with
await expect(p).rejects.toThrow(...) — a bare expect(p).rejects without await can pass before settlement.
- Pin the environment per file when only some tests need DOM:
// @vitest-environment jsdom at the top of the file.
- Prefer
test.each for input tables over copy-pasted tests; each row reports as its own case.
- When migrating from Jest:
vi replaces jest, vi.mock factories must return the module shape explicitly (no automock), and jest.requireActual becomes importOriginal.
Anti-Patterns
- Referencing top-level variables inside a
vi.mock factory. Hoisting makes them undefined at factory time — the error message mentions hoisting, believe it. Use vi.hoisted.
globals: true plus 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.mock of 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.alias when they already exist in vite.config.ts; drift between the two breaks resolution in tests only.
When to Trigger This Skill
- The project is Vite-based or has
vitest in devDependencies.
- 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.