Skip to main content

vitest

Write and review Vitest tests: structure, assertions, fixtures and lifecycle, the mocking surface and its hoisting rules, snapshots, configuration and projects, pools and isolation, coverage, type tests, and migration from Jest.

Source facts

Repository
xobotyi/cc-foundry
Last source activity
October 13, 2026 at 09:31
Detected SKILL.md language
English
Stars
22
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
11 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
vitest
description
Write and review Vitest tests: structure, assertions, fixtures and lifecycle, the mocking surface and its hoisting rules, snapshots, configuration and projects, pools and isolation, coverage, type tests, and migration from Jest.
when_to_use
Invoke whenever Vitest is touched at all — writing or reviewing a test, editing a vitest config, mocking a module, adding a fixture, running a suite, or reading a failure. Also invoke on the symptoms: a mock leaks between tests, a `vi.mock` factory cannot see a variable, an async assertion passes when it should not, a snapshot will not settle, coverage reports the wrong files, a suite is slow or flaky, or a config key from an older major is rejected. Covers Vitest; the test runners bundled with Node and Bun belong to the nodejs and bun skills, JavaScript semantics to javascript, the type system to typescript, and what is worth testing to the coding skill.
compatibility
Uses Claude Code frontmatter beyond the Agent Skills spec (when_to_use)
A test earns its place by failing when the behavior breaks, and at no other time. Three biases decide most calls: - **Mock the boundary, never the unit under test.** Every mock asserts that the real thing cannot run here. Most such assertions are false, and each false one buys a test that keeps passing after the code is wrong. - **Prefer a fixture to a hook.** `test.extend` initializes on demand, tears down in its own scope, and declares its dependencies in the signature. A `beforeEach` runs for every test in scope whether that test needs it or not. - **The installed major decides what may be written**, not the shape recalled from an older one. ## Version and Toolchain Vitest 4 is the target. Read the installed version out of the lockfile before touching a config file — the configuration surface moved in 3.2, 4.0 and 4.1, and a key from the wrong one is silently ignored or loudly rejected. Vitest 4.1 requires Node `^20 || ^22 || >=24` and Vite `^6 || ^7 || ^8`. Vitest 4.0 shipped requiring Vite 6 and Node 20; 4.1 added Vite 8 and uses the installed `vite` rather than a bundled copy. What changed, by version. Every removal below is rejected or ignored under Vitest 4; the 4.1 entries are additions and need 4.1 installed. - **3.2** — `workspace` deprecated in favor of `projects`; fixture scopes (`test`, `file`, `worker`) added; AST-aware v8 coverage remapping added behind `coverage.experimentalAstAwareRemapping` - **4.0** — `workspace` removed; `poolOptions.*` flattened to top level; `maxThreads`/`maxForks` became `maxWorkers`; `singleThread`/`singleFork` became `maxWorkers: 1, isolate: false`; `minWorkers` removed; `coverage.all` and `coverage.extensions` removed; `environmentMatchGlobs` and `poolMatchGlobs` removed; `deps.external`/`deps.inline` moved under `server.deps`; browser providers became separate packages taking a factory; the `basic` reporter and the pre-3.0 reporter hooks removed; the third-argument options object on `test` and `describe` removed - **4.1** — `aroundEach` and `aroundAll` hooks; the `test.extend` builder pattern with type inference; `test.override`; test tags with `--tags-filter`; `--detect-async-leaks`; `vi.defineHelper`; `vi.setTimerTickMode`; `experimental.viteModuleRunner` Read [`${CLAUDE_SKILL_DIR}/references/configuration.md`] when reconciling a config written against an older major — it carries the rename inventory entry by entry, each with the major that made the change. ## Test Structure - **Import from `vitest` explicitly.** `globals` defaults to `false`. Add ambient globals only where the project already sets `globals: true`, which also needs `"types": ["vitest/globals"]` in `tsconfig.json`. - **Name a test after the behavior it pins, in a phrase that scans.** `'returns an empty array for a null input'`, not `'should correctly return an empty array when the provided input value is null or undefined'`. A long name is read in a list of forty. - **Pass options as the second argument**: `test('name', { retry: 2 }, fn)`. The third-argument form was removed in Vitest 4. A bare timeout number as the last argument still works, and excludes the options object. - **`test.for` over `test.each` when a case needs the test context.** `each` spreads an array row into positional parameters; `for` passes the row as one value and gives the test context as the second parameter, which is what a concurrent snapshot needs. - **Never leave `.only` in a commit.** `allowOnly` defaults to `!process.env.CI`, so CI fails the run rather than quietly testing one case. - **`it.fails` asserts that a test fails**, which is how a known bug is recorded without leaving the suite red. `test.todo` never runs, so it never tells you the bug was fixed; a `fails` test goes red the moment it starts passing. ## Assertions - **`toEqual` ignores `undefined` properties**, so it passes on a field that should not exist. Reach for `toStrictEqual` where `undefined` keys, sparse array holes and class identity are part of the contract. - **Always `await` `.resolves` and `.rejects`.** Vitest 4 fails a test whose async assertion was never awaited; Vitest 3 only warned, because an unawaited async assertion never runs and passes unconditionally. - **Wrap a throwing call in a function**: `expect(() => parse(bad)).toThrow(/unexpected/)`. After `.rejects` the wrapper is wrong — the promise is already unwrapped. - **Assert a volatile field with an asymmetric matcher, never by deleting the assertion.** `expect.any(String)`, `expect.objectContaining`, `expect.arrayContaining`, `expect.stringMatching`, and `expect.schemaMatching(schema)` (Vitest 4) for a Standard Schema validator. All of them negate through `expect.not.*`. - **`expect.poll(() => value, { timeout, interval })` over a hand-written retry loop.** It retries the assertion, must be awaited, and does not work with snapshot matchers, `.resolves`/`.rejects`, or `toThrow`. - **`expect.soft` where several independent properties of one result are checked** and the first mismatch should not hide the rest. - **`expect.assertions(n)` or `expect.hasAssertions()` whenever an assertion sits inside a callback** that might never fire. - **`toHaveBeenCalledExactlyOnceWith(...)` over the `toHaveBeenCalledTimes(1)` plus `toHaveBeenCalledWith(...)` pair.** Read [`${CLAUDE_SKILL_DIR}/references/matchers.md`] when reaching for a matcher whose exact semantics matter, or when writing a custom matcher — it carries the Vitest-only matcher set, the spy and async matcher families, and the `expect.extend` contract with its TypeScript declaration. ## Test Context and Fixtures - **Destructure the context.** The fixture system reads the destructuring pattern to decide what to initialize; `(ctx) => ctx.db` leaves `db` uninitialized. - **Use the context `expect` inside `concurrent` tests.** The global `expect` cannot attribute snapshots or `expect.assertions` to overlapping tests. - **Build fixtures with the builder pattern** (Vitest 4.1), which infers each type from the factory's return value: `test.extend('db', async ({}, { onCleanup }) => { ... })`. The Playwright-style object syntax with `use()` stays correct and needs the types declared by hand. - **`onCleanup` may be called once per fixture.** Two resources is two fixtures — which makes the teardown order explicit anyway. - **Declare `scope` on anything a longer-lived fixture depends on.** A fixture with no scope is `test`-scoped, and a `file`- or `worker`-scoped fixture cannot reach it. Only `test` fixtures see `task`, `expect` and `skip`. - **Suite-level hooks must be called on the extended `test`** — `test.beforeAll(({ db }) => ...)`. The global `beforeAll` receives no fixtures. Asking a suite-level hook for a `test`-scoped fixture throws. - **`test.override` (Vitest 4.1) replaces a fixture for a suite**; it cannot introduce one and cannot change `scope` or `auto`. `test.scoped` is its deprecated predecessor. - **Pass values from `globalSetup` with `provide`/`inject`, never a module-level variable.** Global setup runs in the main process with its own global scope; keys must be strings and values structured-cloneable. ## Lifecycle Hooks - **Never let a hook body return a value by accident.** A function returned from `beforeAll` or `beforeEach` is run as teardown. Write `beforeEach(() => { setup() })`, not `beforeEach(() => setup())`. - **`afterEach` runs in reverse registration order** under the default `sequence.hooks: 'stack'`. `'list'` gives Jest's order; `'parallel'` runs a group at once, bounded by `maxConcurrency`. - **`onTestFinished` for cleanup registered inside a test** — it runs on pass and on fail, always in reverse order, regardless of `sequence.hooks`. `onTestFailed` for diagnostics only. - **Reach for `aroundEach`/`aroundAll` (Vitest 4.1) only when the test must run _inside_ a call** — a database transaction, an `AsyncLocalStorage` run, a tracing span. The callback must call `runTest()` or `runSuite()`. A missed `runTest()` fails the test; a missed `runSuite()` skips every test in the suite. A `beforeEach` returning a teardown function covers everything else more cheaply. - **`setupFiles` run in the worker before each test file; `globalSetup` runs once in the main process** before any worker exists. Expensive shared state belongs in `globalSetup`; hook and matcher registration belongs in `setupFiles`. - **`concurrent` buys nothing for synchronous tests.** No extra worker is created, so the gain is only on tests that wait. Avoid `clearMocks`, `mockReset` and `restoreMocks` alongside it — each reaches across overlapping tests. Read [`${CLAUDE_SKILL_DIR}/references/fixtures-and-lifecycle.md`] when designing shared setup, choosing a fixture scope, or explaining an unexpected hook order — it carries the built-in context, both `extend` syntaxes, the scope access table, and the exact nested execution order. ## Mocking - **Pass the dependency in before mocking a module.** Where the code under test can take its collaborator as a parameter or a constructor argument, pass a fake that implements the real interface. `vi.mock` swaps the module for every importer in the file; keep it for a dependency the code imports and the test cannot route around. - **`vi.spyOn(obj, 'method')` before `vi.mock`.** Replacing one export is recoverable; replacing a module is a standing claim about every export it has, including the ones added later. - **A `vi.mock` factory closes over nothing.** The call is hoisted above every import, so any variable it names is still uninitialized and the reference throws a temporal-dead-zone error — `Cannot access '__vi_import_0__' before initialization` when what it names is an import. Put the value in `vi.hoisted` and reference that. - **`vi` must come from `vitest` in the same file, or `globals` must be on.** The hoisting pass is a static scan; a `vi` re-exported through a project helper is not recognized and the call stays where it was written. - **Write the module specifier as `import('./mod.js')`, not a string.** `vi.mock(import('./mod.js'), ...)` types `importOriginal`, constrains the factory's return to the real module's exports, and updates when the file moves. - **A factory returns the whole namespace, with `default` as an explicit key.** An omitted export throws when the code reaches it. - **`vi.mock(path, { spy: true })` when the question is "was it called correctly".** It tracks every export while keeping the real implementation, and is the only way to observe module exports in browser mode. - **`vi.mock` cannot intercept a call between two functions in one file.** `foobar()` calling `foo()` in the same module holds a direct reference. This is intended and will not change — split the module or inject the dependency. - **`vi.doMock` where the mock must vary per test.** It is not hoisted, so it closes over file-scope variables, and it affects only the next dynamic `import()` — never a static import written below it. - **Know which reset you mean.** `mockClear` empties history. `mockReset` also restores the original implementation — `vi.fn(impl)` returns to `impl`, which is the opposite of Jest. `mockRestore` also detaches a `vi.spyOn` spy. `vi.restoreAllMocks` detaches spies **without** clearing history or resetting implementations, and leaves automocks alone in Vitest 4. - **Pick the config switch by what has to be undone.** All three (`clearMocks`, `mockReset`, `restoreMocks`) default to `false` and run before each test. `restoreMocks` only detaches `vi.spyOn` spies and clears nothing, so it does not isolate call history on its own; `clearMocks` empties history, and `mockReset` also resets implementations. - **Mock a class implementation with `function` or `class`, never an arrow function.** Vitest 4 constructs mocks called with `new`; an arrow function raises `<anonymous> is not a constructor`. - **Intercept HTTP below the code under test with Mock Service Worker**, configured in a setup file with `onUnhandledRequest: 'error'`. Stubbing `fetch` tests the stub. - **A `__mocks__` file is never loaded unless `vi.mock()` names the module.** Jest's always-on behavior means calling `vi.mock` per module in a setup file. Read [`${CLAUDE_SKILL_DIR}/references/mocking.md`] when a mock does not take effect, leaks between tests, or has to work in browser mode — it carries the hoisting transform, the automocking algorithm, the reset matrix, virtual modules, and the per-environment differences. ## Fake Timers - **Pair `vi.useFakeTimers()` with `vi.useRealTimers()`** in `beforeEach`/`afterEach`, or configure `fakeTimers` globally. A leaked fake clock breaks every later file in a non-isolated worker. - **`vi.useFakeTimers()` fakes every timer API present on the global object** except `process.nextTick` and `queueMicrotask` — `performance` and `requestAnimationFrame` included, not only `setTimeout` and `Date`. Measured on Vitest 4.1.11. - **`toFake` replaces that default set, it does not extend it.** `toFake: ['setTimeout']` leaves `Date` real, so the list must name every API the test needs. Measured on Vitest 4.1.11. - **`nextTick` cannot be faked under the default `forks` pool.** Vitest 4.1.11 refuses it outright: `vi.useFakeTimers({ toFake: ["nextTick"] }) is not supported in node:child_process. Use --pool=threads if mocking nextTick is required.` It throws for `toFake` passed to the call and for `fakeTimers.toFake` set in the config alike, and it tests for a child process rather than a pool name. Take the fix the message names, or leave `nextTick` off the list — the v4 docs describe a hung process instead, which is the outcome this guard exists to prevent. - **Advance deliberately.** `vi.advanceTimersToNextTimer()` fires one timer; `vi.runOnlyPendingTimers()` fires what is queued without following the chain; `vi.runAllTimers()` drains everything and throws after 10 000 iterations on an endless interval. - **Use the `*Async` variants when the code under test awaits between timers** — they flush the microtask queue in between. - **`vi.setSystemTime(date)` to fix the clock** without advancing any timer. Read [`${CLAUDE_SKILL_DIR}/references/mocking.md`] when a timer helper does not advance what it should — its fake-timer section carries the full set of faked APIs, `fakeTimers.loopLimit`, and the `vi.setTimerTickMode` modes. ## Snapshots - **Prefer `toMatchInlineSnapshot` for a small value.** It is reviewed in the same diff as the code that changed it. - **Give every volatile field a property matcher**: `toMatchSnapshot({ id: expect.any(String) })`. A snapshot holding a timestamp trains everyone to run `-u`. - **Never run `-u` on a red suite without reading the diff.** Updating is how a regression becomes the expected value. - **A snapshot too large to read is a rubber stamp, not a test** — assert the handful of properties that matter instead. - **In CI, a missing or obsolete snapshot fails the run**, not only a mismatch. An obsolete entry is one whose test was renamed or deleted; delete the entry in the same change. Read [`${CLAUDE_SKILL_DIR}/references/snapshots.md`] when writing a serializer, a custom snapshot matcher, or reconciling snapshot output that differs from Jest — it carries the four snapshot forms, the serializer contract, and the Jest output divergences. ## Configuration - **`vitest.config.ts` overrides `vite.config.ts` entirely.** It is not merged. Where the app config must be shared, either put `test` inside `vite.config.ts` with `/// <reference types="vitest/config" />`, or `mergeConfig` the two deliberately. - **`plugins` and `define` are Vite-only and sit at the top level**, never under `test`. `alias` and `server` exist in both places and mean different things: `test.alias` merges over `resolve.alias`, and `test.server.deps` controls inlining and externalization, not Vite's dev server. The Vitest 4 docs mark `test.server` deprecated without naming a replacement, and it is still where `deps.inline` and `deps.external` live. - **Extend a default array rather than replacing it**: `exclude: [...configDefaults.exclude, ...]`. - **Since Vitest 4, `exclude` covers only `node_modules` and `.git`.** `dist`, `cypress`, `.cache`, `.output` and the common `*.config.js` files are collected unless excluded. Narrow the search with `dir` instead — it limits where Vitest looks rather than filtering what it found. ## Projects - **`projects` for anything that needs a second environment, pool, alias set or isolation setting.** It replaced `workspace` (deprecated 3.2, removed 4.0) and both `environmentMatchGlobs` and `poolMatchGlobs` (removed 4.0). - **Every project needs a unique `name`.** - **A project inherits nothing by default.** Add `extends: true` to take the root config, or compose with `mergeConfig`. Use `defineProject` rather than `defineConfig` in a project file for the narrower type. - **`coverage`, `reporters` and `resolveSnapshotPath` are root-only** and silently ignored in a project config. - **The root config is not itself a project** unless it is listed among them. Read [`${CLAUDE_SKILL_DIR}/references/configuration.md`] when creating a config file or laying out projects in a monorepo — it carries the three config-file shapes, the defaults with their exact values, what a project entry may be, and the naming rule a glob entry must satisfy. ## Pools and Isolation - **Leave `pool: 'forks'` alone unless a measurement says otherwise.** It is the Vitest 4 default and the compatible one: `process.chdir` works and native modules do not segfault.
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub