Back to blog
Programming
IntermediateForJavaScript DevelopersFrontend EngineersNode.js Developers
7 min

Migrating from Jest to Vitest: What Actually Breaks

Swap jest for vi and most of a Jest suite runs on Vitest untouched. The problems are not the calls that throw — they are the ones that keep passing while meaning something different. A migration guide organised by how each difference fails.

vitestjestjavascript-testingtest-migrationvitemocking
Cover image: Migrating from Jest to Vitest: What Actually Breaks
Contents

Replacing jest with vi gets you most of the way. Vitest was built with a Jest-compatible API on purpose, and a large share of any suite runs untouched.

That compatibility is also the problem. The calls that fail are the easy part — you fix them in an afternoon because the run stops and tells you. The expensive ones are the calls that keep working and mean something different.

Here is what actually breaks, organised by how it breaks.

Three Ways a Suite Breaks

Three columns: loud failures like done callbacks and jest namespace types, silent wrong behaviour like mockReset and hook order, and silently missing behaviour like __mocks__ not auto-loading.
Only the first column is cheap. The other two are why a green run on day one proves nothing.

Loud differences throw immediately. You cannot ship them.

Silent and wrong differences run the same call with different semantics. The test passes and now asserts something else.

Silently missing differences mean something you configured simply does not happen. No error, no mock, real module.

Plan your time around the middle column.

The Trap: mockReset() Means the Opposite

If you read one thing before starting, read this one.

Side-by-side comparison: in Jest mockReset replaces the implementation with an empty function returning undefined; in Vitest it restores the original implementation passed to the mock factory.
Identical call, opposite outcome. Nothing in the output warns you that the meaning changed.

Jest’s mockReset replaces the mock implementation with an empty function that returns undefined. Vitest’s mockReset resets the mock implementation to its original — reset a mock created by vi.fn(impl) and you get impl back.

const fn = vi.fn(() => 'real')
fn.mockReset()
fn() // Jest semantics: undefined
// Vitest semantics: 'real'

A test that asserted the empty-mock behaviour now runs the real implementation. In plenty of cases it still passes — and it is no longer testing what its name claims.

Related and equally quiet: mock.mock is persistent in Vitest. Jest recreates the mock state object when .mockClear() is called, so you must always read it as a getter. Vitest holds a persistent reference, so this passes in Vitest and fails in Jest:

const mock = vi.fn()
const state = mock.mock
mock.mockClear()
expect(state).toBe(mock.mock) // passes in Vitest, fails in Jest

Globals Are Off

Jest enables its globals API by default. Vitest does not. Either set globals: true in the config, or import what you use:

import { describe, expect, it, vi } from 'vitest'

Importing is the better long-term shape. But know the side effect of leaving globals off: testing-library will not run its automatic DOM cleanup. That does not raise an error. It shows up as tests polluting each other — which is a considerably worse way to discover a config choice.

Hooks Run as a Stack

Two separate changes hide in hooks.

First, beforeAll and beforeEach may return a teardown function in Vitest. That makes concise arrow bodies dangerous, because an implicit return is now interpreted as teardown:

// Jest: fine. Vitest: the return value is treated as a teardown function.
beforeEach(() => setActivePinia(createTestingPinia()))
// Correct in Vitest
beforeEach(() => { setActivePinia(createTestingPinia()) })

Second, Jest runs hooks sequentially; Vitest runs them as a stack. If you have nested describe blocks with hooks that depend on ordering, that is a real behaviour change. To get Jest’s ordering back:

export default defineConfig({
test: {
sequence: { hooks: 'list' },
},
})

Mocks That Quietly Do Not Happen

__mocks__ is not automatic. Modules in a root __mocks__ directory are not loaded unless vi.mock() is called. Jest loads them for you. If you want the Jest behaviour suite-wide, call the mocks inside a file listed in setupFiles.

Third-party mocking is not propagated. Where Jest applies a module mock to external libraries that import the same module, Vitest needs you to say so explicitly:

export default defineConfig({
test: {
server: { deps: { inline: ['lib-name'] } },
},
})

Mock factories return an object, not a value. In Jest, the factory’s return value is the default export. In Vitest it must be an object with each export named:

// Jest
jest.mock('./some-path', () => 'hello')
// Vitest
vi.mock('./some-path', () => ({ default: 'hello' }))

jest.requireActual becomes vi.importActual — and it is async. That await is easy to miss:

const { cloneDeep } = await vi.importActual('lodash/cloneDeep')

Test Names Join With >

Vitest joins suite and test names with > to make suites easier to distinguish. Jest joins them with a space. This bites in two places: expect.getState().currentTestName, and any -t / testNamePattern filter that spans the boundary.

Terminal window
# Jest
vitest -t 'math adds' # no longer matches
# Vitest
vitest -t 'math > adds'

Safest fix for CI scripts: match a single segment (-t adds) or put a wildcard between them (-t 'math.*adds').

The Loud Ones

These stop the run, so they cost you an afternoon and nothing more.

  • done callbacks are not supported. Rewrite to async/await, or wrap: it('works', () => new Promise(done => { /* ... */ done() })).
  • There is no jest type namespace. let fn: jest.Mock<...> becomes import type { Mock } from 'vitest'.
  • Jest’s legacy fake timers are not supported.
  • jest.setTimeout(5000) becomes vi.setConfig({ testTimeout: 5_000 }).
  • jest.replaceProperty has no direct equivalent; use vi.stubEnv or vi.spyOn.
  • JEST_WORKER_ID becomes VITEST_POOL_ID (always ≤ maxWorkers). Note that VITEST_WORKER_ID also exists but means something different — a unique id per created worker, unbounded by maxWorkers.

Should You Migrate At All?

Checklist: already on Vite, wanting ESM, big watch loop are good reasons to migrate; being on webpack or plain Node, or relying on Jest-only plugins, are reasons to reconsider.
The speed argument is downstream of one question: are you already on Vite?

This is the part most migration posts skip, and it decides the whole thing.

Vitest is fast because it reuses your Vite pipeline. Its own documentation puts it plainly: it is a test runner that uses the same configuration as your app through vite.config.js, sharing a common transformation pipeline during dev, build and test time — where Jest and Vite otherwise force you to configure two separate pipelines. In watch mode it walks the module graph and reruns only the related tests, the same way HMR works in Vite.

On a webpack or plain-Node project, you still get first-class ESM and a better developer experience. But you have paid the full migration cost for a much smaller share of the benefit. The Vitest team’s own positioning is careful here — they aim to be the runner of choice for Vite projects, and a solid alternative even for projects not using Vite. That can still be the right call — just make it for ESM and ergonomics, not because of a benchmark run on somebody else’s Vite app.

One more pre-flight check: inventory your Jest-only plugins and serializers before you start. Vue projects, for instance, need jest-serializer-vue registered in snapshotSerializers or snapshots fill up with escaped quotes.

A Migration Order That Works

  1. Get it running loud-first. Install, point the config at your test glob, run once, fix everything that throws. This is the cheap part.
  2. Decide the globals question deliberately. If you turn globals off, verify DOM cleanup is still happening before you trust a single component test.
  3. Audit every mockReset and resetMocks. This is the one that ships bugs. Grep for them and check each assertion still means what it says.
  4. Grep for __mocks__ and requireActual. Confirm each mock is actually applied — temporarily break the real module and check the test fails.
  5. Re-check hook ordering in any suite with nested describe blocks that share state.
  6. Fix CI filters that use -t across a suite boundary.

Step 4 deserves emphasis because it inverts the usual instinct: make the test fail on purpose. A mock that silently did not apply looks exactly like a mock that did, right up until the real HTTP call goes out.

The Bottom Line

The Jest-compatible API is genuinely good work, and it is why this migration is measured in days rather than weeks. It is also why the migration is easy to declare finished too early.

A green suite after the swap does not mean the suite still tests what it used to. Treat the first green run as the start of the audit, not the end of the migration.

For the equivalent conversation on the Python side, the pytest in practice guide covers fixtures and the same class of “passing but meaningless” failure. And if you are modernising JavaScript tooling more broadly, Biome replacing ESLint and Prettier is the same trade-off in a different corner of the toolchain.

Frequently asked questions

Is migrating from Jest to Vitest hard?

The mechanical part is easy and the semantic part is not. Vitest was designed with a Jest-compatible API, so most test files run after replacing the jest global with vi, and many run with no edit at all if you enable the globals option. What takes the time is a short list of behavioural differences that do not produce errors: mockReset() restores the original implementation instead of an empty one, hooks run as a stack rather than sequentially, test names join with '>' instead of a space, and modules in __mocks__ are not auto-loaded. Budget your migration time for auditing those, not for the find-and-replace.

What is the difference between mockReset in Jest and Vitest?

They do opposite things. Jest's mockReset replaces the mock implementation with an empty function that returns undefined. Vitest's mockReset resets the mock implementation to its original — so resetting a mock created by vi.fn(impl) puts impl back. If a test asserted that a reset mock returns undefined, that test will now silently exercise the real implementation and, in many cases, still pass. It is the single most dangerous difference in the migration because nothing flags it. Audit every mockReset call and every place you rely on resetMocks in config.

Why are describe and it not defined in Vitest?

Because Vitest does not enable the globals API that Jest turns on by default. You have two options: set globals to true in your Vitest config, which restores the Jest-like behaviour, or import { describe, it, expect } from 'vitest' in each test file. Importing is the cleaner long-term choice, but note one consequence of leaving globals disabled: common libraries like testing-library will not run their automatic DOM cleanup. That does not throw — it shows up as tests leaking state into each other, which is a much worse way to find out.

Does Vitest support the done callback?

No. Vitest does not support the callback style of declaring tests. Rewrite them as async/await functions, which is usually clearer anyway. If a test genuinely needs the callback shape — for example when testing an event emitter — you can wrap it in a Promise: it('works', () => new Promise(done => { /* ... */ done() })). This is one of the loud failures, so you will find every instance on the first run rather than discovering them in production.

Do my __mocks__ folders still work in Vitest?

Only when you explicitly ask for them. Jest auto-loads modules from a root __mocks__ directory; Vitest does not load them unless vi.mock() is called for that module. If you want the Jest behaviour across the whole suite, call the mocks inside a setup file listed in setupFiles. The failure mode here is quiet and unpleasant: no error is raised, the mock simply is not applied, and the real module runs — which for something like a payments client or an HTTP layer is exactly the test you did not want to run.

Is Vitest actually faster than Jest?

It depends entirely on whether you are already using Vite, and that is the honest answer rather than a hedge. Vitest reuses your project's Vite config, plugins and transform pipeline, so the work of turning your source into something runnable is shared with your dev server — its documentation describes a common transformation pipeline across dev, build and test, where Jest and Vite otherwise mean configuring two. In watch mode it walks the module graph and reruns only the related tests, the way HMR works in Vite. On a webpack or plain-Node project you get ESM support and a nicer developer experience, but you have paid a migration cost for a much smaller share of the speed story. Decide on that basis, not on someone else's benchmark.

From the community

Discussion on the Fediverse

Replies from Mastodon and Bluesky — straight from the open web, no tracking.

Loading replies …

ENDE