---
title: "Migrating from Jest to Vitest: What Actually Breaks"
description: "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."
author: Aleksei Aleinikov
date: 2026-09-18
lang: en
tags: [vitest, jest, javascript-testing, test-migration, vite, mocking]
canonical: https://www.alekseialeinikov.com/en/blog/topics/programming/jest-to-vitest-migration
source: alekseialeinikov.com
---

# 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

<figure>
  <img src="/blog/jest-vitest-breakage-classes.webp" alt="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." width="1200" height="700" loading="lazy" decoding="async">
  <figcaption>Only the first column is cheap. The other two are why a green run on day one proves nothing.</figcaption>
</figure>

**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.

<figure>
  <img src="/blog/jest-vitest-mockreset-trap.webp" alt="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." width="1200" height="700" loading="lazy" decoding="async">
  <figcaption>Identical call, opposite outcome. Nothing in the output warns you that the meaning changed.</figcaption>
</figure>

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.

```js
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:

```js
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:

```js
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:

```js
// 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:

```js
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:

```js
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:

```js
// 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:

```js
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.

```bash
# 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?

<figure>
  <img src="/blog/jest-vitest-should-migrate.webp" alt="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." width="1200" height="700" loading="lazy" decoding="async">
  <figcaption>The speed argument is downstream of one question: are you already on Vite?</figcaption>
</figure>

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](https://www.alekseialeinikov.com/en/blog/topics/programming/pytest-in-practice-2026-python-testing-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](https://www.alekseialeinikov.com/en/blog/topics/programming/biome-2026-replace-eslint-prettier-benchmark) is the same trade-off in a different corner of the toolchain.
