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.

~/.claude/skills/vitest-testing/SKILL.md

--- name: vitest-testing description: Configures and writes Vitest suites for TypeScript and Vite projects, covering vitest.config, test environments (node, jsdom, happy-dom),… license: MIT ---

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: workspace files became test.projects (3.2+), and coverage include rules changed. Read the project’s existing config before adding keys.
  • Set restoreMocks: true (or mockReset) in config so spies and mocks do not leak.
  • Prefer dependency injection or vi.spyOn over vi.mock for code the project owns. Reserve vi.mock for modules you cannot inject (SDKs, node:fs, framework modules).
  • vi.mock calls are hoisted above imports. Anything referenced in the factory must be created with vi.hoisted or imported inside the factory.
  • Explicitly import describe/it/expect/vi from vitest unless the project already uses globals: 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 testEnvironment
Pure TS, Node services, CLIsnode (default, fastest)
React/Vue components with Testing Libraryjsdom (most compatible) or happy-dom (faster, fewer APIs)
Code needing real layout, CSS, or browser APIsVitest 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.mock path is resolved relative to the test file and must match what the code under test imports (after aliases).
  • vi.mock applies 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 fetch per 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 Async variants (advanceTimersByTimeAsync, runAllTimersAsync) when timers schedule promises; the sync versions leave awaited work pending.
  • runAllTimers on an interval loops until the limit and fails; advance by a duration.
  • With Testing Library user-event, pass advanceTimers: vi.advanceTimersByTime to userEvent.setup() or clicks will hang.
  • Fake timers also fake Date by default; setSystemTime without fake timers only works if you call vi.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.include explicitly 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 */ (or start/stop) for genuinely unreachable branches, with a comment explaining why.

Migrating from Jest

JestVitest
jest.fn / jest.spyOn / jest.mockvi.fn / vi.spyOn / vi.mock
jest.requireActualawait vi.importActual() or importOriginal in the factory
jest.useFakeTimers({ now })vi.useFakeTimers(); vi.setSystemTime(now)
moduleNameMapperresolve.alias in Vite config
testEnvironmenttest.environment
setupFilesAfterEnvtest.setupFiles
Mock factory returning default onlyReturn { 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. Without globals: true, call cleanup() in a setup file afterEach, or DOM leaks between tests.
  • test.concurrent needs the expect from the test context (async ({ expect }) =>) for snapshots and expect.assertions to attribute correctly.
  • Mocking a module that the setup file already imported has no effect for that import.
  • vi.mock of 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 v8 and istanbul; do not switch providers and compare.
  • A test that leaves a real interval or open handle keeps the worker alive; use --reporter=hanging-process to find it.

Verification

  1. Run the changed files, then the full suite with --sequence.shuffle twice.
  2. Run vitest run --coverage and confirm thresholds pass with the include list.
  3. 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.

esc