Skip to content

jest-compat Layer ​

vitest-native/jest-compat lets an existing Jest suite run under Vitest without rewriting jest.* to vi.*. Your test files keep their jest calls and just work — it's an opt-in layer that clears the mechanical Jest-API coupling (not a full auto-migration).

Setup ​

ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { reactNative } from 'vitest-native'
import { jestCompatAliases, jestCompatSetup, jestMockTransform } from 'vitest-native/jest-compat'

export default defineConfig({
  plugins: [reactNative({ engine: 'native' }), jestMockTransform()], // or engine: 'mock'
  resolve: {
    dedupe: ['react', 'react-test-renderer', 'react-is'],
    alias: { ...jestCompatAliases() },
  },
  test: {
    globals: true,
    environment: 'node',
    setupFiles: [jestCompatSetup],
  },
})

The three pieces ​

PieceWhat it does
jestCompatSetupInstalls a jest global backed by Vitest's vi, so jest.fn / jest.spyOn / jest.useFakeTimers work unchanged. Adds the sync jest.requireActual / requireMock that Vitest only ships as async, a global require, maps jest.setTimeout onto the file's test timeout, and sets process.env.JEST_WORKER_ID (from Vitest's 1-based VITEST_POOL_ID, unless already set) so code that detects a Jest run takes its test branch.
jestMockTransform()A Vite plugin that makes top-level jest.mock(...) actually apply. Vitest only hoists vi.mock, so it rewrites jest.mock / unmock / doMock / doUnmock to the hoisted vi.* form, and runs each factory's return through Jest's CommonJS interop (so () => Component and named-only factories resolve the way Jest resolves them, getters in the returned object are read only when the app reads them, and an export the factory leaves out is undefined rather than a No "x" export is defined error). A plain vi.mock factory keeps Vitest's strict behaviour.
jestCompatAliases()resolve.alias entries: @jest/globals → a Vitest-globals shim (unblocks @testing-library/react-native < 12), and @testing-library/jest-native/extend-expect → a no-op (those matchers are already registered).

You don't swap the test API ​

In your Jest testUnder jest-compat
jest.fn(), jest.spyOn(), jest.useFakeTimers()work as-is (the global jest is vi, and its mocks are vi mocks); jest.useFakeTimers(options) takes Jest's options — see Fake timers
new on a jest.fn(() => ({ ... }))returns the implementation's object, as in Jest — Vitest's own vi.fn requires a function or class implementation for new, and jest.fn does not
import { jest } from '@jest/globals' or require('@jest/globals')resolves to the vi-backed jest (aliased), the same module either way, also when the real @jest/globals is installed
top-level jest.mock('m', factory)hoisted + applied, with Jest's factory interop
__mocks__/<package> beside node_modulesapplied with no jest.mock() call — see Manual mocks of node_modules packages
jest.replaceProperty(Platform, 'OS', 'android')replaced in place and restored by the handle's restore(), jest.restoreAllMocks(), the restoreMocks setting, or the end of the test file, with Jest's errors. A bare vi.restoreAllMocks() leaves it: that is Vitest's own
jest.spyOn(View.prototype, 'measure')reaches every View ref, as with the Jest preset's View stand-in: under the native engine, jest-compat gives View a prototype holding the native methods its refs call. Text and ScrollView keep React Native's own shape, without one
describe / it / expect / beforeEachsame names, available as globals

Manual mocks of node_modules packages ​

Jest applies a file in a __mocks__ folder to the node_modules package of the same name, with no jest.mock() call: __mocks__/moti.ts replaces moti, and __mocks__/@scope/pkg.js replaces @scope/pkg. jestMockTransform() does the same, following Jest's rules:

  • Which files count. Every __mocks__ folder under Jest's roots, at any depth (src/api/__mocks__/axios.ts mocks axios too). A file is named by its path inside __mocks__, and __mocks__/pkg/index.js also answers pkg. Files that modulePathIgnorePatterns match do not count, and neither do node_modules, ios, android, Pods, build, dist, .expo and .tmp, which Jest crawls but where no manual mock lives. Pass Jest's settings as jestMockTransform({ roots: ['src'], modulePathIgnorePatterns: [...] }), relative to the Vite root. The default is the root. migrate carries them over.
  • Which one wins. A jest.mock(name, factory) or jest.unmock(name) in a setup file or test file wins over the manual mock, as in Jest. A factory-less jest.mock(name) gets the manual mock, wherever under the roots it lives. The manual mocks are registered by a generated setup module that runs straight after the jest-compat setup, whether test.setupFiles lists it or the project's own setup file imports it.
  • Every load. The manual mock replaces the package for the test's imports, for require() and for the modules Node loads. A manual mock that loads its own package (jest.requireActual('pkg'), or require('pkg') while it runs) gets the real module, as in Jest.
  • __mocks__/react-native.js. It applies too, as in Jest: write it as the real module with your changes, { ...jest.requireActual('react-native'), Platform: { … } }. Spreading requireActual('react-native') loads the whole React Native graph, so every test file that uses the mock also logs React Native's deprecation warnings, as under Jest. Mock only what you need.
  • Two files with one name. Jest warns duplicate manual mock found and uses one of them. jestMockTransform() prints the same warning and uses the one it reads last, in name order. Delete the others.
  • What is left alone. A Node core module (fs, path) still needs an explicit jest.mock(), as in Jest. A package that reactNative() already stands in for keeps that: a preset shadows it, or the library runs with its own Jest setup. The run warns once that its manual mock is not applied, and migrate lists these mocks to delete.

This is a change for a suite whose manual mocks were not applied before 0.15. To keep the previous behaviour, pass jestMockTransform({ rootMocks: false }); each mock then needs a jest.mock('<package>').

Watch mode

The __mocks__ folders are read when the run starts. After adding, renaming or deleting a manual mock, restart vitest --watch.

Fake timers ​

jest.useFakeTimers(options) translates Jest's options into Vitest's the way Jest translates them for @sinonjs/fake-timers, which both runners use: advanceTimers (true or a step in ms), doNotFake, now and timerLimit (default 100,000 as in Jest, where Vitest's is 10,000). It returns the jest object, as Jest does, so jest.useFakeTimers().setSystemTime(date) chains. Calling vi.useFakeTimers directly still takes Vitest's options.

Two differences remain:

  • process.nextTick and queueMicrotask stay real. Jest fakes them by default; Vitest does not, because Node and Vitest's runner depend on them. Name them in Vitest's toFake if a test needs them faked.
  • legacyFakeTimers: true runs modern fake timers over the timer functions and leaves Date real, as legacy timers do. The timer functions are not mock functions, so assertions such as expect(setTimeout).toHaveBeenCalled() do not work; the first such call logs a warning.

Options set in Jest's fakeTimers config are not read. Pass them to jest.useFakeTimers(...) in the tests, where a beforeEach(() => jest.useFakeTimers({ ... })) in a setup file covers every test. Or set them in Vitest's names under test.fakeTimers (doNotFake as toFake, advanceTimers as shouldAdvanceTime and advanceTimeDelta), which applies to every call except for loopLimit and now: jest.useFakeTimers always passes Jest's timerLimit, and starts the clock at the real time unless given now.

Known limitation: options in a setup file's top level

A jest.useFakeTimers(options) at the top level of a setup file loses its options once React Native Testing Library loads. RNTL switches to real timers and back with jest.useFakeTimers({ legacyFakeTimers: false }) at import, and that call does not carry the earlier options. A bare jest.useFakeTimers() there still fakes timers for the file, as Jest's enableGlobally does. With options, use a beforeEach or test.fakeTimers as above.

One module registry per test file ​

Jest keeps one module registry per test file, so a jest.mock(spec, factory) applies to every way the file loads that module. Under Vitest, imports go through Vite, while require(), jest.requireActual and whatever those load go through Node. With jestMockTransform() and jestCompatSetup, both loaders share one per-file registry of jest.mock factories, keyed by the resolved file (string resolve.alias entries and platform extensions included). Registrations last for the test file, also when Vitest reuses a worker (isolate: false, a watch rerun, or the hot runtime):

In your Jest testUnder jest-compat
require('#/x') in a test, with jest.mock('#/x', factory)returns the mock — the same object jest.requireMock('#/x') returns
jest.mock('a', () => jest.requireActual('a/index.web')) while jest.mock('#/storage', …)the module requireActual loads gets the mocked #/storage: only the requested module is unmocked, as in Jest
jest.requireActual('#/x') of a mocked modulethe real module; everything else still gets the mock
jest.mock('#/x', () => ({ ...jest.requireActual('#/x'), y: jest.fn() })) of your own sourcethe real members load through Vite, so they share module state (a store, a cache) with the code the test imports. A requireActual inside a function the factory returns, or a require() of the module that runs before any import, still loads a separate copy through Node
jest.mock('#/a', () => { const { defaults } = require('#/b'); … }) of your own source#/b loads through Vite too, so it is the instance the test imports, or the mock if the file mocks #/b. A require inside a function the factory returns still goes through Node
const { Sentry } = jest.requireMock('#/sentry')the registered mock, the same objects the file's imports see
jest.mock('react-native/Libraries/AppState/AppState', factory)applies to React Native's own require of that module, so import { AppState } from 'react-native' and require('react-native').AppState see the mock. A module with platform files (Libraries/Utilities/BackHandler) is mocked as the one for the run's platform (BackHandler.ios.js), the file React Native loads. A vi.mock of the same path reaches React Native's own require only with experimental.nodeMocks; without it, only the test's own imports of that path see it
jest.mock('virtual', factory, { virtual: true })require('virtual') returns the mock
jest.doMock / jest.setMock / jest.unmock / jest.dontMockregister and unregister for require() too
jest.resetModules() then require(…)a fresh copy of each project module, which still sees the mocks; factories run again on the next load. React Native and other node_modules packages stay loaded
jest.isolateModules(fn)modules required inside fn are fresh; a mock already created before the block is reused, and one first created inside is fresh and discarded afterwards, as in Jest. Afterwards the earlier modules are back. jest.resetModules() inside ends the block, and nesting throws Jest's error
await jest.isolateModulesAsync(fn)the same across awaits, for require() and for import()
require('./x') after the test imported ./xthe instance the import got, not a second copy, and assigning to it (require('./x').FLAG = true) is seen by the file's imports. A CommonJS file is required as what it set module.exports to
a TypeScript project file Node loads (require, requireActual and what those load)compiled with the options @babel/core loads for it, as babel-jest compiles it, so its macros and plugins apply. Any config Babel reads counts (babel.config.*, .babelrc*, package.json#babel), with its overrides and env- or caller-dependent settings. In a project with Expo the caller is jest-expo's Metro caller for the configured platform, so babel-preset-expo inlines Platform.OS. A file the config ignores, or leaves out of only, fails with "Babel ignores …", as under babel-jest
a relative require from a file Node loadsresolved as Metro resolves it: platform extensions first (./x → x.native.ts before x.ts), a trailing / names the directory (./lib/ → lib/index.ts, never lib.ts), and a directory's package.json entry (react-native, then browser, then main) before its index

Remaining differences:

  • A factory-less jest.mock('x') (automock or __mocks__) is built by Vitest for imports only: require('x') and jest.requireMock('x') return the real module.
  • jest.resetModules() and isolateModules renew project modules only; Jest renews node_modules packages too.
  • After jest.resetModules(), a module that is both imported and required is loaded once by each loader, so the two are separate copies.
  • Inside jest.isolateModules and jest.isolateModulesAsync, a project module the block loads with import() and also requires is two copies; Jest's isolated registry gives one.
  • A jest.mock / jest.doMock whose specifier is computed (anything but a string or a variable, such as jest.mock(path.join(…))) applies to imports only.
  • jest.mock must be at the top level of the file. Inside a describe it becomes a nested vi.mock, which Vitest rejects ("defined outside of the module's top level scope"); Jest hoists it from there.
  • A jest.mock in a helper file that Node loads (one a test requires) is not hoisted, and does not register for require(). babel-jest hoists it there, because it adds babel-preset-jest to every file it compiles. Put the call in the test file or in a setup file.
  • Finding which Babel options apply means loading the project's config and its plugins. That costs each worker about 0.25 s on a large Expo config, even when the transform cache is warm, the first time it loads a project TypeScript file through Node.
  • Two errors are compared as Vitest compares them, except that an own property that is a function on both is left out, so two identical zod 3 ZodErrors, whose methods are per-instance closures, are equal. expect is shared, so this applies to Vitest-style assertions anywhere in the project too.
  • Errors that differ in a data property, their name or their cause still differ under toEqual, as in Vitest. Jest compares two errors by message alone there, so it passes them.
  • Without jestCompatSetup in test.setupFiles, a jest.mock factory fails with an error naming it (JEST_COMPAT_SETUP_MISSING). It must come before any setup file that calls jest.mock.

What it does not do ​

It clears the API coupling, not the suite-specific work — you still:

  • write mocks for native libraries with no preset,
  • record snapshots for Vitest, which keys them differently from Jest (see snapshots),
  • and fix the occasional factory that references an out-of-scope mock-prefixed variable (Jest's Babel plugin allows that; Vitest doesn't).

For the full migration walkthrough — including which manual mocks you can delete and how snapshots change — see Migrating from Jest.

Released under the MIT License.