SuperAPI AI Directory

vitest

Testing & quality

View repository

Updated 10-16 06:13

About this skill

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.