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
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.
Segmentation fault, Abort trap: 6 or thread '<unnamed>' panicked means switch off threads. A native addon
in a worker thread is the cause.
vmThreads and vmForks leak by construction — ES modules are cached with no API to clear them, and native
module globals differ, so err instanceof Error can be false across the boundary. isolate has no effect there.
isolate: false is the biggest single speed gain available to a node-environment suite, and its biggest
correctness cost. Module registry, globalThis mutations and module-level singletons persist across files in the
same worker. Scope it to a project holding the files that hold no state, never to the whole run.
fileParallelism: false for a shared external resource, not for a leak between files — it forces maxWorkers
to 1.
maxWorkers replaced maxThreads and maxForks in Vitest 4, and VITEST_MAX_WORKERS replaced the two matching
environment variables. It accepts a percentage string.
Read [${CLAUDE_SKILL_DIR}/references/configuration.md] when choosing a pool or scoping isolation to part of a suite —
it carries the four pools side by side, what isolation costs when it is off, and the experimental knobs with the release
each landed in.
Coverage
- Set
coverage.include explicitly. Vitest 4 removed coverage.all, so without it the report holds only files some
test imported — an untested file simply vanishes and the percentage rises.
v8 is the default and the right default. Vitest 4 remaps its results through AST analysis — introduced behind a
flag in 3.2, the only mode since 4.0 — which matches Istanbul's accuracy at lower cost. Choose istanbul for a non-V8
runtime, or when the module count makes v8 slower.
- Neither provider ships with Vitest:
@vitest/coverage-v8 or @vitest/coverage-istanbul.
- An ignore hint needs
-- @preserve. esbuild strips comments during transpilation, so /* v8 ignore if */
disappears before the provider sees it; /* v8 ignore if -- @preserve */ survives.
- A negative threshold is a maximum count of uncovered items, which ratchets a legacy codebase where a percentage
cannot.
Read [${CLAUDE_SKILL_DIR}/references/coverage.md] when tuning what appears in a report, setting thresholds, or merging
coverage across shards — it carries the provider trade-offs, the include/exclude semantics, and the full ignore-hint
syntax.
Type Testing
- Type tests are not executed. Vitest runs
tsc over *.test-d.ts and parses the diagnostics, so a name built by
test.each is never evaluated.
toEqualTypeOf<T>() by default; toMatchObjectType<T>() for a strict object subset. toExtend is the loose
form, and toMatchTypeOf is deprecated in its favor.
- Assert
.not.toBeAny() on anything crossing a generic or library boundary. any satisfies every other assertion,
so a degraded type passes the whole file.
Read [${CLAUDE_SKILL_DIR}/references/type-testing.md] when writing type tests or decoding an expectTypeOf error — it
carries the matcher set, the navigation chain, and the typecheck options.
Benchmarks
vitest bench collects *.bench.* and *.benchmark.* files. Benchmarking is experimental and outside SemVer —
pin the version before depending on the output shape.
- Compare against a stored baseline:
vitest bench --outputJson main.json on the base branch, --compare main.json
on the change.
- Options come from tinybench —
time (500 ms), iterations (10), warmupTime (100 ms), warmupIterations (5),
plus setup and teardown per cycle.
Browser Mode
- Browser mode is stable as of Vitest 4 and is the honest way to test anything that depends on layout, focus, or
real pointer events.
jsdom and happy-dom remain right for code that only touches the DOM API.
- The provider is a factory from its own package, not a string:
provider: playwright() from
@vitest/browser-playwright. Vitest 4 dropped @vitest/browser and moved the context imports to vitest/browser.
vi.spyOn on a module namespace throws in the browser. Native ESM seals it. Use vi.mock(path, { spy: true }).
- Put browser tests in their own project so the Node suite does not pay for a browser it does not use.
Read [${CLAUDE_SKILL_DIR}/references/browser-mode.md] when setting up browser mode, writing locator queries, or adding
visual regression tests — it carries the provider packages, the locator surface, and the screenshot stability
constraints.
Running Vitest
- Always
vitest run from a script, a CI job, or an agent. Bare vitest watches whenever stdin is a TTY and CI
is unset; that detection is fragile, and a watching process never exits.
- Filter narrowly rather than rerunning everything: a path substring,
-t <regex> against the full name including
describe titles, or file.test.ts:42 to run the test containing a line.
--changed [ref] while iterating, defaulting to uncommitted work and accepting a commit or branch.
--shard=i/n with --reporter=blob, then one vitest --merge-reports. Sharding splits files, never cases.
--detect-async-leaks (Vitest 4.1) when a suite hangs or flakes — it names the leaked handles and their source
locations, at a runtime cost that makes it a debugging tool rather than a default.
Read [${CLAUDE_SKILL_DIR}/references/configuration.md] when a filter selects the wrong files, or when setting up
sharding or test tags — it carries the exact matching rule for every filter form, the blob and merge flow, and the tag
declaration and priority resolution.
Migrating from Jest
Three behavioral differences catch a mechanical jest. → vi. rename:
mockReset restores the original implementation rather than replacing it with a stub returning undefined.
- A
vi.mock factory returns the module namespace, with default as an explicit key, not the default export
itself.
- A function returned from
beforeEach is teardown, so a concise arrow body changes meaning.
Read [${CLAUDE_SKILL_DIR}/references/jest-migration.md] when porting a suite from Jest, or from Mocha, Chai and Sinon
— it carries the full API translation, the globals and auto-mocking differences, and the snapshot output divergences.
Application
When writing tests, apply these conventions silently — do not narrate a rule while following it. Where the existing
suite contradicts one, follow the suite and flag the divergence once.
When reviewing tests, cite the violation and show the fix inline. Do not lecture.
Bad: "Vitest best practice suggests awaiting async assertions..."
Good: expect(fetchUser(1)).rejects.toThrow() -> await expect(fetchUser(1)).rejects.toThrow()
Integration
The coding skill decides what is worth testing and when; this skill decides how a Vitest test is written. Both are
active at once.
This skill covers Vitest and nothing else. node:test belongs to the nodejs skill and bun:test to the bun
skill, because each ships with its runtime. expectTypeOf and the typecheck options are this skill's, being its own
API and its own config keys; the type system they exercise is the typescript skill's, and the language under test is
the javascript skill's.
When in doubt, mock less.
1---2name: vitest3description: 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.4---56A test earns its place by failing when the behavior breaks, and at no other time. Three biases decide most calls:78- **Mock the boundary, never the unit under test.** Every mock asserts that the real thing cannot run here. Most such9 assertions are false, and each false one buys a test that keeps passing after the code is wrong.10- **Prefer a fixture to a hook.** `test.extend` initializes on demand, tears down in its own scope, and declares its11 dependencies in the signature. A `beforeEach` runs for every test in scope whether that test needs it or not.12- **The installed major decides what may be written**, not the shape recalled from an older one.1314## Version and Toolchain1516Vitest 4 is the target. Read the installed version out of the lockfile before touching a config file — the configuration17surface moved in 3.2, 4.0 and 4.1, and a key from the wrong one is silently ignored or loudly rejected.1819Vitest 4.1 requires Node `^20 || ^22 || >=24` and Vite `^6 || ^7 || ^8`. Vitest 4.0 shipped requiring Vite 6 and Node2020; 4.1 added Vite 8 and uses the installed `vite` rather than a bundled copy.2122What changed, by version. Every removal below is rejected or ignored under Vitest 4; the 4.1 entries are additions and23need 4.1 installed.2425- **3.2** — `workspace` deprecated in favor of `projects`; fixture scopes (`test`, `file`, `worker`) added; AST-aware v826 coverage remapping added behind `coverage.experimentalAstAwareRemapping`27- **4.0** — `workspace` removed; `poolOptions.*` flattened to top level; `maxThreads`/`maxForks` became `maxWorkers`;28 `singleThread`/`singleFork` became `maxWorkers: 1, isolate: false`; `minWorkers` removed; `coverage.all` and29 `coverage.extensions` removed; `environmentMatchGlobs` and `poolMatchGlobs` removed; `deps.external`/`deps.inline`30 moved under `server.deps`; browser providers became separate packages taking a factory; the `basic` reporter and the31 pre-3.0 reporter hooks removed; the third-argument options object on `test` and `describe` removed32- **4.1** — `aroundEach` and `aroundAll` hooks; the `test.extend` builder pattern with type inference; `test.override`;33 test tags with `--tags-filter`; `--detect-async-leaks`; `vi.defineHelper`; `vi.setTimerTickMode`;34 `experimental.viteModuleRunner`3536Read [`${CLAUDE_SKILL_DIR}/references/configuration.md`] when reconciling a config written against an older major — it37carries the rename inventory entry by entry, each with the major that made the change.3839## Test Structure4041- **Import from `vitest` explicitly.** `globals` defaults to `false`. Add ambient globals only where the project already42 sets `globals: true`, which also needs `"types": ["vitest/globals"]` in `tsconfig.json`.43- **Name a test after the behavior it pins, in a phrase that scans.** `'returns an empty array for a null input'`, not44 `'should correctly return an empty array when the provided input value is null or undefined'`. A long name is read in45 a list of forty.46- **Pass options as the second argument**: `test('name', { retry: 2 }, fn)`. The third-argument form was removed in47 Vitest 4. A bare timeout number as the last argument still works, and excludes the options object.48- **`test.for` over `test.each` when a case needs the test context.** `each` spreads an array row into positional49 parameters; `for` passes the row as one value and gives the test context as the second parameter, which is what a50 concurrent snapshot needs.51- **Never leave `.only` in a commit.** `allowOnly` defaults to `!process.env.CI`, so CI fails the run rather than52 quietly testing one case.53- **`it.fails` asserts that a test fails**, which is how a known bug is recorded without leaving the suite red.54 `test.todo` never runs, so it never tells you the bug was fixed; a `fails` test goes red the moment it starts passing.5556## Assertions5758- **`toEqual` ignores `undefined` properties**, so it passes on a field that should not exist. Reach for `toStrictEqual`59 where `undefined` keys, sparse array holes and class identity are part of the contract.60- **Always `await` `.resolves` and `.rejects`.** Vitest 4 fails a test whose async assertion was never awaited; Vitest 361 only warned, because an unawaited async assertion never runs and passes unconditionally.62- **Wrap a throwing call in a function**: `expect(() => parse(bad)).toThrow(/unexpected/)`. After `.rejects` the wrapper63 is wrong — the promise is already unwrapped.64- **Assert a volatile field with an asymmetric matcher, never by deleting the assertion.** `expect.any(String)`,65 `expect.objectContaining`, `expect.arrayContaining`, `expect.stringMatching`, and `expect.schemaMatching(schema)`66 (Vitest 4) for a Standard Schema validator. All of them negate through `expect.not.*`.67- **`expect.poll(() => value, { timeout, interval })` over a hand-written retry loop.** It retries the assertion, must68 be awaited, and does not work with snapshot matchers, `.resolves`/`.rejects`, or `toThrow`.69- **`expect.soft` where several independent properties of one result are checked** and the first mismatch should not70 hide the rest.71- **`expect.assertions(n)` or `expect.hasAssertions()` whenever an assertion sits inside a callback** that might never72 fire.73- **`toHaveBeenCalledExactlyOnceWith(...)` over the `toHaveBeenCalledTimes(1)` plus `toHaveBeenCalledWith(...)` pair.**7475Read [`${CLAUDE_SKILL_DIR}/references/matchers.md`] when reaching for a matcher whose exact semantics matter, or when76writing a custom matcher — it carries the Vitest-only matcher set, the spy and async matcher families, and the77`expect.extend` contract with its TypeScript declaration.7879## Test Context and Fixtures8081- **Destructure the context.** The fixture system reads the destructuring pattern to decide what to initialize;82 `(ctx) => ctx.db` leaves `db` uninitialized.83- **Use the context `expect` inside `concurrent` tests.** The global `expect` cannot attribute snapshots or84 `expect.assertions` to overlapping tests.85- **Build fixtures with the builder pattern** (Vitest 4.1), which infers each type from the factory's return value:86 `test.extend('db', async ({}, { onCleanup }) => { ... })`. The Playwright-style object syntax with `use()` stays87 correct and needs the types declared by hand.88- **`onCleanup` may be called once per fixture.** Two resources is two fixtures — which makes the teardown order89 explicit anyway.90- **Declare `scope` on anything a longer-lived fixture depends on.** A fixture with no scope is `test`-scoped, and a91 `file`- or `worker`-scoped fixture cannot reach it. Only `test` fixtures see `task`, `expect` and `skip`.92- **Suite-level hooks must be called on the extended `test`** — `test.beforeAll(({ db }) => ...)`. The global93 `beforeAll` receives no fixtures. Asking a suite-level hook for a `test`-scoped fixture throws.94- **`test.override` (Vitest 4.1) replaces a fixture for a suite**; it cannot introduce one and cannot change `scope` or95 `auto`. `test.scoped` is its deprecated predecessor.96- **Pass values from `globalSetup` with `provide`/`inject`, never a module-level variable.** Global setup runs in the97 main process with its own global scope; keys must be strings and values structured-cloneable.9899## Lifecycle Hooks100101- **Never let a hook body return a value by accident.** A function returned from `beforeAll` or `beforeEach` is run as102 teardown. Write `beforeEach(() => { setup() })`, not `beforeEach(() => setup())`.103- **`afterEach` runs in reverse registration order** under the default `sequence.hooks: 'stack'`. `'list'` gives Jest's104 order; `'parallel'` runs a group at once, bounded by `maxConcurrency`.105- **`onTestFinished` for cleanup registered inside a test** — it runs on pass and on fail, always in reverse order,106 regardless of `sequence.hooks`. `onTestFailed` for diagnostics only.107- **Reach for `aroundEach`/`aroundAll` (Vitest 4.1) only when the test must run _inside_ a call** — a database108 transaction, an `AsyncLocalStorage` run, a tracing span. The callback must call `runTest()` or `runSuite()`. A missed109 `runTest()` fails the test; a missed `runSuite()` skips every test in the suite. A `beforeEach` returning a teardown110 function covers everything else more cheaply.111- **`setupFiles` run in the worker before each test file; `globalSetup` runs once in the main process** before any112 worker exists. Expensive shared state belongs in `globalSetup`; hook and matcher registration belongs in `setupFiles`.113- **`concurrent` buys nothing for synchronous tests.** No extra worker is created, so the gain is only on tests that114 wait. Avoid `clearMocks`, `mockReset` and `restoreMocks` alongside it — each reaches across overlapping tests.115116Read [`${CLAUDE_SKILL_DIR}/references/fixtures-and-lifecycle.md`] when designing shared setup, choosing a fixture scope,117or explaining an unexpected hook order — it carries the built-in context, both `extend` syntaxes, the scope access118table, and the exact nested execution order.119120## Mocking121122- **`vi.spyOn(obj, 'method')` before `vi.mock`.** Replacing one export is recoverable; replacing a module is a standing123 claim about every export it has, including the ones added later.124- **A `vi.mock` factory closes over nothing.** The call is hoisted above every import, so any variable it names is still125 uninitialized and the reference throws a temporal-dead-zone error —126 `Cannot access '__vi_import_0__' before initialization` when what it names is an import. Put the value in `vi.hoisted`127 and reference that.128- **`vi` must come from `vitest` in the same file, or `globals` must be on.** The hoisting pass is a static scan; a `vi`129 re-exported through a project helper is not recognized and the call stays where it was written.130- **Write the module specifier as `import('./mod.js')`, not a string.** `vi.mock(import('./mod.js'), ...)` types131 `importOriginal`, constrains the factory's return to the real module's exports, and updates when the file moves.132- **A factory returns the whole namespace, with `default` as an explicit key.** An omitted export throws when the code133 reaches it.134- **`vi.mock(path, { spy: true })` when the question is "was it called correctly".** It tracks every export while135 keeping the real implementation, and is the only way to observe module exports in browser mode.136- **`vi.mock` cannot intercept a call between two functions in one file.** `foobar()` calling `foo()` in the same module137 holds a direct reference. This is intended and will not change — split the module or inject the dependency.138- **`vi.doMock` where the mock must vary per test.** It is not hoisted, so it closes over file-scope variables, and it139 affects only the next dynamic `import()` — never a static import written below it.140- **Know which reset you mean.** `mockClear` empties history. `mockReset` also restores the original implementation —141 `vi.fn(impl)` returns to `impl`, which is the opposite of Jest. `mockRestore` also detaches a `vi.spyOn` spy.142 `vi.restoreAllMocks` detaches spies **without** clearing history or resetting implementations, and leaves automocks143 alone in Vitest 4.144- **Pick the config switch by what has to be undone.** All three (`clearMocks`, `mockReset`, `restoreMocks`) default to145 `false` and run before each test. `restoreMocks` only detaches `vi.spyOn` spies and clears nothing, so it does not146 isolate call history on its own; `clearMocks` empties history, and `mockReset` also resets implementations.147- **Mock a class implementation with `function` or `class`, never an arrow function.** Vitest 4 constructs mocks called148 with `new`; an arrow function raises `<anonymous> is not a constructor`.149- **Intercept HTTP below the code under test with Mock Service Worker**, configured in a setup file with150 `onUnhandledRequest: 'error'`. Stubbing `fetch` tests the stub.151- **A `__mocks__` file is never loaded unless `vi.mock()` names the module.** Jest's always-on behavior means calling152 `vi.mock` per module in a setup file.153154Read [`${CLAUDE_SKILL_DIR}/references/mocking.md`] when a mock does not take effect, leaks between tests, or has to work155in browser mode — it carries the hoisting transform, the automocking algorithm, the reset matrix, virtual modules, and156the per-environment differences.157158## Fake Timers159160- **Pair `vi.useFakeTimers()` with `vi.useRealTimers()`** in `beforeEach`/`afterEach`, or configure `fakeTimers`161 globally. A leaked fake clock breaks every later file in a non-isolated worker.162- **`vi.useFakeTimers()` fakes every timer API present on the global object** except `process.nextTick` and163 `queueMicrotask` — `performance` and `requestAnimationFrame` included, not only `setTimeout` and `Date`. Measured on164 Vitest 4.1.11.165- **`toFake` replaces that default set, it does not extend it.** `toFake: ['setTimeout']` leaves `Date` real, so the166 list must name every API the test needs. Measured on Vitest 4.1.11.167- **`nextTick` cannot be faked under the default `forks` pool.** Vitest 4.1.11 refuses it outright:168 `vi.useFakeTimers({ toFake: ["nextTick"] }) is not supported in node:child_process. Use --pool=threads if mocking nextTick is required.`169 It throws for `toFake` passed to the call and for `fakeTimers.toFake` set in the config alike, and it tests for a170 child process rather than a pool name. Take the fix the message names, or leave `nextTick` off the list — the v4 docs171 describe a hung process instead, which is the outcome this guard exists to prevent.172- **Advance deliberately.** `vi.advanceTimersToNextTimer()` fires one timer; `vi.runOnlyPendingTimers()` fires what is173 queued without following the chain; `vi.runAllTimers()` drains everything and throws after 10 000 iterations on an174 endless interval.175- **Use the `*Async` variants when the code under test awaits between timers** — they flush the microtask queue in176 between.177- **`vi.setSystemTime(date)` to fix the clock** without advancing any timer.178179Read [`${CLAUDE_SKILL_DIR}/references/mocking.md`] when a timer helper does not advance what it should — its fake-timer180section carries the full set of faked APIs, `fakeTimers.loopLimit`, and the `vi.setTimerTickMode` modes.181182## Snapshots183184- **Prefer `toMatchInlineSnapshot` for a small value.** It is reviewed in the same diff as the code that changed it.185- **Give every volatile field a property matcher**: `toMatchSnapshot({ id: expect.any(String) })`. A snapshot holding a186 timestamp trains everyone to run `-u`.187- **Never run `-u` on a red suite without reading the diff.** Updating is how a regression becomes the expected value.188- **A snapshot too large to read is a rubber stamp, not a test** — assert the handful of properties that matter instead.189- **In CI, a missing or obsolete snapshot fails the run**, not only a mismatch. An obsolete entry is one whose test was190 renamed or deleted; delete the entry in the same change.191192Read [`${CLAUDE_SKILL_DIR}/references/snapshots.md`] when writing a serializer, a custom snapshot matcher, or193reconciling snapshot output that differs from Jest — it carries the four snapshot forms, the serializer contract, and194the Jest output divergences.195196## Configuration197198- **`vitest.config.ts` overrides `vite.config.ts` entirely.** It is not merged. Where the app config must be shared,199 either put `test` inside `vite.config.ts` with `/// <reference types="vitest/config" />`, or `mergeConfig` the two200 deliberately.201- **`plugins` and `define` are Vite-only and sit at the top level**, never under `test`. `alias` and `server` exist in202 both places and mean different things: `test.alias` merges over `resolve.alias`, and `test.server.deps` controls203 inlining and externalization, not Vite's dev server. The Vitest 4 docs mark `test.server` deprecated without naming a204 replacement, and it is still where `deps.inline` and `deps.external` live.205- **Extend a default array rather than replacing it**: `exclude: [...configDefaults.exclude, ...]`.206- **Since Vitest 4, `exclude` covers only `node_modules` and `.git`.** `dist`, `cypress`, `.cache`, `.output` and the207 common `*.config.js` files are collected unless excluded. Narrow the search with `dir` instead — it limits where208 Vitest looks rather than filtering what it found.209210## Projects211212- **`projects` for anything that needs a second environment, pool, alias set or isolation setting.** It replaced213 `workspace` (deprecated 3.2, removed 4.0) and both `environmentMatchGlobs` and `poolMatchGlobs` (removed 4.0).214- **Every project needs a unique `name`.**215- **A project inherits nothing by default.** Add `extends: true` to take the root config, or compose with `mergeConfig`.216 Use `defineProject` rather than `defineConfig` in a project file for the narrower type.217- **`coverage`, `reporters` and `resolveSnapshotPath` are root-only** and silently ignored in a project config.218- **The root config is not itself a project** unless it is listed among them.219220Read [`${CLAUDE_SKILL_DIR}/references/configuration.md`] when creating a config file or laying out projects in a221monorepo — it carries the three config-file shapes, the defaults with their exact values, what a project entry may be,222and the naming rule a glob entry must satisfy.223224## Pools and Isolation225226- **Leave `pool: 'forks'` alone unless a measurement says otherwise.** It is the Vitest 4 default and the compatible227 one: `process.chdir` works and native modules do not segfault.228- **`Segmentation fault`, `Abort trap: 6` or `thread '<unnamed>' panicked` means switch off `threads`.** A native addon229 in a worker thread is the cause.230- **`vmThreads` and `vmForks` leak by construction** — ES modules are cached with no API to clear them, and native231 module globals differ, so `err instanceof Error` can be `false` across the boundary. `isolate` has no effect there.232- **`isolate: false` is the biggest single speed gain available to a `node`-environment suite, and its biggest233 correctness cost.** Module registry, `globalThis` mutations and module-level singletons persist across files in the234 same worker. Scope it to a project holding the files that hold no state, never to the whole run.235- **`fileParallelism: false` for a shared external resource**, not for a leak between files — it forces `maxWorkers`236 to 1.237- **`maxWorkers` replaced `maxThreads` and `maxForks` in Vitest 4**, and `VITEST_MAX_WORKERS` replaced the two matching238 environment variables. It accepts a percentage string.239240Read [`${CLAUDE_SKILL_DIR}/references/configuration.md`] when choosing a pool or scoping isolation to part of a suite —241it carries the four pools side by side, what isolation costs when it is off, and the experimental knobs with the release242each landed in.243244## Coverage245246- **Set `coverage.include` explicitly.** Vitest 4 removed `coverage.all`, so without it the report holds only files some247 test imported — an untested file simply vanishes and the percentage rises.248- **`v8` is the default and the right default.** Vitest 4 remaps its results through AST analysis — introduced behind a249 flag in 3.2, the only mode since 4.0 — which matches Istanbul's accuracy at lower cost. Choose `istanbul` for a non-V8250 runtime, or when the module count makes v8 slower.251- **Neither provider ships with Vitest**: `@vitest/coverage-v8` or `@vitest/coverage-istanbul`.252- **An ignore hint needs `-- @preserve`.** esbuild strips comments during transpilation, so `/* v8 ignore if */`253 disappears before the provider sees it; `/* v8 ignore if -- @preserve */` survives.254- **A negative threshold is a maximum count of uncovered items**, which ratchets a legacy codebase where a percentage255 cannot.256257Read [`${CLAUDE_SKILL_DIR}/references/coverage.md`] when tuning what appears in a report, setting thresholds, or merging258coverage across shards — it carries the provider trade-offs, the include/exclude semantics, and the full ignore-hint259syntax.260261## Type Testing262263- **Type tests are not executed.** Vitest runs `tsc` over `*.test-d.ts` and parses the diagnostics, so a name built by264 `test.each` is never evaluated.265- **`toEqualTypeOf<T>()` by default; `toMatchObjectType<T>()` for a strict object subset.** `toExtend` is the loose266 form, and `toMatchTypeOf` is deprecated in its favor.267- **Assert `.not.toBeAny()` on anything crossing a generic or library boundary.** `any` satisfies every other assertion,268 so a degraded type passes the whole file.269270Read [`${CLAUDE_SKILL_DIR}/references/type-testing.md`] when writing type tests or decoding an `expectTypeOf` error — it271carries the matcher set, the navigation chain, and the `typecheck` options.272273## Benchmarks274275- **`vitest bench` collects `*.bench.*` and `*.benchmark.*` files.** Benchmarking is experimental and outside SemVer —276 pin the version before depending on the output shape.277- **Compare against a stored baseline**: `vitest bench --outputJson main.json` on the base branch, `--compare main.json`278 on the change.279- **Options come from tinybench** — `time` (500 ms), `iterations` (10), `warmupTime` (100 ms), `warmupIterations` (5),280 plus `setup` and `teardown` per cycle.281282## Browser Mode283284- **Browser mode is stable as of Vitest 4** and is the honest way to test anything that depends on layout, focus, or285 real pointer events. `jsdom` and `happy-dom` remain right for code that only touches the DOM API.286- **The provider is a factory from its own package**, not a string: `provider: playwright()` from287 `@vitest/browser-playwright`. Vitest 4 dropped `@vitest/browser` and moved the context imports to `vitest/browser`.288- **`vi.spyOn` on a module namespace throws in the browser.** Native ESM seals it. Use `vi.mock(path, { spy: true })`.289- **Put browser tests in their own project** so the Node suite does not pay for a browser it does not use.290291Read [`${CLAUDE_SKILL_DIR}/references/browser-mode.md`] when setting up browser mode, writing locator queries, or adding292visual regression tests — it carries the provider packages, the locator surface, and the screenshot stability293constraints.294295## Running Vitest296297- **Always `vitest run` from a script, a CI job, or an agent.** Bare `vitest` watches whenever stdin is a TTY and `CI`298 is unset; that detection is fragile, and a watching process never exits.299- **Filter narrowly rather than rerunning everything**: a path substring, `-t <regex>` against the full name including300 `describe` titles, or `file.test.ts:42` to run the test containing a line.301- **`--changed [ref]` while iterating**, defaulting to uncommitted work and accepting a commit or branch.302- **`--shard=i/n` with `--reporter=blob`, then one `vitest --merge-reports`.** Sharding splits files, never cases.303- **`--detect-async-leaks` (Vitest 4.1) when a suite hangs or flakes** — it names the leaked handles and their source304 locations, at a runtime cost that makes it a debugging tool rather than a default.305306Read [`${CLAUDE_SKILL_DIR}/references/configuration.md`] when a filter selects the wrong files, or when setting up307sharding or test tags — it carries the exact matching rule for every filter form, the blob and merge flow, and the tag308declaration and priority resolution.309310## Migrating from Jest311312Three behavioral differences catch a mechanical `jest.` → `vi.` rename:313314- **`mockReset` restores the original implementation** rather than replacing it with a stub returning `undefined`.315- **A `vi.mock` factory returns the module namespace**, with `default` as an explicit key, not the default export316 itself.317- **A function returned from `beforeEach` is teardown**, so a concise arrow body changes meaning.318319Read [`${CLAUDE_SKILL_DIR}/references/jest-migration.md`] when porting a suite from Jest, or from Mocha, Chai and Sinon320— it carries the full API translation, the globals and auto-mocking differences, and the snapshot output divergences.321322## Application323324When **writing** tests, apply these conventions silently — do not narrate a rule while following it. Where the existing325suite contradicts one, follow the suite and flag the divergence once.326327When **reviewing** tests, cite the violation and show the fix inline. Do not lecture.328329```330Bad: "Vitest best practice suggests awaiting async assertions..."331Good: expect(fetchUser(1)).rejects.toThrow() -> await expect(fetchUser(1)).rejects.toThrow()332```333334## Integration335336The **coding** skill decides what is worth testing and when; this skill decides how a Vitest test is written. Both are337active at once.338339This skill covers Vitest and nothing else. `node:test` belongs to the **nodejs** skill and `bun:test` to the **bun**340skill, because each ships with its runtime. `expectTypeOf` and the `typecheck` options are this skill's, being its own341API and its own config keys; the type system they exercise is the **typescript** skill's, and the language under test is342the **javascript** skill's.343344**When in doubt, mock less.**