Vitest Testing skill for Claude Code
Vitest suites with no leaking mocks or hung timers. Claude configures Vitest so mocks restore themselves, each file runs in the right environment, and coverage counts every source file instead of only the ones a test touched. It knows the hoisting rules behind vi.mock, uses the async timer helpers when promises are involved, and maps Jest APIs one to one when migrating a suite.
- Name
vitest-testing- Size
- 220 lines · ≈2,150 tokens
- Files
- 1
- License
- MIT
- Updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Configures and writes Vitest suites for TypeScript and Vite projects, covering vitest.config, test environments (node, jsdom, happy-dom), module mocking with vi.mock and vi.hoisted, spies, fake timers and system time, env stubbing, V8 coverage thresholds, and multi-project setups. Use when writing or debugging Vitest tests, migrating from Jest, when mocks leak between tests, when fake timers hang a test, or when setting up coverage and projects in a monorepo. Try asking
- Mock the Stripe client in this Vitest test without breaking the other exports
- My test with vi.useFakeTimers never finishes. What's wrong?
- Migrate our Jest config and tests to Vitest with node and jsdom projects
Use it when
- Writing or fixing tests in a Vite or Vitest project
- Mocks or env stubs leak from one test into another
- Fake timers hang or dates are wrong in tests
- Setting up coverage thresholds or multi-project configs
- Moving a test suite from Jest to Vitest
Skip it when
- The question is what to test rather than how in Vitest: use unit-testing
- You need an HTTP mocking strategy across the codebase: use mocking-strategy
How to check the result
The full suite passes twice with --sequence.shuffle, coverage thresholds pass with an explicit include list, and no test depends on another test's mock state.
The common mistake it prevents
Referencing a top-level variable inside a vi.mock factory, which fails or reads undefined because the mock call is hoisted above the declaration; create it with vi.hoisted.
Done well: tests run in the right environment per file, every mock is restored after the test that created it, timers are faked only where time matters, and coverage is gated on the code that should be covered. Leaked mocks are the number one source of Vitest pain.
Ground rules
- Check the installed major first (
npx vitest --version). Config keys moved between majors:workspacefiles becametest.projects(3.2+), and coverage include rules changed. Read the project’s existing config before adding keys. - Set
restoreMocks: true(ormockReset) in config so spies and mocks do not leak. - Prefer dependency injection or
vi.spyOnovervi.mockfor code the project owns. Reservevi.mockfor modules you cannot inject (SDKs,node:fs, framework modules). vi.mockcalls are hoisted above imports. Anything referenced in the factory must be created withvi.hoistedor imported inside the factory.- Explicitly import
describe/it/expect/vifromvitestunless the project already usesglobals: true.
Workflow
- [ ] 1. Read vitest.config / vite.config and package scripts
- [ ] 2. Confirm environment per test type (node vs DOM)
- [ ] 3. Write tests; mock only true boundaries
- [ ] 4. Fake time only in tests that depend on it
- [ ] 5. Run focused, then full with shuffle
- [ ] 6. Gate coverage on included source paths
1. Baseline config
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
restoreMocks: true,
unstubEnvs: true,
unstubGlobals: true,
setupFiles: ['./test/setup.ts'],
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.*', 'src/**/*.d.ts', 'src/generated/**'],
reporter: ['text', 'html', 'lcov'],
thresholds: { lines: 80, branches: 75, functions: 80, statements: 80 },
},
},
});
If the project has a vite.config.ts with plugins/aliases the tests need, either put
test inside it or use mergeConfig(viteConfig, defineConfig({ test: {...} })) so
aliases resolve identically.
2. Environments and projects
| Code under test | Environment |
|---|---|
| Pure TS, Node services, CLIs | node (default, fastest) |
| React/Vue components with Testing Library | jsdom (most compatible) or happy-dom (faster, fewer APIs) |
| Code needing real layout, CSS, or browser APIs | Vitest browser mode with Playwright provider |
Per-file override: first line // @vitest-environment jsdom.
For mixed suites use projects (Vitest 3.2+; older versions use a workspace file):
export default defineConfig({
test: {
projects: [
{ extends: true, test: { name: 'unit', include: ['src/**/*.test.ts'], environment: 'node' } },
{ extends: true, test: { name: 'dom', include: ['src/**/*.test.tsx'], environment: 'jsdom',
setupFiles: ['./test/setup-dom.ts'] } },
],
},
});
extends: true inherits root options like restoreMocks and coverage. Run one project
with vitest --project dom.
3. Mocking
Spy on an owned module or object (preferred):
import * as mailer from '../src/mailer';
const send = vi.spyOn(mailer, 'send').mockResolvedValue({ id: 'm1' });
await registerUser({ email: '[email protected]' });
expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: '[email protected]' }));
Spying on ESM named exports only works when the consumer accesses the export through
the module namespace at call time; if it does not take effect, switch to vi.mock or
inject the dependency.
Module mock with partial passthrough:
const { charge } = vi.hoisted(() => ({ charge: vi.fn() }));
vi.mock('../src/payments/stripe-client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/payments/stripe-client')>();
return { ...actual, charge };
});
it('marks order paid when charge succeeds', async () => {
charge.mockResolvedValueOnce({ status: 'succeeded' });
const order = await checkout(cart());
expect(order.status).toBe('paid');
});
- The
vi.mockpath is resolved relative to the test file and must match what the code under test imports (after aliases). vi.mockapplies to the whole file. For per-test variation, mock once and change the return value per test (mockResolvedValueOnce).- Type mocked imports with
vi.mocked(fn). - For HTTP, mock at the network layer with MSW instead of mocking
fetchper call (see mocking-strategy).
Env and globals:
vi.stubEnv('FEATURE_X', 'on'); // reverted by unstubEnvs: true
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {} })));
4. Fake timers and dates
beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-03-01T10:00:00Z')); });
afterEach(() => { vi.useRealTimers(); });
it('expires the session after 30 minutes idle', async () => {
const session = startSession();
await vi.advanceTimersByTimeAsync(30 * 60_000);
expect(session.isExpired()).toBe(true);
});
- Use the
Asyncvariants (advanceTimersByTimeAsync,runAllTimersAsync) when timers schedule promises; the sync versions leave awaited work pending. runAllTimerson an interval loops until the limit and fails; advance by a duration.- With Testing Library user-event, pass
advanceTimers: vi.advanceTimersByTimetouserEvent.setup()or clicks will hang. - Fake timers also fake
Dateby default;setSystemTimewithout fake timers only works if you callvi.useFakeTimers()first.
5. Run
npx vitest # watch mode
npx vitest run src/cart # filter by path
npx vitest run -t "expires the session" # filter by test name
npx vitest related src/cart/total.ts --run # tests importing this file
npx vitest run --changed origin/main # tests affected by changes since ref
npx vitest run --sequence.shuffle # catch order dependence
npx vitest run --reporter=verbose
npx vitest run --coverage
npx vitest run --shard=1/3 # CI split
Debug: npx vitest --inspect-brk --no-file-parallelism and attach a debugger, or use the
VS Code Vitest extension.
6. Coverage gate
- List
coverage.includeexplicitly so untested files show as 0% instead of being invisible. - Start thresholds at the current measured value and ratchet up; do not set 90% on a legacy codebase and then exclude half of it.
/* v8 ignore next */(orstart/stop) for genuinely unreachable branches, with a comment explaining why.
Migrating from Jest
| Jest | Vitest |
|---|---|
jest.fn / jest.spyOn / jest.mock | vi.fn / vi.spyOn / vi.mock |
jest.requireActual | await vi.importActual() or importOriginal in the factory |
jest.useFakeTimers({ now }) | vi.useFakeTimers(); vi.setSystemTime(now) |
moduleNameMapper | resolve.alias in Vite config |
testEnvironment | test.environment |
setupFilesAfterEnv | test.setupFiles |
| Mock factory returning default only | Return { default: ..., named: ... } explicitly for ESM |
Jest-style __mocks__ directories work, but only when vi.mock('module') is called in
the test file; they are not applied automatically for user modules.
Gotchas
- Testing Library auto-cleanup relies on a global
afterEach. Withoutglobals: true, callcleanup()in a setup fileafterEach, or DOM leaks between tests. test.concurrentneeds theexpectfrom the test context (async ({ expect }) =>) for snapshots andexpect.assertionsto attribute correctly.- Mocking a module that the setup file already imported has no effect for that import.
vi.mockof a path with an alias must use the same alias string or the resolved path, not a different relative path to the same file.- Coverage numbers differ between
v8andistanbul; do not switch providers and compare. - A test that leaves a real interval or open handle keeps the worker alive; use
--reporter=hanging-processto find it.
Verification
- Run the changed files, then the full suite with
--sequence.shuffletwice. - Run
vitest run --coverageand confirm thresholds pass with the include list. - If a file passes alone but fails in the full run, a test leaks state (mocks, env, globals, module cache). Fix the leaking test, not the victim.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.