Skip to content

Migrating from Jest ​

Honest expectation first: this is not a turnkey drop-in. Real React Native Jest suites couple to Jest at several levels (the jest global, @jest/globals, jest.mock('react-native'), @react-native/jest-preset, jest-native matchers, recorded snapshots). The vitest-native/jest-compat layer clears the API coupling mechanically; the rest is a small, well-defined per-suite cleanup.

This guide is informed by migration runs against production apps, but support claims come from minimal package-owned tests. External apps include custom Jest setup and migration shims, so their pass counts are observations rather than capability verdicts. See the validation model.

Recommended path: adopt incrementally

Point vitest-native at new tests (zero migration cost, better DX, real-RN fidelity when you want it) while your existing Jest suite keeps running on Jest. Migrate older tests as you touch them, rather than all at once.

1. The jest-compat layer (clears API coupling) ​

ts
// vitest.config.mts
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],
  },
})

What each piece clears:

PieceClears
jestCompatSetup (a setup file)Installs a jest global backed by Vitest's vi, plus the sync jest.requireActual / jest.requireMock that Vitest only ships as the async vi.importActual.
jestMockTransform() (a plugin)Rewrites top-level jest.mock(...) to a hoisted vi.mock(...) so it actually applies — see Top-level jest.mock. The single biggest mechanical blocker, automated.
jestCompatAliases() → @jest/globalsRedirects @jest/globals (imported by @testing-library/react-native < 12) to a shim re-exporting Vitest's globals.
jestCompatAliases() → @testing-library/jest-native/extend-expectNo-ops it — vitest-native already registers the jest-native matchers (toHaveStyle, toBeVisible, …).

After this, jest.fn, jest.spyOn, jest.useFakeTimers, jest.requireActual, and top-level jest.mock / jest.unmock / jest.doMock calls all work unchanged. See the jest-compat reference for details.

2. Per-suite cleanup still required ​

These are the things the compat layer cannot do for you. Each is small and mechanical.

Top-level jest.mock ​

Vitest only hoists mock calls made on the vi / vitest identifier. A top-level jest.mock('react-native', factory) would otherwise run after imports and silently not apply.

jestMockTransform() (section 1) handles this for you — it rewrites top-level jest.mock / jest.unmock / jest.doMock / jest.doUnmock to the hoisted vi.* form, and runs each mock/doMock factory's return through Jest's CommonJS interop so the two most common Jest manual-mock shapes resolve the way they do under Jest:

ts
jest.mock('./Icon', () => () => null)         // function factory → usable as `import Icon from`
jest.mock('./api', () => ({ get: vi.fn() }))  // named-only → `import api from` gets the object

(Jest treats a factory return as module.exports; Vitest treats it as an ES namespace. The wrapper bridges that. A factory already returning an ES shape — __esModule or an explicit default — is left as-is.) So you can leave existing jest.mock(...) calls unchanged. It keys on the literal jest.mock member form, not jest aliased to another local name.

…and in most cases you can delete third-party native-lib mocks entirely — see below.

The @jest/globals alias unblocks RNTL < 12, but upgrading to @testing-library/react-native@^12 is the cleaner fix (12 dropped the @jest/globals dependency). vitest-native supports RNTL 12–14. RNTL 14 makes rendering APIs async, so await render and other APIs as described in its migration guide.

Delete third-party native-lib mocks ​

You do not need jest.mock('react-native-reanimated', …), safe-area's jest/mock, gesture-handler's jestSetup, etc. vitest-native auto-detects installed third-party libraries and shadows their native runtimes with built-in presets, under both engines. Just have the package installed; delete the manual mock. (Reanimated, Gesture Handler, Safe Area, Navigation, Screens, AsyncStorage, Expo are covered out of the box.)

Keep Jest's and Vitest's snapshots apart ​

Jest's .snap files are not something Vitest can check against, for two reasons.

Keys differ. Vitest names a snapshot by joining the describe and test names with > (a > b 1), Jest with a space (a b 1). A hint is name > hint 1 under Vitest, name: hint 1 under Jest. Only a top-level test without a hint has the same key in both. For every other entry Vitest finds no snapshot and treats it as new:

  • Locally, it writes the new entry and the test passes, whatever it rendered. It also rewrites the file's header to // Vitest Snapshot v1 and leaves Jest's entries in place as obsolete. A plain local jest still passes, reports Vitest's entries as obsolete and puts its own header back; jest --ci refuses the file ("Outdated snapshot: No snapshot header found").
  • In CI (CI set), it writes no new entries and every such test fails as mismatched. It still rewrites the file's header, so a jest --ci run after it in the same checkout refuses the file too.

So a first local run is not a comparison with Jest's snapshots, and vitest -u rewrites Jest's files.

Trees differ. Under engine: 'native', real React Native renders real host component names (RCTText, RCTView, RCTScrollView) and the host props it computes, whereas @react-native/jest-preset snapshots show mock names (Text, View). So even an entry whose key matches usually differs.

While both runners run, give Vitest its own directory, so that neither rewrites the other's files:

ts
// vitest.config.mts
import path from 'node:path'

export default defineConfig({
  test: {
    // Jest keeps __snapshots__/; Vitest writes __vitest_snapshots__/ beside it.
    resolveSnapshotPath: (testPath, snapExtension) =>
      path.join(path.dirname(testPath), '__vitest_snapshots__', path.basename(testPath) + snapExtension),
  },
})

Run vitest run once locally to record them, review the new files as you would any new snapshot, and commit them. CI then checks against them, and Jest's own runs, jest -u included, leave them alone. Once Jest is gone, either keep the setting and delete __snapshots__/, or delete Jest's files, remove the setting and record again:

bash
vitest run -u

TIP

Prefer explicit queries (getByText, getByTestId, getByRole) over large snapshots — they're robust across the engine's real host names and far easier to review.

Drop jest-preset and transform config ​

Drop @react-native/jest-preset and transform / transformIgnorePatterns — those are Jest/Metro config. vitest-native handles RN + third-party transformation itself (engine: 'native' transforms real RN in Node's loader hooks; the mock engine virtualizes RN). For run-real pure-JS third-party libs under the native engine, use the plugin's transform allowlist instead of transformIgnorePatterns.

The one case that needs a manual transform entry: a library that ships untranspiled JSX in .js files (its package.json "react-native"/"module" field points at src/, relying on Metro to transform it). Under the native engine such a lib is external to the Vite graph, and you'll see:

Failed to parse source for import analysis because the content contains invalid JS
syntax. If you are using JSX, make sure to name the file with the .jsx or .tsx extension.
  File: node_modules/<lib>/src/Something.js

The error names the offending file — add that package to transform so vitest-native transforms it:

ts
reactNative({ engine: 'native', transform: ['<lib>'] })

This is the direct replacement for having listed the lib in transformIgnorePatterns under Jest. It's also required when a vi.mock('<lib>') must intercept an otherwise-externalized library — vi.mock only applies to modules pulled into the Vite graph.

Babel plugins your Jest run applied ​

Under Jest, babel-jest ran your babel.config.js on every file. vitest-native does not read it: your source goes through Vite's transform, and React Native's own code through RN's Babel preset. Most plugins in a React Native Babel config exist for Metro and are not missed, but a few change what the code means. vitest-native migrate and doctor read the config and sort its plugins:

PluginUnder vitest-native
babel-plugin-macros ('macros'), @lingui/babel-plugin-lingui-macro, other macro pluginsRequired. A macro is compiled away at build time; without the plugin the macro module is imported for real and fails, typically with Cannot find module 'babel-plugin-macros'.
babel-plugin-module-resolver ('module-resolver')Its alias entries become resolve.alias (same exact-or-key/ prefix rule). migrate writes them.
react-native-worklets/plugin, react-native-reanimated/pluginNot needed when the worklets/reanimated preset is active (it replaces the library, so no worklet runs) or the library is not installed; otherwise listed for you to judge.
babel-plugin-react-compilerNot needed: it only adds memoization, and components render the same without it.
anything elseListed for you to judge — check whether a test depends on what it does.

Required plugins run through a Vite plugin that calls Babel. Which one depends on the Vite major:

ts
// npm i -D @rolldown/plugin-babel @babel/core
import babel from '@rolldown/plugin-babel'

export default defineConfig({
  plugins: [
    reactNative(),
    babel({ plugins: ['@lingui/babel-plugin-lingui-macro'] }),
    jestMockTransform(),
  ],
})
ts
// npm i -D @vitejs/plugin-react @babel/core
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    reactNative(),
    react({ babel: { plugins: ['@lingui/babel-plugin-lingui-macro'] } }),
    jestMockTransform(),
  ],
})

Vite 8's @vitejs/plugin-react (v6) no longer runs Babel and points to @rolldown/plugin-babel for it; on Vite 7 and earlier, @vitejs/plugin-react's babel option is the way to add plugins. List only the plugins your tests need. Neither plugin loads babel.config.js, so the full Metro preset is not applied twice. When Vite 8, @rolldown/plugin-babel and @babel/core are installed, migrate --write adds the babel() entry itself.

Move jest config options to Vitest ​

jest.config.js keys (setupFilesAfterEnv, moduleNameMapper, testEnvironment, etc.) move to the Vitest config: setupFiles, resolve.alias, test.environment: 'node'. A suite on testEnvironment: 'jsdom' keeps it as test.environment: 'jsdom' ('happy-dom' works too): React Native renders there alongside the DOM, with either engine and with or without the hot runtime. jest.setTimeout(ms) sets the timeout for the rest of the file, as in Jest (the shim maps it onto vi.setConfig({ testTimeout })). Jest's fakeTimers config is not read: pass its options to jest.useFakeTimers(...) in a beforeEach, which translates them, or set them in Vitest's names under test.fakeTimers (see Fake timers). A jest.useFakeTimers(options) at the top level of a setup file loses its options once RNTL loads.

Mock clearing defaults differ. Jest leaves mock calls in place between tests unless clearMocks is set; Vitest 5 clears them before every test by default (Vitest 4 does not). A suite asserting on calls a mocked dependency received while modules loaded fails only after migrating, so set test.clearMocks: false unless the Jest config set it. Jest's resetMocks is test.mockReset in Vitest; a resetMocks key in a Vitest config is ignored. vitest-native migrate writes all of these.

Typing the config: vitest/config does not export UserConfig (on Vitest 4 or 5), so a config or shared helper that imports it from there fails to typecheck; import ViteUserConfig from vitest/config instead.

3. Known limits — assertions coupled to Jest's mocks ​

A minority of tests assert on Jest's React Native mock internals rather than on rendered behavior. The native engine runs real React Native, so these can't be reproduced without re-mocking RN internally (which would defeat the point). They're worth recognizing up front so you can rewrite or skip them rather than chase them. In our own runs these were concentrated in component libraries (which test RN internals directly); ordinary app suites hit few or none.

Jest-coupled patternWhy it doesn't portWhat to do
jest.spyOn(View.prototype, 'measure') / 'measureInWindow'Jest mocks View as a class with methods on its prototype; real RN's View is a forwardRef with no such prototype, so the spy target is undefined.Rewrite to drive layout via onLayout / fireEvent, or accept as a known-incompatible assertion.
jest.mock('react-native/Libraries/Utilities/Appearance') (and other deep RN-internal submodules) without a factory, then jest.spyOn themUnder the native engine RN is externalized/resident, and Vitest builds a factory-less mock for imports only, so it never intercepts React Native's own require of the submodule.Prefer the public API (e.g. Appearance from react-native); vitest-native provides controllable boundaries for common ones. A jest.mock of the submodule with a factory does reach React Native under the jest-compat layer.
image.props.source.uri / raw source-shape assertionsReal RN normalizes the source prop (an object like { uri }), so tests reading the raw mock shape see a different value. Native = real RN = ground truth.Assert on behavior (what renders) rather than the internal prop shape.
jest.mock('react-native', …) nested inside beforeAll/describe callbacksJest does not hoist a jest.mock inside a callback, so under Jest it's effectively inert; vitest-native applies mocks more consistently, which can expose a mock that was silently doing nothing.Move the mock to top level and make its override valid, or drop it if it was a no-op.

The jest.requireActual('react-native') clone-and-override pattern (const RN = jest.requireActual('react-native'); RN.Platform = {…}; return RN) is supported — RN's module is writable under the compat layer.

Coming from jest-expo

migrate translates the jest-expo presets, and expo-router's own testing library runs as written. See Expo.

4. Suggested migration recipe ​

  1. Add the jest-compat config (section 1). Upgrade RNTL to a supported 12–14 release.
  2. Delete manual third-party native-lib mocks and @react-native/jest-preset.
  3. Convert any top-level jest.mock(...) to vi.mock(...); leave runtime jest.* as-is (or rely on jestMockTransform()).
  4. Add any JSX-in-.js third-party libs to transform as their parse errors surface (section 2).
  5. Point Vitest's snapshots at their own directory and record them with one local vitest run (see Keep Jest's and Vitest's snapshots apart), then run vitest run again to confirm green.
  6. Triage remaining failures — a missing query update, a suite-specific mock, or a known Jest-mock-coupled assertion (section 3).

5. What "done" looks like ​

A migrated suite runs under Vitest with: the jest-compat aliases + setup, RNTL 12–14, no manual third-party native mocks, top-level mocks converted, and snapshots recorded by Vitest. You get Vitest's speed/watch/UI while keeping engine: 'native' real-RN fidelity (or engine: 'mock' for the fast path). The compat layer is intentionally small — it removes the mechanical Jest coupling so the only work left is the genuinely suite-specific bits.

Released under the MIT License.