Most React Native teams have decent unit test coverage. Jest is baked into the default project template. React Native Testing Library (RNTL) makes component tests easy to write. Snapshot tests catch unintended UI regressions. The bottom of the testing pyramid is well-covered.
The top of the pyramid is where things fall apart.
Integration tests that verify how your JS code interacts with native modules? Usually mocked out entirely. E2E tests that run on real devices with OEM skins and manufacturer specific behavior? Rarely exist. And the bugs that make it to production the ones users report as "it crashed when I tried to pay" almost always live in those skipped layers.
This isn't a testing tutorial with npm install instructions. The React Native docs and Jest docs do that well. This is about the two layers most RN teams underbuild, why those gaps exist, and what to do about them.
The architecture that makes RN testing unique
React Native isn't a web app. It's also not a fully native app. It's JavaScript running on a separate thread that communicates with native platform code to render actual native views. This architecture creates testing blind spots that don't exist in either pure web or pure native development.
The old Bridge (pre-New Architecture) serialized every JS-to-native call as JSON and sent it across an asynchronous bridge. This meant a setState call in JavaScript wouldn't update the screen immediately update had to cross the bridge, get deserialized, and be rendered by the native thread. A test that asserted the screen state immediately after a state change could pass or fail depending on how fast the bridge processed the message. Timing-dependent tests are flaky by nature.
The New Architecture (JSI + Fabric + TurboModules) eliminates the JSON-serialized bridge. JavaScript now holds direct C++ references to native objects via JSI, Fabric handles concurrent rendering, and TurboModules lazy-load native code. As Bolder Apps noted, "the Bridge is dead" — JSI enables synchronous calls when needed and eliminates the serialization overhead.
But here's the thing: whether you're on the old bridge or the new JSI, unit tests don't touch any of this. Jest runs in Node.js on your laptop. It doesn't boot the native runtime. It doesn't render native views. It doesn't load TurboModules. When you mock react-native in a Jest test, you're replacing the entire native layer with fake stubs. Your tests are validating JavaScript logic not the actual app that ships to users.
That's fine for testing a price formatter function or a Redux reducer. It's not fine for testing whether your payment module actually initiates a transaction on a real device.
Unit and component testing: what you probably already have
If your RN project follows the standard setup, you have Jest configured with the react-native preset. Your unit tests look something like this:
// formatPrice.test.ts
import { formatPrice } from './formatPrice';
test('formats cents to dollar string', () => {
expect(formatPrice(1999)).toBe('$19.99');
});
test('handles zero', () => {
expect(formatPrice(0)).toBe('$0.00');
});
And your component tests use RNTL to simulate user interactions:
// LoginForm.test.tsx
import { render, fireEvent, screen } from '@testing-library/react-native';
import { LoginForm } from './LoginForm';
test('shows error for empty email', () => {
render(<LoginForm onSubmit={jest.fn()} />);
fireEvent.press(screen.getByText('Log in'));
expect(screen.getByText('Email is required')).toBeTruthy();
});
These tests are fast (milliseconds), stable (no device dependency), and useful (they catch logic and rendering bugs in isolation). RNTL has replaced react-test-renderer as the standard react-test-renderer is deprecated as of React 19 and shouldn't be used in new projects.
This is the layer most teams have. It works. It's not the layer where production bugs live.
The integration layer nobody tests
Here's a pattern that shows up in almost every RN codebase I've seen: native modules are mocked in tests, and nobody ever validates that the mock matches the real behavior.
Your app uses a biometric authentication module. In your Jest setup, you have:
// jest.setup.js
jest.mock('react-native-biometrics', () => ({
isSensorAvailable: jest.fn(() =>
Promise.resolve({ available: true, biometryType: 'FaceID' })
),
simplePrompt: jest.fn(() =>
Promise.resolve({ success: true })
),
}));
Your tests pass beautifully. isSensorAvailable always returns true. simplePrompt always succeeds. You ship with full confidence.
Then a user on a Pixel 7 with Android 15 opens the app, taps "Login with Biometrics," and nothing happens. Why? Because the real react-native-biometrics module on that device returns { available: true, biometryType: 'Fingerprint' } — not 'FaceID' — and your component has a conditional that only shows the prompt for 'FaceID' or 'TouchID'. The 'Fingerprint' type hits the default branch, which silently does nothing.
Your mock was wrong. Your test was testing mock behavior, not app behavior. The integration between your JS component and the native biometrics module was never validated on a real device.
This pattern repeats across every native dependency: camera modules that return different image formats on Samsung vs Pixel, geolocation modules that timeout differently on iOS vs Android, push notification modules where token registration fails silently on Xiaomi because MIUI restricts background services.
Why teams skip integration testing:
Detox is the closest thing to an integration test runner for RN, and it's hard to set up. It requires native build configuration, specific Xcode and Android Studio versions, and tight coupling to your RN version. Jupiter, a fintech company, tested Detox in September 2025 and found it succeeded only 2 out of 10 times on physical devices, with animation synchronization issues on the other 8. Detox's grey-box synchronization engine (it watches the JS event loop, pending network requests, and animations) is powerful when it works, but it demands that your entire async chain is Detox-compatible. One rogue setTimeout or an unresolved promise and the test hangs.
Appium is the alternative, but it's black-box it doesn't understand React Native's internals, so you're back to explicit waits and timing-dependent assertions. Typical flakiness rates with Appium on RN are 15-25% compared to Detox's sub-2% on well-written suites.
So most teams do neither. They mock the native modules, test the JS layer, and hope the integration works. It usually does. Until it doesn't.
Real-device E2E: where RN breaks differently than web
Let's say you do set up Detox or Appium. You run your E2E suite on an emulator. Tests pass. You ship.
A user on a Samsung Galaxy A14 with One UI 5 and Android 13 opens the app. The login screen renders, but the "Log in with Google" button is half-hidden behind the system navigation bar because Samsung's gesture navigation has a different inset height than stock Android. The button is technically there a Detox test on a stock emulator would find it by testID and tap it successfully. But a real user can't see the bottom 20 pixels of the button, doesn't realize it's tappable, and abandons the login.
This is the class of bugs that only appears on real devices with manufacturer-specific configurations. Emulators run stock Android. They don't reproduce:
OEM navigation and display insets. Samsung, Xiaomi, Oppo, and OnePlus each handle the gesture navigation bar, status bar, and notch/camera cutout differently. A layout that respects SafeAreaView on stock Android might clip on One UI because Samsung calculates safe area insets differently.
Manufacturer-specific popup interruptions. Xiaomi's HyperOS shows a "Security" permission dialog on first launch. Samsung's "Device Care" might flag your app as battery-draining and prompt the user to restrict it. Huawei's "Protected Apps" screen asks the user to whitelist the app or it won't receive push notifications. None of these popups exist on emulators. An E2E test running on an emulator never encounters them.
Font rendering and scaling. Samsung devices ship with "Large" as the default font size for accessibility. Your component that looks fine on a Pixel with default font scaling shows truncated text on a Samsung because the system-level font scale is 1.3x instead of 1.0x. Jest doesn't render fonts. Detox on an emulator uses the default font scale. Only a real Samsung device shows the problem.
GPU and animation performance. React Native animations (Reanimated, Animated API) run on the native UI thread. A Lottie animation that runs at 60fps on a Pixel 8 might drop to 20fps on a budget phone with a weaker GPU and cause the navigation transition to stutter. Performance profiling on an emulator doesn't match real-device GPU behavior.
Shopify's New Architecture migration surfaced a particularly nasty variant of this: manipulating React Native views from native UIManagers caused "tap gestures not working due to desync between what React Native thinks the view hierarchy looks like and what it actually is." That desync was invisible in their unit and component tests. It only showed up on real devices running the integrated app.
Closing the gap
The two layers most RN teams skip native module integration and real-device E2E are where the highest impact bugs live. Here's how to close each gap without rebuilding your entire test infrastructure.
For native module integration: Stop assuming your mocks match reality. At minimum, write a few "smoke integration tests" that boot the real app on a device (or emulator) and validate that each native module initializes, returns the expected shape of data, and handles denial/failure states. You don't need a full Detox suite for this. Even a handful of manual validation scripts that run before each release will catch the biometrics-type bugs described above.
For real-device E2E: You need tests running on actual Samsung, Xiaomi, and Pixel hardware, not just emulators running stock Android. Drizz is built for this. Tests are written in plain English ("Tap on Log in with Google," "Validate 'Welcome' is visible," "Scroll down until 'Settings'"), and Vision AI reads the screen the way a user sees it. It doesn't find elements by testID or XPath, it sees the Google button as a Google button, regardless of how Samsung's inset calculation shifts its position.
The popup agent handles Xiaomi's Security dialog, Samsung's Battery Optimization, and Huawei's Protected Apps screen automatically. Self-healing adapts when layouts shift across devices and OS versions. The same test runs on both Android and iOS without separate scripts.
Teams using Drizz go from 15 tests per month to 200 per QA engineer, with flakiness dropping from ~15% to ~5%. For a deeper comparison of RN-specific E2E tools, see our Detox vs Appium vs Drizz breakdown.
FAQ
What is React Native Testing Library?
RNTL is a lightweight library for testing React Native components by simulating user interactions (taps, text input) rather than testing implementation details. It's the current standard, replacing the deprecated react-test-renderer.
Is Detox or Appium better for React Native E2E testing?
Detox has tighter RN integration and lower flakiness (~2%) but requires heavy native build configuration. Appium is more flexible but flakier on RN apps (15-25%). Neither runs well on real OEM devices at scale.
Can I use Playwright or Cypress for React Native testing?
Only if your RN app has a web build (Expo Web, React Native Web). Playwright and Cypress are browser-based tools. They can't test native mobile rendering, device hardware, or platform-specific behavior.
What's the biggest testing gap in React Native projects?
Native module integration. Most teams mock every native dependency in Jest, so the real interaction between JS code and native platform code is never validated until it breaks in production.
How do I test React Native apps on real devices?
Use a real-device testing platform. Detox supports physical devices but setup is painful. Drizz runs plain-English tests on real Android and iOS hardware using Vision AI — no selectors, no native build configuration needed.
Should I use snapshot testing in React Native?
For stable components that rarely change, yes — snapshots catch unintended regressions. For actively developed components, no — snapshots generate noise, and developers start approving changes without reviewing them.
Top comments (0)