Vitest testing
QA Skills Directory QA Skills is a curated directory of testing-specific skills for AI coding agents (Claude Code, Cursor, Copilot, etc.).
npx -y skills add PramodDutta/qaskills --skill vitest-testingAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
What its author says it does
Copied from the file, not written here
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.
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
9.5 KB, as published. Nobody here has run it
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('[email protected]', `${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(
'[email protected]',
'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.