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
// 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
| Piece | What it does |
|---|---|
jestCompatSetup | Installs 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 test | Under 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_modules | applied 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 / beforeEach | same 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'sroots, at any depth (src/api/__mocks__/axios.tsmocksaxiostoo). A file is named by its path inside__mocks__, and__mocks__/pkg/index.jsalso answerspkg. Files thatmodulePathIgnorePatternsmatch do not count, and neither donode_modules,ios,android,Pods,build,dist,.expoand.tmp, which Jest crawls but where no manual mock lives. Pass Jest's settings asjestMockTransform({ roots: ['src'], modulePathIgnorePatterns: [...] }), relative to the Vite root. The default is the root.migratecarries them over. - Which one wins. A
jest.mock(name, factory)orjest.unmock(name)in a setup file or test file wins over the manual mock, as in Jest. A factory-lessjest.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, whethertest.setupFileslists 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'), orrequire('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: { … } }. SpreadingrequireActual('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 foundand 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 explicitjest.mock(), as in Jest. A package thatreactNative()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, andmigratelists 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.nextTickandqueueMicrotaskstay real. Jest fakes them by default; Vitest does not, because Node and Vitest's runner depend on them. Name them in Vitest'stoFakeif a test needs them faked.legacyFakeTimers: trueruns modern fake timers over the timer functions and leavesDatereal, as legacy timers do. The timer functions are not mock functions, so assertions such asexpect(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 test | Under 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 module | the real module; everything else still gets the mock |
jest.mock('#/x', () => ({ ...jest.requireActual('#/x'), y: jest.fn() })) of your own source | the 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.dontMock | register 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 ./x | the 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 loads | resolved 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')andjest.requireMock('x')return the real module. jest.resetModules()andisolateModulesrenew project modules only; Jest renewsnode_modulespackages too.- After
jest.resetModules(), a module that is both imported andrequired is loaded once by each loader, so the two are separate copies. - Inside
jest.isolateModulesandjest.isolateModulesAsync, a project module the block loads withimport()and alsorequires is two copies; Jest's isolated registry gives one. - A
jest.mock/jest.doMockwhose specifier is computed (anything but a string or a variable, such asjest.mock(path.join(…))) applies to imports only. jest.mockmust be at the top level of the file. Inside adescribeit becomes a nestedvi.mock, which Vitest rejects ("defined outside of the module's top level scope"); Jest hoists it from there.- A
jest.mockin a helper file that Node loads (one a testrequires) is not hoisted, and does not register forrequire(). babel-jest hoists it there, because it addsbabel-preset-jestto 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.expectis shared, so this applies to Vitest-style assertions anywhere in the project too. - Errors that differ in a data property, their
nameor theircausestill differ undertoEqual, as in Vitest. Jest compares two errors by message alone there, so it passes them. - Without
jestCompatSetupintest.setupFiles, ajest.mockfactory fails with an error naming it (JEST_COMPAT_SETUP_MISSING). It must come before any setup file that callsjest.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.