Run your React Native tests under Vitest, against real React Native — the same JavaScript that ships in your app, mocking only the native-module boundary. That's the zero-config default. A fast pure-JS mock engine is available as an opt-in for RN-free unit tests. One plugin.
📖 Documentation: danfry1.github.io/vitest-native
Beta. The reproducible guarantee is a CI-gated behavioral cross-check that runs the same assertions under the mock engine and real React Native across RN 0.81–0.85, failing the build on any divergence. We've also exercised the native engine against real apps in our own testing (react-native-paper, the obytes template, Rocket.Chat). Some APIs may still shift before 1.0.
Maintained successor to
vitest-community/vitest-react-native— same core idea (externalize RN, run its real JS under Node), rebuilt for modern Vitest (v4). Coming from it? See Migrating fromvitest-react-native.
Two engines behind one plugin, so you choose the fidelity each suite needs:
engine: 'native'(default) — runs real React Native JS, mocking only the thin native boundary (native modules,UIManager, and the native host-component registry — not theView/Text/ScrollViewcomponent JS, which runs for real). Jest's preset mocks a superset of this (it also swaps RN's core components for stand-ins), so the native engine has higher fidelity for accessibility, RN-API behavior, and integration, with no mock drift. This is whatreactNative()gives you.engine: 'mock'— a fast, zero-dependency pure-JS reimplementation of React Native. The opt-in escape hatch for pure-logic suites, environment control, and maximum determinism.
It's the strongest fit when you:
- start a new RN project or write new tests — great DX, zero migration cost;
- want real-RN fidelity that mock-based runners can't give you;
- already use Vitest elsewhere and want one runner across your codebase;
- want to adopt incrementally — write new tests on vitest-native alongside your existing Jest suite, and migrate older tests as you touch them.
Migrating a large, deeply Jest-coupled suite wholesale is possible but not turnkey — see
Migrating from Jest. As a real, reproducible data point:
react-native-paper's own test suite runs
625 of 734 tests (~85%) under the native engine — no paper source changed, just an RNTL bump and
the test config/setup. The remaining failures are tests coupled to Jest's RN-mock internals (e.g.
vi.spyOn(View.prototype, 'measure'), deep jest.mock('react-native/…') of Appearance/Dimensions),
not vitest-native bugs. The Expo-based obytes template
runs 34 of 40 (~85%) — a more deeply-coupled case needing a few library mocks. Both are public
and reproducible: vitest-native-bakeoffs. We've
also migrated a Rocket.Chat suite in local testing.
# npm
npm install -D vitest-native
# yarn
yarn add -D vitest-native
# pnpm
pnpm add -D vitest-native
# bun
bun add -d vitest-native// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { reactNative } from 'vitest-native';
export default defineConfig({
plugins: [reactNative()],
});That's it. Write your tests:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react-native';
import { MyComponent } from './MyComponent';
describe('MyComponent', () => {
it('renders correctly', () => {
render(<MyComponent title="Hello" />);
expect(screen.getByText('Hello')).toBeTruthy();
});
});- Node.js >= 20
- Vitest 4.x
- Vite ^6.4.2, ^7.3.2, or ^8.0.5
- React >= 18
engine: 'native'(the default) needs@react-native/babel-preset+@babel/core(these ship with React Native projects). The opt-in mock engine needs no Babel.- React Native 0.81–0.85 validated in CI (native engine).
reactNative() // default — real React Native (native), when its babel deps are present
reactNative({ engine: 'native' }) // force real React Native; mock only the native boundary
reactNative({ engine: 'mock' }) // opt in to the fast pure-JS mock
reactNative({ engine: 'auto' }) // the default — native when available, else mock (with a one-line notice)reactNative() with no options resolves to native whenever @react-native/babel-preset and
@babel/core are present (i.e. any real RN app), falling back to mock only when they're absent.
Both engines share the same test API (RNTL, the helpers, the presets). Reach for mock when you
want no RN at all — fast, deterministic, environment-controllable.
- Zero config — Plugin auto-injects setup files and configures RNTL. No manual
setupFilesneeded. - Real React Native by default —
nativeruns RN's real JS, mocking only the native boundary; the opt-inmockengine is a fast pure-JS reimplementation for when you want no RN at all. - Single package — One install replaces three.
- Same toolchain as RN —
nativeFlow-strips real React Native via your project's Babel preset, the toolchain RN already uses. Themockengine needs no Babel — it's just Vite. - 100% public API coverage (mock engine) — every stable React Native export is mocked.
- RNTL compatible — Works with
@testing-library/react-nativeautomatically. - Third-party presets — auto-detected mocks for reanimated, gesture handler, safe area, navigation, screens, async-storage, device-info, mmkv, svg, webview, and Expo.
- Jest-compat layer —
vitest-native/jest-compateases migrating existing Jest suites. - Test helpers —
setPlatform,setDimensions,setColorScheme,mockNativeModulefor easy state control. - TypeScript first — Full type safety across the entire API.
The plugin does three things automatically:
- Module resolution — Redirects
react-nativeimports to virtual modules and resolves platform-specific files (.ios.ts,.android.ts,.native.ts) - Asset stubbing — Stubs image/font/media imports with their filename, matching React Native's bundler
- Setup injection — Auto-injects a setup file that registers all mocks, sets React Native globals (
__DEV__,requestAnimationFrame, etc.), and wires up@testing-library/react-nativeif installed (registering its matchers, and setting host component names for older RNTL; RNTL ≥ 12 auto-detects them against real RN host names)
reactNative({
platform: 'ios', // 'ios' | 'android' (default: 'ios')
diagnostics: false, // Log plugin activity (default: false)
presets: [], // Third-party library presets
mocks: {}, // Custom mock overrides
assetExts: [], // Additional asset extensions (e.g. ['.lottie', '.m4b'])
});import {
setPlatform,
setDimensions,
setColorScheme,
mockNativeModule,
resetAllMocks,
} from 'vitest-native/helpers';
// Switch platform for a test
setPlatform('android');
// Override screen dimensions
setDimensions({ width: 768, height: 1024 });
// Switch to dark mode
setColorScheme('dark');
// Mock a native module
mockNativeModule('MyNativeModule', {
getValue: () => Promise.resolve(42),
doSomething: () => {},
});
// Reset everything back to defaults (iOS, 390x844, light, clears all spies)
resetAllMocks();The Keyboard and AppState mocks include internal helpers for simulating state changes:
import { Keyboard, AppState } from 'react-native';
// Simulate keyboard show/hide (fires keyboardDidShow/keyboardDidHide listeners)
(Keyboard as any)._show(336); // height in pixels
(Keyboard as any)._hide();
// Simulate app state change (fires 'change' listeners)
(AppState as any)._setState('background');These are reset automatically by resetAllMocks().
import { reactNative, presets } from 'vitest-native';
export default defineConfig({
plugins: [
reactNative({
presets: [
presets.reanimated(),
presets.gestureHandler(),
presets.safeAreaContext(),
presets.navigation(),
],
}),
],
});| Preset | Library | What's Mocked |
|---|---|---|
presets.reanimated() |
react-native-reanimated |
useSharedValue, useAnimatedStyle, withTiming, withSpring, withDelay, withSequence, withRepeat, layout animations (FadeIn, FadeOut, SlideInRight), Easing, interpolate, createAnimatedComponent |
presets.gestureHandler() |
react-native-gesture-handler |
GestureHandlerRootView, gesture handlers (Pan, Tap, LongPress, Pinch, Rotation, Fling), Gesture API (v2), GestureDetector, Swipeable, touchable wrappers, state constants |
presets.safeAreaContext() |
react-native-safe-area-context |
SafeAreaProvider, SafeAreaView, useSafeAreaInsets, useSafeAreaFrame, initialWindowMetrics, withSafeAreaInsets |
presets.navigation() |
@react-navigation/native (+ native-stack, bottom-tabs, drawer, elements) |
NavigationContainer, useNavigation, useRoute, useFocusEffect, useIsFocused, CommonActions, StackActions, TabActions, DrawerActions, navigators |
presets.screens() |
react-native-screens |
enableScreens, Screen, ScreenContainer, ScreenStack |
presets.asyncStorage() |
@react-native-async-storage/async-storage |
in-memory store (getItem/setItem/multiGet/mergeItem/…) |
presets.expo() |
expo-constants, expo-font, expo-asset, expo-linking, expo-status-bar, … |
constants, fonts, linking, status bar, splash screen |
presets.deviceInfo() |
react-native-device-info |
string/bool/number getters with sync + async variants |
presets.mmkv() |
react-native-mmkv |
in-memory MMKV + useMMKV* hooks |
presets.svg() |
react-native-svg |
Svg, Path, Circle, Rect, G, … as host components |
presets.webview() |
react-native-webview |
WebView (default + named) host component |
All presets are auto-detected from your installed dependencies — listing them explicitly is optional. They apply under both engines.
vitest-native's mock engine covers every stable React Native public export — 82/82 stable
exports as of RN 0.84 (7 unstable/experimental internals are intentionally skipped; see
Not Covered). Parity is enforced by a CI-gated check-compat script that diffs
the mock against real RN's export list weekly.
View, Text, TextInput, Image, ScrollView, FlatList, SectionList, VirtualizedList, VirtualizedSectionList, Modal, Pressable, Touchable, TouchableOpacity, TouchableHighlight, TouchableWithoutFeedback, TouchableNativeFeedback, ActivityIndicator, Button, Switch, RefreshControl, StatusBar, SafeAreaView, KeyboardAvoidingView, ImageBackground, InputAccessoryView, DrawerLayoutAndroid, ProgressBarAndroid
Component instance methods are supported via refs: TextInput (focus, blur, clear, isFocused), ScrollView (scrollTo, scrollToEnd), FlatList (scrollToIndex, scrollToOffset, scrollToEnd, recordInteraction), SectionList (scrollToIndex, scrollToLocation, scrollToEnd, recordInteraction).
Platform, StyleSheet, Dimensions, Animated, Alert, Linking, Keyboard, AppState, BackHandler, Vibration, PermissionsAndroid, Appearance, PixelRatio, LayoutAnimation, Share, AccessibilityInfo, InteractionManager, PanResponder, ToastAndroid, ActionSheetIOS, LogBox, Easing, I18nManager, DeviceEventEmitter, Clipboard, AppRegistry, Settings, DevSettings, Systrace, PushNotificationIOS
Full animation API including timing, spring, decay, sequence, parallel, stagger, loop, delay, event, and arithmetic operators (add, subtract, multiply, divide, modulo, diffClamp). Animated.View, Animated.Text, Animated.Image, Animated.ScrollView are real React components (not string literals) compatible with RNTL.
useColorScheme, useWindowDimensions, useAnimatedValue
NativeModules, TurboModuleRegistry, UIManager, NativeEventEmitter, NativeAppEventEmitter, NativeComponentRegistry, NativeDialogManagerAndroid, requireNativeComponent
processColor, findNodeHandle, PlatformColor, DynamicColorIOS, RootTagContext, ReactNativeVersion, UTFSequence, codegenNativeCommands, codegenNativeComponent, registerCallableModule, unstable_batchedUpdates
The following unstable/experimental/private exports are intentionally not mocked. These are React Native internals not intended for use in application code:
| Export | Reason |
|---|---|
DevMenu |
Private dev tooling, not used in production code |
experimental_LayoutConformance |
Experimental API, subject to change without notice |
unstable_NativeText |
Internal renderer primitive |
unstable_NativeView |
Internal renderer primitive |
unstable_TextAncestorContext |
Internal context for Text nesting detection |
unstable_VirtualView |
Private experimental component |
VirtualViewMode |
Private experimental enum |
If your code imports any of these, you can provide a custom mock via the mocks option:
reactNative({
mocks: {
unstable_NativeText: MyCustomMock,
},
})This means the plugin isn't configured. Ensure reactNative() is in your vitest.config.ts plugins array.
Host component names are handled for you — the plugin sets them for older RNTL, and RNTL ≥ 12 auto-detects them against real RN host names. If you're having issues:
- Make sure
@testing-library/react-nativeis installed - Don't manually configure
hostComponentNames— leave it to the plugin / RNTL's auto-detection
The plugin stubs common asset extensions (png, jpg, gif, mp4, mp3, ttf, etc.). For custom formats, use assetExts:
reactNative({
assetExts: ['.lottie', '.m4b'],
})If calling useColorScheme or useWindowDimensions directly outside a component (e.g., in API tests), you'll see a React warning in stderr. The mock handles this gracefully with a try/catch fallback. The test will still pass — the warning is expected.
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).
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: { alias: { ...jestCompatAliases() } },
test: { globals: true, setupFiles: [jestCompatSetup] },
});| 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, and no-ops jest.setTimeout. |
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). |
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) |
import { jest } from '@jest/globals' |
resolves to the vi-backed jest (aliased) |
top-level jest.mock('m', factory) |
hoisted + applied, with Jest's factory interop |
describe / it / expect / beforeEach |
same names, available as globals |
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, re-record snapshots, and fix the occasional
factory that references an out-of-scope mock-prefixed variable (Jest's Babel plugin allows
that; Vitest doesn't).
Honest expectation: a brand-new test is a drop-in. Migrating an existing, deeply
Jest-coupled suite is incremental, not turnkey — real RN suites couple to Jest at many
levels (the jest global, jest.mock('react-native'), @react-native/jest-preset,
jest-native matchers, recorded snapshots, native-lib mocks). The
jest-compat layer above clears the mechanical API
coupling; the rest is suite-specific.
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.
→ Full guide:
packages/vitest-native/docs/migrating-from-jest.md.
Jest with @react-native/jest-preset is the React Native standard and works well. Reach for
vitest-native when you value:
- Higher-fidelity option — Both Jest's RN preset and vitest-native run real RN JS and mock the
native side, but at different boundaries. Jest's preset replaces RN's core components (
View,Text,ScrollView,TextInput,Image,Modal) and a few APIs with simplified passthrough mocks, so you test stand-ins. vitest-native'sengine: 'native'mocks only the deeper native boundary, so your tests run RN's real component JS — they even render real host names likeRCTView/RCTText. And you can still drop to a fast full mock (engine: 'mock') when you don't need that. - DX — Vitest's watch mode, UI, and native ESM tooling.
- Unification — one runner if you also test web/server code with Vitest.
It is not primarily a speed play: with engine: 'native' and isolation on, it isn't
categorically faster than Jest today. Choose it for the fidelity option and DX — not raw speed.
bun install
bun run --filter vitest-native build
bun run --filter vitest-native testMIT