---
url: /guide/migration.md
---

# Migrating to Vitest 5.0 {#vitest-5}

[Migrating to Vitest 4.0](https://v4.vitest.dev/guide/migration) | [Migrating to Vitest 3.0](https://v3.vitest.dev/guide/migration)

::: warning Prerequisites
Vitest 5.0 requires Vite >= 6.4.0 and Node.js >= 22.12.0. Before proceeding with any other migration steps, ensure your environment meets these requirements. Running Vitest 5.0 on older versions of Vite or Node.js is not supported and may result in unexpected errors.
:::

## Yarn Users Must Install `vite` Explicitly

`vite` is no longer a direct dependency of `vitest`. It is now a required peer dependency, so the version of Vite used by Vitest is the one installed in your project. npm, pnpm, Bun, and Deno install peer dependencies automatically. Yarn does not, so after the upgrade Vitest cannot resolve `vite` unless it is listed in your `package.json`:

```bash
yarn add -D vite
```

## `clearMocks` is Enabled by Default

[`clearMocks`](/config/clearmocks) now defaults to `true`: Vitest calls [`vi.clearAllMocks()`](/api/vi#vi-clearallmocks) before every test, clearing the recorded history of every mock while leaving implementations intact.

In practice this means a mock no longer carries calls from one test into the next:

```ts
import { expect, test, vi } from 'vitest'

const fn = vi.fn()

test('first', () => {
  fn()
  expect(fn).toHaveBeenCalledTimes(1)
})

test('second', () => {
  fn()
  // v4: the call from "first" was kept, so this was 2 // [!code --]
  expect(fn).toHaveBeenCalledTimes(2) // [!code --]
  // v5: history is cleared before each test, so only this test's call counts // [!code ++]
  expect(fn).toHaveBeenCalledTimes(1) // [!code ++]
})
```

Tests that record calls outside of the test body (for example in a setup file, at the top level of a module, or in a `beforeAll` hook) are the most affected, because that history is cleared before the test that asserts on it runs.

To keep the previous behavior, set `clearMocks` back to `false`:

```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    clearMocks: false, // [!code ++]
  },
})
```

## `testNamePattern` Matches the `>`-Joined Full Name

[`testNamePattern`](/config/testnamepattern) (the `-t` CLI flag) now matches against the test's full name with the suite chain and test name joined by `' > '`, the same string shown in the reporter output. Previously the segments were joined with a single space, mirroring Jest.

Only patterns that span the boundary between two segments are affected:

```ts
describe('math', () => {
  test('adds', () => {})
})
```

```bash
vitest -t 'math adds' # [!code --]
vitest -t 'math > adds' # [!code ++]
```

To keep a pattern working regardless of the separator, match a single segment (`-t adds`) or use a wildcard between segments (`-t 'math.*adds'`).

## Inline Projects Inherit the Root Config by Default

The [`extends`](/guide/projects#configuration) option now defaults to `true`: every project defined as an inline configuration in [`test.projects`](/guide/projects) inherits all options from the root configuration, including Vite options like `plugins` or `resolve.alias`. The options are merged with the same rules that applied to an explicit `extends: true` in Vitest 4:

```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    projects: [
      {
        // v4: this project didn't apply the react plugin
        // v5: the plugin is inherited from the root config
        test: {
          name: 'unit',
          include: ['**/*.unit.test.ts'],
        },
      },
    ],
  },
})
```

Projects referenced as config files or directories are not affected; they still don't inherit any options from the root config.

Keep in mind that arrays are merged, not overridden: if the root config defines `setupFiles`, the project's own `setupFiles` are appended to the inherited ones. See [the projects guide](/guide/projects#configuration) for the merge rules and the few options that are never inherited. If you need the previous behavior, set `extends: false` in the project configuration:

```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    setupFiles: ['./setup.global.ts'],
    projects: [
      {
        extends: false, // [!code ++]
        test: {
          name: 'unit',
          setupFiles: ['./setup.unit.ts'],
        },
      },
    ],
  },
})
```

## Referenced Config Files Can Define Their Own Projects

A config file referenced in [`test.projects`](/guide/projects) that declares `projects` itself now provides the [nested projects](/guide/projects#nested-projects) it declares (named `app (unit)`, `app (e2e)`, and so on) instead of running tests as a single project.

In Vitest 4 the `projects` field of a referenced config was silently ignored. Check that your project configs don't carry a `projects` field unknowingly. The most common way to do that is merging a config that defines it:

```ts [packages/app/vitest.config.ts]
import { defineProject, mergeConfig } from 'vitest/config'
import rootConfig from '../../vitest.config' // [!code --]
import sharedConfig from '../../vitest.shared' // [!code ++]

export default mergeConfig(
  // the root config defines `test.projects`, so merging it
  // would turn this project into a container for those projects
  rootConfig, // [!code --]
  sharedConfig, // [!code ++]
  defineProject({
    test: {
      environment: 'jsdom',
    },
  }),
)
```

Since the inherited `projects` paths resolve relative to the referenced config, this misconfiguration usually fails loudly at startup with `Projects definition references a non-existing file or a directory`, `No projects were found in "..."`, or a circular `projects` definition error.

Inline configurations continue to ignore the `projects` field at runtime, but it is now also excluded from their `ProjectConfig` type.

## Inline Projects Share the Vite Server by Default

Inline projects that don't modify the Vite config now reuse the Vite server of the config that declares them instead of resolving a new Vite config and creating a new server per project, so shared files are transformed once and tests run faster. This is controlled by the new [`sharedViteServer`](/config/sharedviteserver) option, which is enabled by default; its documentation lists the exact options that still give a project its own server.

Note that this *only* applies to inline projects. Projects referenced as config files or directories always resolve their own Vite config and create their own server, exactly as before.

The observable change: when the server is shared, the declaring config file is executed once instead of once per project, so its plugins are instantiated once and their `config` hooks no longer run for every project. If a plugin relies on being re-instantiated per project, disable the sharing:

```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    sharedViteServer: false, // [!code ++]
    projects: [
      { test: { name: 'unit' } },
      { test: { name: 'integration' } },
    ],
  },
})
```

## Hoisted Mocking Calls Must Be at the Top Level

[`vi.mock`](/api/vi#vi-mock), [`vi.unmock`](/api/vi#vi-unmock), and [`vi.hoisted`](/api/vi#vi-hoisted) are hoisted to the top of the file and run before any surrounding code. Calling them inside a function, block, or `describe`/`test` callback previously only logged a warning. Vitest 5.0 now throws, because the call does not execute where it is written:

```ts
describe('calculator', () => {
  vi.mock('./calculator') // [!code --]
})

vi.mock('./calculator') // [!code ++]

describe('calculator', () => {
  // ...
})
```

The error reports every offending call and its location:

```
1 call in "calculator.test.ts" was defined outside of the module's top level scope:

- vi.mock("./calculator") at calculator.test.ts:2:3

Although it appears nested, it will be hoisted and executed before anything in this file. Move it to the top level to reflect its actual execution order.
```

The dynamic variants [`vi.doMock`](/api/vi#vi-domock) and [`vi.doUnmock`](/api/vi#vi-dounmock) are not hoisted and may still be called anywhere.

## Automocked Modules Stay Automocked in the Browser

In browser mode, the exports of an automocked module (a [`vi.mock`](/api/vi#vi-mock) call with no factory) incorrectly kept calling the real implementation instead of the auto-generated stubs. If a browser test relied on that, its exports now return `undefined` by default. Pass [`{ spy: true }`](/api/vi#vi-mock) to keep calling the real implementation while still tracking calls, or provide a factory with the behavior you need.

## Class Mocks Keep Prototype Methods

Instances created from a class mock previously inherited from the mock's own empty `prototype`. Methods defined with the regular class syntax were `undefined` on instances, even inside the constructor, and `instanceof` checks against the implementation class failed. This affected [`vi.fn(Dog)`](/api/vi#vi-fn), `vi.spyOn(obj, 'Dog')` with or without a mock implementation, and [`.mockImplementation(class ...)`](/api/mock#mockimplementation).

The mock's `prototype` is now chained to the implementation's prototype, so instances behave like instances of the implementation class:

```ts
class Dog {
  speak() {
    return 'bark!'
  }
}

const MockedDog = vi.fn(Dog)
const dog = new MockedDog()

typeof dog.speak // was 'undefined', now 'function'
dog instanceof Dog // was false, now true
dog instanceof MockedDog // true, as before

// the chain is visible on the mock itself
Object.getPrototypeOf(MockedDog.prototype) // was Object.prototype, now Dog.prototype
```

Overriding methods on the mock's `prototype` still works and shadows the implementation, and [`mockReset`](/api/mock#mockreset) reverts the chain together with the implementation. See [Mocking Classes](/guide/mocking/classes) for details.

## Benchmarking API Rewrite

The benchmarking API has been rewritten. `bench` is no longer a top-level import from `vitest`; it is a [test-context fixture](/guide/test-context#bench) accessed from inside a regular `test()`. See the [Benchmarking guide](/guide/benchmarking) for the new API.

Removed, with replacements where applicable:

* **`bench(name, fn)` at module scope**: destructure `bench` from the test context instead.

```ts
// v4
import { bench } from 'vitest' // [!code --]

bench('sort', () => { // [!code --]
  [3, 1, 2].sort() // [!code --]
}) // [!code --]

// v5
import { test } from 'vitest' // [!code ++]

test('sort', async ({ bench }) => { // [!code ++]
  await bench('sort', () => { [3, 1, 2].sort() }).run() // [!code ++]
}) // [!code ++]
```

* **`bench.skip`, `bench.only`, `bench.todo`** are removed. Use the regular `test.skip`, `test.only`, `test.todo` on the surrounding `test()` instead.
* **`benchmark.reporters` / `benchmark.outputFile`** are removed. Benchmark output is now part of the default reporter and the `json` reporter; configure those at the top level via `test.reporters` instead.
* **`benchmark.compare` config and the `--compare` CLI flag** are removed. Pass [`writeResult`](/guide/benchmarking#storing-and-replaying-results) as a per-bench option to persist a result, and read it back with [`bench.from()`](/guide/benchmarking#bench-from) inside `bench.compare()`.
* **`benchmark.outputJson` config and the `--outputJson` CLI flag** are removed. Use `--reporter=json --outputFile=<path>` to capture benchmark results; the JSON reporter now includes a `benchmarks` field on each test case.
* **`Vitest` instance `mode` property** is now always `'test'`. The previous `'benchmark'` value is no longer used; benchmarks run inside a dedicated project of the same `Vitest` instance.

## Vitest UI Requires an Authenticated URL

Vitest UI now requires token authentication for the HTML page and API access. The `/__vitest__/` URL will show an error until the browser is authenticated. To authenticate, open the URL with a token printed by Vitest, as shown below. Once authenticated, the direct `/__vitest__/` URL will work correctly.

```bash
vitest --ui
# UI started at http://localhost:51204/__vitest__/?token=...
```

## Fake Timers and `setSystemTime` Now Mock `Temporal`

Vitest now mocks the [`Temporal`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal) API alongside `Date`, following the [`@sinonjs/fake-timers` v15.4 update](https://github.com/sinonjs/fake-timers/blob/main/CHANGELOG.md#1540--2026-05-05). This only takes effect when `Temporal` is available on the global object, natively or through a globally installed polyfill such as `import 'temporal-polyfill/global'`.

Previously `Temporal.Now` kept returning the real wall-clock time even when [`vi.useFakeTimers()`](/api/vi#vi-usefaketimers) was active. Now it follows the mocked clock:

```ts
vi.useFakeTimers({ now: 0 })

Temporal.Now.instant().epochMilliseconds // 0 (was the real time in v4)
```

The same applies to [`vi.setSystemTime()`](/api/vi#vi-setsystemtime), which previously mocked only `Date` when used without fake timers:

```ts
vi.setSystemTime(0)
Temporal.Now.instant().epochMilliseconds // 0 (was the real time in v4)
```

`Temporal` is part of the default set of faked APIs, so it is controlled by [`fakeTimers.toFake`](/config/faketimers#faketimers-tofake) and [`fakeTimers.toNotFake`](/config/faketimers#faketimers-tonotfake). To keep `Temporal` native, add it to `toNotFake`:

```ts
vi.useFakeTimers({ toNotFake: ['Temporal'] })
```

## `toThrow("")` Matches Any Error Message

[`toThrow`](/api/expect#tothrow) (and its alias `toThrowError`) treats a string argument as a substring of the error message. In Vitest 4 an empty string was special-cased to the `/^$/` pattern, so it matched only an error whose message was empty. It now behaves like any other substring, and an empty string is contained in every message:

```ts
expect(() => { throw new Error('boom') }).not.toThrow('') // [!code --]
expect(() => { throw new Error('boom') }).toThrow('') // [!code ++]
```

To assert that a thrown error has an empty message, match the pattern explicitly:

```ts
expect(() => { throw new Error('boom') }).not.toThrow(/^$/)
```

## Assertion Types Expose Return and Received Types

Assertion interfaces now use two type parameters: `R` is the matcher return type and `T` is the received value type. Synchronous assertions use `void`, while assertions accessed through `.resolves`, `.rejects`, [`expect.poll`](/api/expect#poll), or [`expect.element`](/api/browser/assertions) use `Promise<void>`.

If you declare custom matchers, augment the `Matchers<R, T>` interface as shown in [Extending Matchers](/guide/extending-matchers). It adds the matcher to instance assertions, asymmetric matchers, and the type accepted by `expect.extend`, and the matcher's return type reflects how it is used: `void` when called synchronously, `Promise<void>` through `.resolves` or `.rejects`.

Code that refers to assertion types directly must also provide the return type first:

```ts
Assertion<string> // [!code --]
Assertion<void, string> // [!code ++]
Assertion<Promise<void>, string> // asynchronous assertion
```

Vitest no longer reads custom matcher declarations from the global `jest.Matchers` interface. Libraries that support both Jest and Vitest should augment `jest.Matchers` and `vitest.Matchers` separately. This only affects TypeScript declarations; registering matchers with `expect.extend` works as before.

## `expect.poll` Fails When It Times Out

[`expect.poll`](/api/expect#poll) now rejects when its callback, or the polled assertion, does not settle within `timeout`. Previously a callback that resolved after the deadline, or an assertion that only passed on a late attempt, could still succeed. The callback now also receives an `AbortSignal` that aborts when the timeout elapses, so you can cancel in-flight work:

```ts
await expect.poll(async ({ signal }) => {
  const response = await fetch('/api/status', { signal })
  return response.status
}, { timeout: 1000 }).toBe(200)
```

A poll that legitimately needs more time should raise its `timeout`. Otherwise it fails with `expect.poll() function didn't resolve in time.` (or `expect.poll() assertion didn't resolve in time.`).

## Unawaited Asynchronous Assertions Fail the Test

Asynchronous assertions, like `resolves`, `rejects` and `toMatchFileSnapshot`, now fail the test if they are not awaited. Previously, Vitest auto-awaited them at the end of the test and printed a warning:

```ts
test('unawaited assertion', async () => {
  // v4: prints a warning, the test passes // [!code --]
  // v5: the test fails // [!code ++]
  expect(promise).resolves.toBe(1) // [!code --]
  await expect(promise).resolves.toBe(1) // [!code ++]
})
```

The reported error points to the assertion that was not awaited.

## Test Titles and Inspected Values Use `pretty-format`

Vitest now formats values with [`pretty-format`](https://www.npmjs.com/package/pretty-format) instead of `loupe` when it inspects them, including the values interpolated into [`test.each`](/api/test#test-each) and [`test.for`](/api/test#test-for) titles. The rendering of some values changes, so snapshots or assertions that capture inspected output may need updating.

Two changes are specific to generated test titles:

* A string value interpolated through a `$` placeholder is no longer wrapped in quotes:

```ts
test.for([{ id: 'a1' }])('case $id', ({ id }) => { /* ... */ })
// v4 title: case 'a1' // [!code --]
// v5 title: case a1   // [!code ++]
```

* The length limit for interpolated values is now controlled by the new [`taskTitleValueFormatTruncate`](/config/tasktitlevalueformattruncate) option (default `40`).

## Removed `test.sequential`, `describe.sequential`, and `sequential` Options

Vitest 5.0 removes the deprecated `test.sequential`, `describe.sequential`, and `sequential` test options. Use `concurrent: false` when you need a test or suite to opt out of inherited or globally configured concurrency.

```ts
test.sequential('example', async () => { /* ... */ }) // [!code --]
test('example', { concurrent: false }, async () => { /* ... */ }) // [!code ++]
```

```ts
describe.sequential('suite', () => { /* ... */ }) // [!code --]
describe('suite', { concurrent: false }, () => { /* ... */ }) // [!code ++]
```

The same replacement applies to option objects:

```ts
test('example', { sequential: true }, async () => { /* ... */ }) // [!code --]
test('example', { concurrent: false }, async () => { /* ... */ }) // [!code ++]
```

## Locators in Commands are Serialized as Objects

Locators forwarded to [browser commands](/api/browser/commands) are now serialized as a `SerializedLocator` object instead of a bare selector string. The object exposes two fields:

* `selector`: the provider-specific selector string (the same value commands previously received).
* `locator`: a human-readable representation of the locator (e.g. `getByRole('button')`), used for error messages and tracing.

Update any custom commands that accept a locator to destructure `selector` from the new object:

```ts
import type { SerializedLocator } from '@vitest/browser'
import type { BrowserCommandContext } from 'vitest/node'

export async function customClick(
  context: BrowserCommandContext,
  selector: string, // [!code --]
  { selector }: SerializedLocator, // [!code ++]
) {
  await context.page.locator(selector).click()
}
```

## Locators are Strict by Default

Browser locators now match the text exactly by default, requiring a full, case-sensitive match. To keep the previous behaviour, you can set [`browser.locators.exact`](/config/browser/locators#browser-locators-exact) to `false`.

```ts
// With exact: true (default), this only matches the string "Hello, World" exactly.
// With exact: false, this matches "Hello, World!", "Say Hello, World", etc.
const locator = page.getByText('Hello, World', { exact: true })
await locator.click()
```

## `toHaveTextContent` Now Performs Strict Equality

The browser-mode [`toHaveTextContent`](/api/browser/assertions#tohavetextcontent) matcher now validates that an element's text content is exactly equal to the expected string instead of performing a partial, case-sensitive match. Regular expressions are no longer accepted. The previous behaviour, including `RegExp` support, has moved to the new [`toMatchTextContent`](/api/browser/assertions#tomatchtextcontent) matcher.

```ts
// Partial or regex matches:
await expect.element(banner).toHaveTextContent('Error') // [!code --]
await expect.element(banner).toHaveTextContent(/error/i) // [!code --]
await expect.element(banner).toMatchTextContent('Error') // [!code ++]
await expect.element(banner).toMatchTextContent(/error/i) // [!code ++]

// Exact matches stay on `toHaveTextContent`:
await expect.element(banner).toHaveTextContent('Error!')
```

## `render` Is Async in `vitest-browser-vue` and `vitest-browser-svelte`

The companion component-testing packages [`vitest-browser-vue`](https://npmx.dev/package/vitest-browser-vue) and [`vitest-browser-svelte`](https://npmx.dev/package/vitest-browser-svelte) now return a promise from `render`, so the call must be awaited before you query the rendered output:

```ts
import { render } from 'vitest-browser-vue'
import Component from './Component.vue'

test('renders', async () => {
  const screen = render(Component) // [!code --]
  const screen = await render(Component) // [!code ++]

  await expect.element(screen.getByRole('heading')).toBeVisible()
})
```

## Glob Coverage Thresholds No Longer Inherit `perFile`

[`coverage.thresholds.perFile`](/config/coverage#coverage-thresholds-perfile) previously applied to every threshold set, including files matched by glob-pattern thresholds. Glob patterns now control their own per-file checking and no longer inherit the top-level `perFile` — set `perFile` on each glob that needs it.

```ts [vitest.config.ts]
export default defineConfig({
  test: {
    coverage: {
      thresholds: {
        'perFile': true,

        'src/utils/**': {
          lines: 80,
          perFile: true, // [!code ++]
        },
      },
    },
  },
})
```

## Coverage `include` and `exclude` Match More Precisely

[`coverage.include`](/config/coverage#coverage-include) and [`coverage.exclude`](/config/coverage#coverage-exclude) were matched against absolute paths with picomatch's `contains` option, which matched many more files than intended. Patterns are now matched against each file's path relative to the project root, without `contains`, and a pattern with no glob wildcard is treated as a directory that matches everything inside it:

```ts [vitest.config.ts]
export default defineConfig({
  test: {
    coverage: {
      include: ['src'], // matches src/**, not every path that contains "src"
    },
  },
})
```

Review your `include` and `exclude` patterns after upgrading and confirm the reported file set is what you expect. Files that were previously matched only by the looser behavior may no longer be included.

## Config Files Are Not Looked Up From Parent Directories

Vitest no longer searches parent directories for config files. If you previously relied on running `vitest` from a subdirectory while using a config file from a parent directory, pass the config explicitly and scope test discovery with `--dir`. For example,

```bash
$ cd subdir && vitest # [!code --]
$ cd subdir && vitest --config ../vitest.config.ts # [!code ++]
```

## DOM Environment Global Assignments Now Update the Underlying Window

Assignments to properties on `globalThis` or `window` in `jsdom` and `happy-dom` environments are now propagated to the underlying DOM implementation. Mutable properties such as `innerWidth` can affect APIs implemented by the DOM environment, for example `happy-dom`'s `matchMedia`.

## `populateGlobal` Returns Descriptors in `originals`

The `originals` map returned by [`populateGlobal`](/guide/environment#custom-environment) now holds [property descriptors](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyDescriptor) instead of plain values. This avoids invoking native lazy getters (such as Node's `localStorage`) while capturing the original, and restores them faithfully on teardown.

If you restore them manually in a custom environment, use `Object.defineProperty` instead of an assignment:

```ts
originals.forEach((value, key) => (global[key] = value)) // [!code --]
originals.forEach((descriptor, key) => Object.defineProperty(global, key, descriptor)) // [!code ++]
```

## Browser Orchestrator URL Requires a Session

Vitest no longer serves the browser orchestrator UI from a bare `/__vitest_test__/` URL. Browser runner URLs are now session-bound and must include the `sessionId` generated by Vitest, for example `/__vitest_test__/?sessionId=...`.

If you manually opened the browser preview by copying the Vite server URL or visiting `/__vitest_test__/` directly, use the URL opened or printed by Vitest instead.

## `browser.api` Is Replaced by the Top-Level `api`

Browser mode now runs on a single Vite server configured by the top-level [`api`](/config/api) option; the default port in browser mode is still `63315`. The `browser.api` option is deprecated and no longer has any effect, so move its value to `api`:

```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    api: { port: 4444 }, // [!code ++]
    browser: {
      enabled: true,
      api: { port: 4444 }, // [!code --]
    },
  },
})
```

The already deprecated `browser.isolate` option now also prints a warning at startup; its value is still applied to the top-level [`isolate`](/config/isolate) option that replaces it.

## Generated Reports and Artifacts Use the `.vitest` Directory

Vitest now uses a single `.vitest` directory at the project root as the shared artifact root, so one `.vitest` entry in `.gitignore` is enough. Defaults that moved this major:

* **Attachments** ([`attachmentsDir`](/config/attachmentsdir)): `.vitest-attachements/` → `.vitest/attachments/`
* **Failure screenshots** ([`screenshotFailures`](/config/browser/screenshotfailures)): `__screenshots__/` → `.vitest/attachments/failure-screenshots/`, so they are no longer mixed with the reference screenshots of `toMatchScreenshot`
* **Blob reporter** and `--merge-reports`: `.vitest-reports/blob-*.json` → `.vitest/blob/blob-*.json`
* **HTML reporter** ([`html`](/guide/reporters#html-reporter)): `html/index.html` → `.vitest/index.html`, and its option changed from `outputFile` (a file) to `outputDir` (a directory)
* **JSON reporter** ([`json`](/guide/reporters#json-reporter)): stdout → `.vitest/json/output.json`
* **JUnit reporter** ([`junit`](/guide/reporters#junit-reporter)): stdout → `.vitest/junit/output.xml`

The `json` and `junit` reporters now write to a file by default instead of printing to stdout. If you piped the report (for example `vitest --reporter=json | jq`), read the artifact file instead, or opt back into stdout with the reporter's [`stdout` option](/guide/reporters#reporter-output) (`reporters: [['json', { stdout: true }]]`). An explicit `outputFile` is still respected and unchanged.

## `toMatchScreenshot` Now Uses a Dedicated Screenshot Directory Config

Previously, reference screenshots for `toMatchScreenshot` did not correctly respect [`browser.screenshotDirectory`](/config/browser/screenshotdirectory). As a result, screenshots were saved in an unintended location when a custom directory was configured.

This has now been fixed by introducing a dedicated option: [`browser.expect.toMatchScreenshot.screenshotDirectory`](/config/browser/expect#browser-expect-tomatchscreenshot-screenshotdirectory). Its default value is `__screenshots__`.

* If you did not set `browser.screenshotDirectory`, no changes are required.
* If you did set `browser.screenshotDirectory`, you must now explicitly configure the new option:

  ```ts [vitest.config.ts]
  export default defineConfig({
    test: {
      browser: {
        screenshotDirectory: 'my-screenshots',
        expect: { // [!code ++]
          toMatchScreenshot: { // [!code ++]
            screenshotDirectory: 'my-screenshots', // [!code ++]
          }, // [!code ++]
        }, // [!code ++]
      },
    },
  })
  ```

  Then either move existing reference screenshots to the new location or regenerate them.

## Worker and Concurrency Ids Are 1-based

Worker and pool identifiers now start at `1` instead of `0`. This changes the values of the `VITEST_POOL_ID` and `VITEST_WORKER_ID` environment variables, which now range from `1` to the worker count. Update any logic that derives a value from these ids, such as a per-worker database name or an array index.

For custom reporters, the [`TestModule`](/api/advanced/test-module#diagnostic) diagnostics now expose both ids: the existing `workerId` (now 1-based) and a new `concurrencyId`.

```ts
import type { Reporter, TestModule } from 'vitest/node'

class MyReporter implements Reporter {
  onTestModuleEnd(testModule: TestModule) {
    const { workerId, concurrencyId } = testModule.diagnostic()
  }
}
```

Node.js and browser tests run in separate pools and do not share these ids, so the same value can appear in both.

## `resolveConfig` Returns the Resolved Vite Config

The [`resolveConfig`](/guide/advanced/#resolveconfig) helper from `vitest/node` no longer returns a `{ vitestConfig, viteConfig }` pair. It resolves the config without creating a Vite server and returns the resolved Vite config; the fully resolved Vitest config is available on its `test` property:

```ts
import { resolveConfig } from 'vitest/node'

const { viteConfig, vitestConfig } = await resolveConfig(options) // [!code --]
const viteConfig = await resolveConfig(options) // [!code ++]
const vitestConfig = viteConfig.test // [!code ++]
```

The Vitest 4 limitations are gone: the returned config now includes fully resolved `projects`, and `viteConfig.test` no longer holds partially resolved options.

## Package Migration

The following packages are deprecated as of this release. They will no longer receive feature updates, but security fixes will continue to be backported:

* [`@vitest/runner`](https://npmx.dev/package/@vitest/runner)
* [`@vitest/ws-client`](https://npmx.dev/package/@vitest/ws-client)

`vitest` also no longer depends on [`@vitest/expect`](https://npmx.dev/package/@vitest/expect): the assertion code is bundled into `vitest` itself. The package is still published and usable on its own, but it no longer shares state with Vitest's `expect`. Interact with Vitest's assertions through the `vitest` entry point (`expect`, `expect.extend`, `chai`) instead.

The [`@vitest/browser-webdriverio`](https://npmx.dev/package/@vitest/browser-webdriverio) provider has been moved to the [vitest-community](https://github.com/vitest-community/vitest-webdriverio) organization. Going forward, WebdriverIO support is community-maintained and addressed on a per-issue basis. If you use it, update your dependency to the new package and report any issues in the new repository.

## Removed Deprecated Entrypoints

Several entry points were marked as deprecated in Vitest 4.1. This release removes them entirely.

* `vitest/coverage`: use `vitest/node` instead
* `vitest/reporters`: use `vitest/node` instead
* `vitest/environments`: use `vitest/runtime` instead
* `vitest/snapshot`: use `vitest/runtime` instead
* `vitest/runners`: use `TestRunner` from `vitest` instead
* `vitest/suite`: use static methods on `TestRunner` from vitest instead (for example, `TestRunner.getCurrentTest()`)
* `vitest/mocker` is removed completely, use `@vitest/mocker` package directly (this was published by accident at one point and never removed)
* `vitest/internal/module-runner` is removed
