packages/vite/src/migrations/update-23-0-0/ai-instructions-for-vitest-3.md
These instructions guide you through migrating an Nx workspace containing multiple Vitest projects to Vitest 3.0. The workspace may currently be on Vitest 1.x or Vitest 2.x. This guide covers breaking changes for both upgrade paths:
Work systematically through each breaking change category.
<pre_pass_summary note="a deterministic pre-pass already applied these specific edits; verify the new shape is in place rather than redoing them">
The pre-pass handled, mechanically:
--segfault-retry removal from package.json scripts AND project.json options.{args,command,commands}@vitest/coverage-c8 → @vitest/coverage-v8 package rename (preserving the user's pin)vitest typecheck → vitest --typecheck in package.json scripts AND project.json options.{args,command,commands}SnapshotEnvironment import path 'vitest' → 'vitest/snapshot' (only when it is the sole named binding)browser.provider: 'none' → 'preview' (only when the value is a direct string literal under test.browser.provider)browser.indexScripts → orchestratorScripts (only as a direct property name)The pre-pass does not edit CI provider configs (.github/workflows/*.yml, .gitlab-ci.yml, azure-pipelines.yml, .circleci/config.yml, bitbucket-pipelines.yml) — YAML structure varies too much. Any matches it finds there are forwarded to you in <advisory_context>.
The vast majority of action items below are NOT covered by the pre-pass and still require your attention — every section other than the six items above.
How to read the wrapper sections above this file:
<files_changed> lists files the pre-pass already wrote to. Verify the new shape is in place; do not re-apply the same edit.<advisory_context> lists detections the pre-pass forwarded because it could not safely complete them. Every entry is pending work — address each one in the relevant section below, not as a separate task.</pre_pass_summary>
<handoff_guidance>
In your handoff summary (1–3 sentences per the system prompt), name the breaking-change categories you applied; explicitly call out any you skipped because they didn't apply (e.g., "no browser-mode configs in this workspace").
</handoff_guidance>
Identify the current Vitest version:
npx vitest --version
Identify all Vitest projects:
nx show projects --with-target test
Locate all Vitest configuration files:
vitest.config.{ts,js,mjs}vitest.workspace.{ts,js,mjs} (deprecated in Vitest 3.2 — see "v3.2 Workspace File Deprecation" below)project.json files for @nx/vitest:test / @nx/vite:test executor options@nx/vitest/plugin), targets come from inference — inspect them with nx show project <name> --json | jq .targetsIdentify affected code:
**/*.{spec,test}.{ts,js,tsx,jsx}vi.fn(), vi.spyOn(), vi.mock(), vi.useFakeTimers()@nx/vitest/plugin (or @nx/vite/plugin historically) to infer the test target from the presence of a vitest.config.* file. project.json may have no test target at all. Renaming or moving vitest.config.* invalidates inference. After config edits, run nx reset && nx show project <name> on a sample project to confirm the target is still present.vitest.config.base.ts via mergeConfig. Apply transforms to the BASE config first, then per-project overrides. Otherwise a migrated base may conflict with un-migrated children.@analogjs/vitest-angular, @analogjs/vite-plugin-angular): the Analog packages are bumped automatically by Nx's packageJsonUpdates. Review the Analog-specific setup file (typically src/test-setup.ts) and any plugin invocations in the per-project vitest.config.ts for changes between Analog ~1.x and ~2.x lines.@nx/vitest/plugin is configured with ciTargetName, per-test-file targets are inferred — your .github/workflows/*.yml doesn't need direct reporter changes. CI-side reporter config matters only if you bypass the plugin.Skip this section if your workspace is already on Vitest 2.x.
threads to forksSearch Pattern: poolOptions in all vitest.config.* files
What Changed: The default pool switched from threads to forks for improved stability. Existing poolOptions.threads configurations now apply to the non-default pool unless pool is explicitly set.
// ❌ BEFORE (Vitest 1.x — relying on implicit `threads` default)
export default defineConfig({
test: {
poolOptions: {
threads: { singleThread: true },
},
},
});
// ✅ AFTER (Vitest 2.0 — either explicitly set the pool or move options to `forks`)
export default defineConfig({
test: {
poolOptions: {
forks: { singleFork: true },
},
},
});
Action Items:
threads (set pool: 'threads' explicitly) or move options to forks.poolOptions.threads.singleThread → poolOptions.forks.singleFork if switching.poolOptions.threads.maxThreads/minThreads → poolOptions.forks.maxForks/minForks if switching.<fail_if note="this decision changes runtime semantics; if you can't determine the project's intent from the workspace, write status: failed and explain in summary">
You cannot determine whether the workspace intended the v1.x implicit-threads behavior (e.g., the codebase uses worker-only APIs like SharedArrayBuffer) or expected the v2.x forks default. Do not guess.
</fail_if>
after*)Search Pattern: beforeAll, beforeEach, afterAll, afterEach usages relying on parallel execution
What Changed: Hooks moved from parallel to serial execution. afterAll/afterEach now run in reverse declaration order. Tests relying on parallel hook side effects or specific teardown ordering may break.
// To revert to parallel behavior:
export default defineConfig({
test: {
sequence: {
hooks: 'parallel',
},
},
});
Action Items:
afterAll/afterEach hooks that depend on registration order; their order is now reversed.sequence.hooks: 'parallel' only if you cannot otherwise resolve hook-order dependencies.Search Pattern: describe.concurrent, suite.concurrent
What Changed: Declaring concurrent on a suite now runs all its tests concurrently (Jest-aligned) instead of grouping by suite. Bound by maxConcurrency.
Action Items:
maxConcurrency if needed.ignoreEmptyLines On by DefaultSearch Pattern: coverage configuration in vitest.config.* (V8 provider)
What Changed: coverage.ignoreEmptyLines defaults to true. Coverage thresholds may shift.
// To restore the previous behavior:
export default defineConfig({
test: {
coverage: {
ignoreEmptyLines: false,
},
},
});
Action Items:
coverage.ignoreEmptyLines: false only if you need the prior numbers exactly.watchExclude Option RemovedSearch Pattern: watchExclude in vitest.config.*
// ❌ BEFORE (Vitest 1.x)
export default defineConfig({
test: {
watchExclude: ['node_modules', 'custom/path/**'],
},
});
// ✅ AFTER (Vitest 2.0)
export default defineConfig({
server: {
watch: {
ignored: ['**/node_modules/**', 'custom/path/**'],
},
},
});
Pattern Semantics Note: server.watch.ignored uses chokidar patterns, while Vitest 1.x's watchExclude accepted simpler relative-path matchers. A literal find-and-replace may over- or under-ignore. Treat each entry as manual review:
'node_modules' typically needs '**/node_modules/**' to match nested occurrences./** are usually portable as-is.nx test <project> --watch.Action Items:
test.watchExclude entry to server.watch.ignored, adjusting pattern syntax to chokidar conventions.watchExclude from vitest.config.*.<fail_if note="pattern semantics differ between the two options; a blind copy can over- or under-ignore files">
An existing watchExclude entry uses a pattern whose chokidar equivalent is ambiguous (e.g., a relative path without ** wrapping, an entry that may need both **/foo/** and foo/**). Write status: failed with the specific patterns you're unsure about; the user should decide.
</fail_if>
--segfault-retry CLI Flag RemovedWhat Changed: The CLI flag was removed; the underlying issue is fixed by switching to the forks pool (now the default).
Action Items:
--segfault-retry from scripts, project.json test target options, and CI configuration..suite Now Optional, .testPath RequiredSearch Pattern: Custom reporters and tooling that traverse the task tree
What Changed: Top-level task .suite is now optional. Use .file (available on all tasks) instead. expect.getState().testPath is now always populated; expect.getState().currentTestName no longer includes the file name.
Action Items:
.suite chains with .file where the top-level task could be a file root.currentTestName, switch to testPath.task.metaWhat Changed: Output shape gained task.meta per assertion result.
Action Items:
task.meta field (additive — most consumers will be unaffected).Search Pattern: vi.fn<...>(...), Mock<...>
// ❌ BEFORE (Vitest 1.x)
import { type Mock, vi } from 'vitest';
const add = (x: number, y: number): number => x + y;
const mockAdd = vi.fn<Parameters<typeof add>, ReturnType<typeof add>>();
const mockAdd2: Mock<Parameters<typeof add>, ReturnType<typeof add>> = vi.fn();
// ✅ AFTER (Vitest 2.0)
const mockAdd = vi.fn<typeof add>();
const mockAdd2: Mock<typeof add> = vi.fn();
Action Items:
vi.fn<Args, Return>() with single-generic vi.fn<typeof fn>() everywhere.Mock<Args, Return> with single-generic Mock<typeof fn> everywhere.mock.results No Longer Auto-Resolves PromisesSearch Pattern: .mock.results accesses on async mocks
What Changed: For mocks returning Promises, mock.results now contains the Promise itself. Use the new mock.settledResults for resolved values.
// ❌ BEFORE (Vitest 1.x)
const fn = vi.fn().mockResolvedValueOnce('result');
await fn();
const result = fn.mock.results[0]; // 'result' (auto-resolved)
// ✅ AFTER (Vitest 2.0)
const result = fn.mock.results[0]; // a Promise
const settled = fn.mock.settledResults[0]; // 'result'
Action Items:
.mock.results[i] reads with .mock.settledResults[i] for promise-returning mocks.toHaveResolved* matchers where appropriate.Search Pattern: browser.provider, browser.indexScripts in vitest.config.*
What Changed: The none provider was renamed to preview and is now the default. indexScripts was renamed to orchestratorScripts.
Action Items:
browser.provider: 'none' → browser.provider: 'preview'.browser.indexScripts → browser.orchestratorScripts.Action Items:
vitest typecheck command usages with vitest --typecheck.VITEST_JUNIT_CLASSNAME and VITEST_JUNIT_SUITE_NAME env vars; move equivalent values into JUnit reporter options.SnapshotEnvironment, change the import path from vitest to vitest/snapshot.SpyInstance type imports with MockInstance.c8 as a coverage provider, switch to v8 (@vitest/coverage-v8).Search Pattern: test('name', () => {...}, { ... }), describe('name', () => {...}, { ... })
What Changed: Test/describe options objects must now be passed as the second argument, not the third.
// ❌ BEFORE (Vitest 2.x)
test(
'validation works',
() => {
/* ... */
},
{ retry: 3 }
);
// ✅ AFTER (Vitest 3.0)
test('validation works', { retry: 3 }, () => {
/* ... */
});
A numeric timeout value as the third argument is still accepted (test('name', () => {}, 1000)).
Action Items:
test, it, describe, and their variants (.skip, .only, .each, etc.).browser.instancesSearch Pattern: browser.name, browser.providerOptions in vitest.config.*
What Changed: browser.name and browser.providerOptions are deprecated in v3 (still work, emit warnings) and removed in v4. Use browser.instances instead. Migrating now silences the v3 warnings and is required before reaching v4.
// ❌ BEFORE (Vitest 2.x)
export default defineConfig({
test: {
browser: {
name: 'chromium',
providerOptions: {
launch: { devtools: true },
},
},
},
});
// ✅ AFTER (Vitest 3.0)
export default defineConfig({
test: {
browser: {
instances: [
{
browser: 'chromium',
launch: { devtools: true },
},
],
},
},
});
Action Items:
browser.name + browser.providerOptions into a single entry in browser.instances.browser.name and browser.providerOptions.mockReset() Restores Original ImplementationSearch Pattern: .mockReset(), mockReset: true in vitest.config.*
What Changed: spy.mockReset() now restores the original implementation rather than replacing it with a noop returning undefined.
// Behavior change illustration:
const foo = { bar: () => 'Hello, world!' };
vi.spyOn(foo, 'bar').mockImplementation(() => 'Hello, mock!');
foo.bar(); // 'Hello, mock!'
foo.bar.mockReset();
foo.bar(); // BEFORE: undefined → AFTER: 'Hello, world!'
Action Items:
undefined; explicitly mock to a noop if needed (vi.spyOn(foo, 'bar').mockReturnValue(undefined)).mockReset: true globally, expect spied methods to return their original implementation between tests.vi.spyOn() Reuses Existing MocksSearch Pattern: Repeated vi.spyOn(obj, 'method') on the same target
What Changed: Calling vi.spyOn() on an already-mocked method now returns the existing mock rather than creating a new one. After vi.restoreAllMocks(), the method is no longer a mock.
vi.spyOn(fooService, 'foo').mockImplementation(() => 'bar');
vi.spyOn(fooService, 'foo').mockImplementation(() => 'bar');
vi.restoreAllMocks();
vi.isMockFunction(fooService.foo);
// BEFORE: true (the second spy survived restore)
// AFTER: false (both calls referenced the same spy)
Action Items:
restoreAllMocks — they will now see the original.Search Pattern: vi.useFakeTimers() usages and fakeTimers.toFake config
What Changed: Vitest now mocks all timer-related APIs by default (the previously-restricted built-in subset is gone). This includes performance.now(). Only nextTick is left unmocked. To restore the prior, narrower subset, configure fakeTimers.toFake explicitly.
// To restore the prior subset:
export default defineConfig({
test: {
fakeTimers: {
toFake: [
'setTimeout',
'clearTimeout',
'setInterval',
'clearInterval',
'setImmediate',
'clearImmediate',
'Date',
],
},
},
});
Action Items:
vi.useFakeTimers() that observe performance.now() or other newly-faked APIs.fakeTimers.toFake if a test should leave specific timer APIs real.Search Pattern: toEqual(new Error(...)), toThrowError(new Error(...))
What Changed: Error comparisons via toEqual() and toThrowError() now check name, message, cause, AggregateError.errors, and prototype.
// Cause is checked asymmetrically:
expect(new Error('hi', { cause: 'x' })).toEqual(new Error('hi')); // ✅ passes
expect(new Error('hi')).toEqual(new Error('hi', { cause: 'x' })); // ❌ fails
// Prototype is checked:
expect(() => {
throw new TypeError('type error');
})
.toThrowError(new Error('type error')) // ❌ fails (Error vs TypeError)
.toThrowError(new TypeError('type error')); // ✅ passes
Action Items:
Error to match a subclassed throw — use the matching subclass.cause/name that the actual error lacks.module Condition Excluded from resolve.conditionsApplies when: Workspace is on Vite 6 (and Vitest 3 with Vite 6).
What Changed: module is excluded from resolve.conditions by default to align with the upstream Vite 6 migration.
Action Items:
module condition for tests, add it explicitly to resolve.conditions in your Vitest config.onTestFinished / onTestFailed Receive ContextSearch Pattern: onTestFinished, onTestFailed usages in custom reporters or test utilities
What Changed: These hooks now receive a test context as the first argument, matching beforeEach/afterEach.
Action Items:
onTestFinished/onTestFailed callbacks to take a context argument and access the previous "result" through it.Custom Type Deprecated; WorkspaceSpec RemovedSearch Pattern: import { Custom, WorkspaceSpec } from 'vitest'
What Changed: Custom is now an alias for Test; prefer RunnerCustomCase and RunnerTestCase. WorkspaceSpec is removed — use TestSpecification instead. Tasks created via getCurrentSuite().custom() now have type: 'test'.
Action Items:
import { Custom } from 'vitest': most usages should become RunnerTestCase (the regular test case type — Custom was an alias for Test). Only use RunnerCustomCase if the workspace explicitly creates tasks via getCurrentSuite().custom().WorkspaceSpec references with TestSpecification.resolveConfig() API Shape ChangedSearch Pattern: import { resolveConfig } from 'vitest/node' (or similar) used by custom tooling
What Changed: resolveConfig() now takes user configuration and returns a resolved configuration, rather than taking an already-resolved Vite config.
Action Items:
resolveConfig(), pass user config (not already-resolved Vite config) and consume the resolved return value.vitest/reporters Export Slimmed DownSearch Pattern: Type imports from vitest/reporters
What Changed: vitest/reporters now exports only reporter implementations and their option types. Task types (TestCase, TestSuite, etc.) moved.
Action Items:
TestCase, TestSuite, related) from vitest/reporters to vitest/node.Search Pattern: coverage.excludes in vitest.config.* attempting to include test files
What Changed: Test files are always excluded from coverage, even if coverage.excludes is customized.
Action Items:
coverage.excludes patterns that were trying to surface test files in coverage — they no longer apply.Applies when: A workspace consumes @vitest/snapshot directly (custom snapshot tooling).
What Changed: The public Snapshot API in @vitest/snapshot was restructured to support multiple states within a single run. The .toMatchSnapshot() matcher API is unchanged.
Action Items:
@vitest/snapshot, review your usage against the v3 API.Applies when: Workspace has vitest.workspace.{ts,js,mjs} files, uses defineWorkspace, or has test.workspace set in vitest.config.*.
What Changed:
test.projects as the config option, with test.workspace deprecated in favor of it (the option emits a deprecation warning).vitest.workspace.* + defineWorkspace) is removed entirely. Projects must be inlined in the root vitest.config.*.Migrating now (while still on v3) clears the v3.2 deprecation warning and is required before reaching v4.
// ❌ DEPRECATED (Vitest 3.2+)
// vitest.workspace.ts
import { defineWorkspace } from 'vitest/config';
export default defineWorkspace(['apps/*', 'libs/*']);
// ✅ AFTER (inline in root vitest.config.ts)
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: ['apps/*', 'libs/*'],
},
});
Action Items:
vitest.workspace.* into test.projects in the root vitest.config.*.vitest.workspace.* file once migration is complete.defineWorkspace anywhere, replace with defineConfig and the inlined shape above.pnpm install (or npm install / yarn install)nx affected -t testnx run <project>:test --coveragenx affected -t typecheckserver.watch.ignored covers what watchExclude previously did.Confirm:
summary if they remain failing — see <test_integrity_guardrails> below)<test_integrity_guardrails note="violating any of these masks regressions and defeats the migration's purpose">
expect(true).toBe(true).If a test cannot be made to pass within the scope of this migration, leave it failing and report it in your handoff summary.
</test_integrity_guardrails>