DEV Community

Aleksei Aleinikov
Aleksei Aleinikov

Posted on Originally published at alekseialeinikov.com on

Migrating from Jest to Vitest: What Actually Breaks

Migrating from Jest to Vitest: What Actually Breaks

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'

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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'

Enter fullscreen mode Exit fullscreen mode

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()) })

Enter fullscreen mode Exit fullscreen mode

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' },

  },

})
Enter fullscreen mode Exit fullscreen mode

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'] } },

  },

})
Enter fullscreen mode Exit fullscreen mode

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' }))

Enter fullscreen mode Exit fullscreen mode

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


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

Enter fullscreen mode Exit fullscreen mode

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'

Enter fullscreen mode Exit fullscreen mode

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.

Originally published at alekseialeinikov.com

Top comments (0)