Home → Upgrading to Vitest 5: every breaking change tested, with the errors you'll see and how to fix them

Upgrading to Vitest 5: every breaking change tested, with the errors you'll see and how to fix them

By · Node.js & JavaScript developer
Published October 2, 2026

Vitest 5.0 came out on September 3, 2026. It's faster and adds a trace viewer for browser tests, but the migration guide also lists more than twenty breaking changes. Most are small. A few fail loudly, and one fails silently in a way you might not notice for weeks.

To see what an upgrade really looks like, this post uses a small suite that passes on Vitest 4.1.11 and uses each risky pattern once, upgraded to Vitest 5.0.3. Everything ran in node:24-slim (Node 24.21), with Node 26.10 for the Temporal test and the official Playwright image for browser mode. All output below is real, with colours turned off.

Before you upgrade: requirements

npm install -D vitest@5
  • Node.js 22.12 or newer (or 24, or 26+). On Node 22.11, npm printed EBADENGINE Unsupported engine … required: { node: '^22.12.0 || ^24.0.0 || >=26.0.0' }. My small suite still ran there, but that version isn't supported, so don't rely on it.
  • Vite 6.4 or newer, which is now a peer dependency instead of a bundled one. npm installs it for you (it pulled in Vite 8.3.2). With Yarn, add it yourself: yarn add -D vite.
  • Browser mode packages must match: @vitest/browser-playwright@5 alongside vitest@5.

Vitest 4 already warned you

Two of the breaking changes were announced as warnings in Vitest 4. If your Vitest 4 output contains these, those tests will fail on 5:

Warning: A vi.mock('../src/math.js') call in "/w/test/nested-mock.test.js" is not at the top level of the module. Although it appears nested, it will be hoisted and executed before any tests run. Move it to the top level to reflect its actual execution order. This will become an error in a future version.

Promise returned by `expect(actual).resolves.toBe(expected)` was not awaited. Vitest currently auto-awaits hanging assertions at the end of the test, but this will cause the test to fail in the next Vitest major. Please remember to await the assertion.

After the upgrade, the same suite that passed completely on Vitest 4 reported:

 Test Files  5 failed | 3 passed | 1 skipped (9)
      Tests  3 failed | 5 passed | 1 skipped (9)

Here is each failure, followed by the fix.

1. Mocks are cleared before every test (clearMocks: true)

This is the change most likely to break real suites. Vitest 5 calls vi.clearAllMocks() before each test by default, which wipes recorded calls (implementations stay). Any call made outside the test, in beforeAll, a setup file or at the top of the module, is gone by the time the test checks it:

import { beforeAll, describe, expect, test, vi } from 'vitest';
import { mailer, notify } from '../src/notify.js';

describe('notify', () => {
  const send = vi.spyOn(mailer, 'send');

  beforeAll(async () => {
    await notify([{ email: '[email protected]' }, { email: '[email protected]' }], 'Hi');
  });

  test('sends one email per user', () => {
    expect(send).toHaveBeenCalledTimes(2);
  });
});
FAIL  test/clear-mocks.test.js > notify > sends one email per user
AssertionError: expected "send" to be called 2 times, but got 0 times
 ❯ test/clear-mocks.test.js:12:18

The proper fix is to trigger the call inside the test that checks it, so each test is independent:

import { describe, expect, test, vi } from 'vitest';
import { mailer, notify } from '../src/notify.js';

describe('notify', () => {
  const send = vi.spyOn(mailer, 'send');

  test('sends one email per user', async () => {
    await notify([{ email: '[email protected]' }, { email: '[email protected]' }], 'Hi');
    expect(send).toHaveBeenCalledTimes(2);
  });
});

For a quick upgrade, you can restore the old behaviour in the config. With this, the unchanged test above passed again:

// vitest.config.js
export default defineConfig({
  test: {
    clearMocks: false, // Vitest 4 behaviour
  },
});

2. Un-awaited async assertions now fail

import { expect, test } from 'vitest';
import { fetchTotal } from '../src/math.js';

test('total is 42', () => {
  expect(fetchTotal()).resolves.toBe(42);
});
FAIL  test/async-assert.test.js > total is 42
Error: Promise returned by `expect(actual).resolves.toBe(expected)` was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not guaranteed to complete before the test finishes:
await expect(actual).resolves.toBe(expected)
 ❯ test/async-assert.test.js:5:23

The same applies to .rejects and toMatchFileSnapshot. Make the test async and await the assertion:

import { expect, test } from 'vitest';
import { fetchTotal } from '../src/math.js';

test('total is 42', async () => {
  await expect(fetchTotal()).resolves.toBe(42);
});

3. vi.mock() must be at the top level

vi.mock() is always hoisted to the top of the file, so writing it inside a describe never limited it to that block. Vitest 5 makes this an error instead of a warning, for vi.mock, vi.unmock and vi.hoisted:

import { describe, expect, test, vi } from 'vitest';
import { add } from '../src/math.js';

describe('with a mocked add', () => {
  vi.mock('../src/math.js', () => ({ add: () => 0 }));

  test('add is mocked', () => {
    expect(add(1, 2)).toBe(0);
  });
});
FAIL  test/nested-mock.test.js [ test/nested-mock.test.js ]
Error: 1 call in "test/nested-mock.test.js" was defined outside of the module's top level scope:
- vi.mock('../src/math.js') at test/nested-mock.test.js:5: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.
import { describe, expect, test, vi } from 'vitest';
import { add } from '../src/math.js';

vi.mock('../src/math.js', () => ({ add: () => 0 }));

describe('with a mocked add', () => {
  test('add is mocked', () => {
    expect(add(1, 2)).toBe(0);
  });
});

Note that this error stops the whole file from loading, so none of its tests run. If you really need a mock for only some tests, put those tests in a separate file, or use vi.doMock() with a dynamic import().

4. test.sequential and describe.sequential are gone

import { describe, expect, test } from 'vitest';

describe.concurrent('suite', () => {
  test.sequential('runs alone', () => {
    expect(1 + 1).toBe(2);
  });
  test('runs concurrently', () => {
    expect(2 + 2).toBe(4);
  });
});
FAIL  test/sequential.test.js [ test/sequential.test.js ]
TypeError: test.sequential is not a function
 ❯ test/sequential.test.js:4:8

Pass { concurrent: false } in the test options instead:

import { describe, expect, test } from 'vitest';

describe.concurrent('suite', () => {
  test('runs alone', { concurrent: false }, () => {
    expect(1 + 1).toBe(2);
  });
  test('runs concurrently', () => {
    expect(2 + 2).toBe(4);
  });
});

5. bench is a test fixture now

Benchmarks used to be defined with a top-level bench() imported from vitest. That import is gone:

import { bench } from 'vitest';

const data = Array.from({ length: 10_000 }, () => Math.random());

bench('toSorted', () => {
  data.toSorted((a, b) => a - b);
});
FAIL  |bench| test/sort.bench.js [ test/sort.bench.js ]
TypeError: bench is not a function
 ❯ test/sort.bench.js:5:1

In Vitest 5, bench comes from the test context, and a benchmark only runs when you call .run(), or when you pass several to bench.compare(). That means benchmarks now get fixtures, hooks, retries and assertions like any other test:

import { test } from 'vitest';

const data = Array.from({ length: 10_000 }, () => Math.random());

test('sorting 10,000 numbers', async ({ bench }) => {
  await bench.compare(
    bench('toSorted', () => {
      data.toSorted((a, b) => a - b);
    }),
    bench('Float64Array sort', () => {
      Float64Array.from(data).sort();
    }),
  );
});
$ npx vitest bench
 ✓ |bench| test/sort.bench.js (1 test) 2517ms
   ✓ sorting 10,000 numbers 2516ms
     name                     hz     min     max    mean     p75     p99    p995    p999     rme  samples
     Float64Array sort  1,498.10  0.6500  1.9405  0.6683  0.6695  0.7076  0.7227  0.9326  ±0.27%     1497   fastest
     toSorted             450.23  2.1203  3.4984  2.2246  2.2149  2.4985  2.5611  3.1318  ±0.41%      450

bench.skip, bench.only, benchmark.reporters and the --compare flag were removed. To keep baselines, use the writeResult option and bench.from(). Plain vitest still ignores *.bench.js files unless you set benchmark.enabled: true.

6. toThrow('') now matches every error

This one doesn't break loudly. It quietly makes an assertion weaker. In Vitest 4, toThrow('') matched only an error with an empty message. In Vitest 5, an empty string matches any message:

import { expect, test } from 'vitest';

function parse(input) {
  if (!input) throw new Error('');      // "empty" error
  throw new Error(`cannot parse ${input}`);
}

test('throws an empty error for empty input', () => {
  expect(() => parse('')).toThrow('');
});

test('a non-empty message does not match toThrow("")', () => {
  expect(() => parse('x')).not.toThrow('');
});
✓ throws an empty error for empty input
× a non-empty message does not match toThrow("")

AssertionError: expected [Function] to throw error not including ''
+ cannot parse x

The first test still passes, but it no longer checks what its name says: it would pass for any error. To match an empty message exactly, use a regular expression:

expect(() => parse('')).toThrow(/^$/);
expect(() => parse('x')).not.toThrow(/^$/);

7. -t uses " > " between names, and an old pattern runs zero tests

The name that -t (--testNamePattern) matches against is now built by joining the describe and test names with " > ". In Vitest 4, vitest run -t 'math adds' found the adds test inside describe('math'). After the upgrade, with the suite otherwise green:

$ npx vitest run -t 'math adds'; echo "exit=$?"
 Test Files  10 skipped (10)
      Tests  13 skipped (13)
exit=0

$ npx vitest run -t 'math > adds'
      Tests  1 passed | 12 skipped (13)

Every test is skipped, and the exit code is still 0. If a CI job, a pre-push hook or an editor task runs a subset of tests with an old -t pattern, it now passes while testing nothing. Search your package.json scripts and CI config for -t and --testNamePattern, and check that each command still reports at least one passing test. A pattern like 'math.*adds' works on both versions.

8. No more config lookup in parent folders

Vitest 4 searched parent folders for a config file, so running vitest inside packages/api picked up the monorepo's root vitest.config.js. Vitest 5 doesn't. A test in that package relying on globals: true from the root config:

$ cd packages/api && npx vitest run
 FAIL  test/globals.test.js [ test/globals.test.js ]
ReferenceError: test is not defined
 ❯ test/globals.test.js:2:1

The run exited with code 1. Give each package its own config that extends the root one (or pass --config ../../vitest.config.js):

import { defineConfig, mergeConfig } from 'vitest/config';
import root from '../../vitest.config.js';

export default mergeConfig(root, defineConfig({
  test: { include: ['test/**/*.test.js'] },
}));
$ cd packages/api && npx vitest run
      Tests  2 passed (2)

9. Browser mode: strict text matching

If you use browser mode, two assertions that used to match part of the text now require the whole text. Both of these pass on Vitest 4:

import { expect, test } from 'vitest';
import { page } from 'vitest/browser';

test('shows the error banner', async () => {
  document.body.innerHTML = '<p role="alert">Error! Please try again.</p><button>Save draft</button>';
  await expect.element(page.getByRole('alert')).toHaveTextContent('Error!');
});

test('clicks the save button', async () => {
  document.body.innerHTML = '<p role="alert">Error! Please try again.</p><button>Save draft</button>';
  await page.getByText('Save').click({ timeout: 1000 });
});
× shows the error banner 15045ms
× clicks the save button 1067ms

Error: expect(element).toHaveTextContent()
Expected element to have text content:
  Error!
Received:
  Error! Please try again.
Caused by: Error: Matcher did not succeed in time.

TimeoutError: locator.click: Timeout 1000ms exceeded.
Call log:
  - waiting for locator('[data-vitest="true"]').contentFrame().getByText('Save', { exact: true })

The call log shows what happened to the locator: getByText('Save') became { exact: true } and no longer finds "Save draft". Notice also that the text assertion took 15 seconds to fail, because expect.element keeps retrying until it times out, so a suite full of these gets very slow before it reports anything. The fixes are toMatchTextContent() for partial or regex matches, and role-based locators:

await expect.element(page.getByRole('alert')).toMatchTextContent(/^Error!/);
await page.getByRole('button', { name: 'Save draft' }).click();
✓ |chromium| test/banner.test.js (2 tests) 71ms

Setting browser.locators.exact: false in the config makes getByText() match substrings again, but it doesn't affect toHaveTextContent(): with that option, the click test passed, and the text assertion still failed after 15 seconds.

Failure screenshots also moved, to .vitest/attachments/failure-screenshots/.

10. Smaller changes you may notice

  • Class mocks keep their methods. new (vi.fn(Dog))() printed dog.speak is undefined | instanceof Dog: false on Vitest 4 and dog.speak is function | instanceof Dog: true on Vitest 5. Tests that relied on the methods being missing may change behaviour.
  • Fake timers also fake Temporal. On Node 26 (the first version with Temporal built in), after vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') }), Vitest 4 printed the real time for Temporal.Now.instant(), and Vitest 5 printed 2026-01-01T00:00:00Z. That's what you want, but snapshots containing the current time may change.
  • Worker IDs start at 1. With --maxWorkers=1, Vitest 4 reported VITEST_POOL_ID=1 VITEST_WORKER_ID=0 and Vitest 5 VITEST_POOL_ID=1 VITEST_WORKER_ID=1. Check anything that builds names from them, such as per-worker test databases.
  • Everything generated goes into .vitest/. The blob reporter wrote .vitest-reports/blob.json on Vitest 4 and .vitest/blob/blob.json on Vitest 5. Update .gitignore and any CI step that uploads reports.
  • The UI needs a token: open the URL Vitest prints, which includes ?token=….
  • Removed entry points: vitest/coverage and vitest/reporters moved to vitest/node, and vitest/environments and vitest/snapshot moved to vitest/runtime.

Find affected code before you upgrade

Run this from the project root while you're still on Vitest 4. Against the test suite above, it found every affected line (plus one test title, which you can ignore):

# Un-awaited .resolves / .rejects
grep -rnE "expect\(.*\)\.(resolves|rejects)\." --include='*.[jt]s' --include='*.[jt]sx' . --exclude-dir=node_modules | grep -v await

# vi.mock / vi.unmock / vi.hoisted that are indented (likely nested)
grep -rnE "^[[:space:]]+vi\.(mock|unmock|hoisted)\(" --include='*.[jt]s' --include='*.[jt]sx' . --exclude-dir=node_modules

# Removed APIs: .sequential and top-level bench
grep -rnE "\b(test|it|describe)\.sequential\b|import \{[^}]*\bbench\b" --include='*.[jt]s' --include='*.[jt]sx' . --exclude-dir=node_modules

# toThrow('') now matches every error
grep -rnE "toThrow(Error)?\((''|\"\")\)" --include='*.[jt]s' --include='*.[jt]sx' . --exclude-dir=node_modules

# Browser mode: partial text matching
grep -rnE "toHaveTextContent\(|getByText\(" --include='*.[jt]s' --include='*.[jt]sx' . --exclude-dir=node_modules
./test/async-assert.test.js:5:  expect(fetchTotal()).resolves.toBe(42);
./test/nested-mock.test.js:5:  vi.mock('../src/math.js', () => ({ add: () => 0 }));
./test/sort.bench.js:1:import { bench } from 'vitest';
./test/sequential.test.js:4:  test.sequential('runs alone', () => {
./test/to-throw.test.js:9:  expect(() => parse('')).toThrow('');
./test/to-throw.test.js:12:test('a non-empty message does not match toThrow("")', () => {
./test/to-throw.test.js:13:  expect(() => parse('x')).not.toThrow('');
./test/banner.browser.js:6:  await expect.element(page.getByRole('alert')).toHaveTextContent('Error!');
./test/banner.browser.js:11:  await page.getByText('Save').click({ timeout: 1000 });

Three things a grep can't find: mock calls recorded outside the test that checks them (change 1), -t patterns in scripts and CI (change 7), and packages that rely on a parent folder's config (change 8). Check those by hand, then upgrade and run the full suite once with clearMocks: false to separate mock-history failures from everything else.

Still deciding between Vitest and the test runner built into Node? See The built-in Node.js test runner: a complete guide.

Sources & further reading

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.