If you upgrade Vitest from 2.x straight to 5, you pick up the breaking changes from 3.0, 4.0 and 5.0 all at once. Some of them stop the run, some fail whole test files, and a few quietly change what runs without printing anything. Below are the Vitest 5 breaking changes in the order you'll hit them, from moving a test project (happy-dom, coverage-v8 and the UI) from Vitest 2.1.9 to 5.1.0. Every fix below was checked on 5.1.0.
Quick answer
Bump all Vitest packages together, then reinstall cleanly:
{
"devDependencies": {
"vitest": "5.1.0",
"@vitest/coverage-v8": "5.1.0",
"@vitest/ui": "5.1.0",
"vite": "^8.0.0"
}
}
rm -rf node_modules package-lock.json
npm install
npx vitest run
Then fix things in this order:
- Config errors that stop startup:
reporters: ['basic'],test.workspace,poolOptions. - Files that fail to load:
vi.mockorvi.hoistedinside adescribe, and test options passed as the third argument. - Test failures:
clearMocksis now on by default, and an unawaited.resolvesnow fails the test. - Silent changes:
environmentMatchGlobs,vitest.workspace.ts, coverage options and-tpatterns.
Install fails with ERESOLVE
Upgrading only vitest on top of an existing 2.x install fails, because @vitest/coverage-v8 and @vitest/ui pin an exact Vitest version as a peer:
npm error code ERESOLVE
npm error ERESOLVE could not resolve
npm error Found: @vitest/coverage-v8@2.1.9
...
npm error Conflicting peer dependency: vitest@5.1.0
npm error peer vitest@"5.1.0" from @vitest/coverage-v8@5.1.0
Put the same version on every @vitest/* package in package.json, delete node_modules and the lockfile, and install again.
Vitest 5 no longer brings its own Vite. It's a peer dependency now (^6.4.0 || ^7.0.0 || ^8.0.0). npm installed Vite 8 automatically. With Yarn, add vite yourself.
Vitest 5's engines field says ^22.12.0 || ^24.0.0 || >=26.0.0. The suite still ran on Node 20.20, but 20 is outside the supported range. If your CI or hosting builds on Node 20, bump it as part of the upgrade.
Config errors that stop the run
The first run on 5.1.0 printed a Vite 8 warning if the config used __dirname:
(!) Your Vite config uses features that are unsupported by `configLoader: 'native'`, which is planned to become the default in a future major version of Vite:
- `__dirname` (vitest.config.ts:27:32). Use `import.meta.dirname` instead
It's only a warning. Replace __dirname with import.meta.dirname and it goes away.
reporters: ['basic']
The basic reporter was removed in 4.0. Vitest now tries to load it as a file:
⎯⎯⎯⎯⎯⎯⎯ Startup Error ⎯⎯⎯⎯⎯⎯⎯⎯
Error: Failed to load custom Reporter from basic
[cause]: Error: Failed to load url basic (resolved id: basic). Does the file exist?
Use the default reporter without the summary:
reporters: [['default', { summary: false }]],
If you liked the old verbose tree output, that's tree now. verbose prints a flat list.
test.workspace
Error: The `test.workspace` option was removed in Vitest 4. Please, migrate to `test.projects` instead.
Rename it to projects. The entries stay the same.
poolOptions
This one only warns, and it's easy to miss:
DEPRECATED `test.poolOptions` was removed in Vitest 4. All previous `poolOptions` are now top-level options.
Vitest ignores the whole block, so singleFork: true stops applying and your tests run in parallel. The 5.x summary line showed 7 workers spawned. The replacement for singleFork is:
test: {
maxWorkers: 1,
isolate: false,
},
Whole test files fail to load
After the config loads, some files fail before any test in them runs. Vitest lists them under "Failed Suites", and each one hides every test in that file.
vi.mock or vi.hoisted inside describe
Error: 1 call in "src/__tests__/nested-mock.test.ts" was defined outside of the module's top level scope:
- vi.mock("~/lib/logger") at src/__tests__/nested-mock.test.ts:4: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.
vi.mock was always hoisted to the top of the file, so a vi.mock inside describe never ran inside it. Vitest 2.1.9 accepted it without even a warning. Now it's an error with no config option to turn it off. Move the call to the top level:
import { describe, it, vi } from 'vitest';
vi.mock('~/lib/logger');
describe('checkout', () => {
it('logs the order', () => {
// ...
});
});
vi.hoisted() gets the same error and the same fix.
Test options as the third argument
TypeError: Signature "test(name, fn, { ... })" was deprecated in Vitest 3 and removed in Vitest 4. Please, provide options as a second argument instead.
-test('flaky upload', async () => { /* ... */ }, { retry: 2 });
+test('flaky upload', { retry: 2 }, async () => { /* ... */ });
A plain timeout number as the third argument still works. Only the options object moved.
Once that's fixed, the same file may stop at the next removed API. describe.sequential is gone:
TypeError: describe.sequential is not a function
Use describe(name, { concurrent: false }, fn) instead.
Tests that fail now
clearMocks is on by default
AssertionError: expected "vi.fn()" to be called 2 times, but got 1 times
Vitest 5 sets clearMocks: true, so every mock's call history is cleared before each test. Any test that counts calls made in an earlier test, at module load, or in beforeAll now sees fewer calls.
The quick bridge restores the old behavior:
test: {
clearMocks: false,
},
The proper fix is to assert only on calls made inside the test itself. Usually that means changing toHaveBeenCalledTimes(2) to toHaveBeenCalledTimes(1) and moving setup that matters into beforeEach.
Unawaited .resolves and .rejects
On 2.1.9 this was a warning that said it would fail "in Vitest 3". On 5.1.0 it fails the test:
Error: Promise returned by `expect(actual).resolves.toEqual(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.toEqual(expected)
Add the await. There's no config option to bring back the old auto-await.
Smaller assertion changes
These show up once the files above load again:
mockReset()now restores the original implementation instead of returningundefined(3.0).expected 'hello' to be undefinedmeans the test assumed the old behavior.toThrowError(new Error('bad'))now checks the error class too (3.0), so a thrownTypeErrorno longer matchesError.vi.spyOnon a class with an arrowmockImplementationthrowsTypeError: ... is not a constructor(4.0). Pass a class:.mockImplementation(class { getApples() { return 0 } }).- Snapshots of a
vi.fn()print[MockFunction]instead of[MockFunction spy](4.0). Update the snapshot. test.eachtitles with$idno longer quote strings (5.0):case a1, notcase 'a1'..not.toThrow('')now fails for any thrown error (5.0). Use a regex if you meant an empty message.
Changes that don't print anything
These are easy to miss because the run stays green or prints nothing.
environmentMatchGlobs is gone and ignored. A *.node.test.ts file that ran under node now runs under happy-dom. Use projects:
import { configDefaults, defineConfig } from 'vitest/config';
// inside defineConfig({ test: { ... } })
test: {
projects: [
{
extends: true,
test: {
name: 'dom',
environment: 'happy-dom',
exclude: [...configDefaults.exclude, '**/*.node.test.ts'],
},
},
{ extends: true, test: { name: 'node', environment: 'node', include: ['**/*.node.test.ts'] } },
],
},
Since 5.0, inline projects inherit the root config by default, so they get your setupFiles and aliases without extends: true. Adding it doesn't hurt, and it makes that inheritance obvious when you read the config.
A vitest.workspace.ts file is ignored completely. Vitest 2 picked it up automatically. Move its contents into test.projects.
The coverage options all, extension and ignoreEmptyLines are ignored at runtime. TypeScript flags them in the config, the CLI doesn't. Delete them and keep coverage.include. Coverage numbers also shift because of the new V8 remapping. In the test project, line coverage went from 75% to 50% on the same tests, so a coverage threshold in CI may start failing.
-t 'analytics module' used to match a test named module inside describe('analytics'). In Vitest 5 that pattern matches nothing, every test is skipped, and the run exits 0. Use -t 'analytics > module'.
--reporter=json writes to .vitest/json/output.json instead of stdout. A CI step that pipes it to jq fails with jq: parse error. Read the file instead, or check the docs for the reporter's stdout option.
Running vitest from a subdirectory no longer finds the config in a parent directory, and you get ReferenceError: it is not defined when globals: true lived in that config. Passing --config ../vitest.config.ts still fails if setupFiles uses a relative path, because it resolves against the current directory. Resolve it from the config file:
setupFiles: [path.resolve(import.meta.dirname, 'vitest.setup.ts')],
Fake timers can leak between files
Vitest 5.x suggests isolate: false in the run summary to speed things up. With isolate: false (which is also the singleFork replacement above), a file that calls vi.useFakeTimers() and never restores them leaks fake time into the next file. Add this to your setup file:
afterEach(() => {
vi.useRealTimers();
});
The UI needs a token
@vitest/ui 5 protects the UI. Opening http://localhost:51204/__vitest__/ without the token returns 403. Use the full URL Vitest prints in the terminal, the one with ?token=....
Verify it works
npx vitest --version
npx vitest run
You should see vitest/5.1.0 and no DEPRECATED or Failed Suites lines. Compare the test count with the old run. If it dropped, look at the silent changes above, especially vitest.workspace.ts and environmentMatchGlobs.
Conclusion
To upgrade Vitest 2 to Vitest 5, bump every @vitest/* package and Vite together, then fix the config errors, then move vi.mock to the top level, then handle clearMocks and unawaited .resolves. After that, compare test counts and coverage with the old run. If either number drops, check the silent changes first.