Testing React Native Apps: RNTL, Jest, Detox, and What Still Breaks
The React Native Testing Stack in 2026
React Native's testing landscape has matured significantly. Three tools now cover the full pyramid:
| Layer | Tool | What It Catches |
|---|---|---|
| Unit / Component | React Native Testing Library + Jest | Component rendering, user interactions, business logic |
| Integration | Jest + MSW or custom mocks | API calls, state management, navigation flows |
| E2E | Detox | Full app flows on real simulator/device |
The frustrating reality: most React Native projects use only Jest unit tests and skip the E2E layer. That means every release ships with an untested gap between "the components render" and "the app actually works for someone on an iPhone."
React Native Testing Library
React Native Testing Library (RNTL) is the React Testing Library port for React Native. It renders components using React Native's renderer in a Node environment — no simulator, no device, fast feedback.
Install it:
npm install --save-dev @testing-library/react-native @testing-library/jest-nativeAdd the Jest Native matchers to your setup file:
// jest.setup.ts
import "@testing-library/jest-native/extend-expect";A basic component test:
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react-native";
import LoginForm from "../components/LoginForm";
describe("LoginForm", () => {
it("calls onSubmit with email and password when form is submitted", () => {
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
fireEvent.changeText(
screen.getByPlaceholderText("Email"),
"user@example.com"
);
fireEvent.changeText(
screen.getByPlaceholderText("Password"),
"password123"
);
fireEvent.press(screen.getByRole("button", { name: "Sign In" }));
expect(mockSubmit).toHaveBeenCalledWith({
email: "user@example.com",
password: "password123",
});
});
it("shows validation error when email is empty", () => {
render(<LoginForm onSubmit={jest.fn()} />);
fireEvent.press(screen.getByRole("button", { name: "Sign In" }));
expect(screen.getByText("Email is required")).toBeOnTheScreen();
});
});Two things worth noting here. First, query by role and placeholder — not by testID. testID queries are a crutch. They require you to add attributes to every component you want to test, and they don't reflect how users interact with the app. Accessibility roles and labels are better. Second, getByRole("button", { name: "Sign In" }) — this is the same API as React Testing Library for web. If your team already knows it, there's no new learning curve.
Mocking Native Modules in Jest
Native modules are the hardest part of React Native unit testing. Modules like @react-native-async-storage/async-storage, react-native-camera, or any custom native bridge don't exist in Node.js. When Jest encounters them, it throws.
The fix is mocking. Most well-maintained libraries ship Jest mocks. Enable them in package.json or jest.config.js:
// jest.config.js
module.exports = {
preset: "react-native",
setupFilesAfterFramework: ["./jest.setup.ts"],
moduleNameMapper: {
// If a library doesn't ship its own mock:
"react-native-device-info": "<rootDir>/__mocks__/react-native-device-info.js",
},
transformIgnorePatterns: [
"node_modules/(?!(react-native|@react-native|react-navigation|@react-navigation|@testing-library)/)",
],
};For @react-native-async-storage/async-storage, the library ships a mock:
// jest.setup.ts
import mockAsyncStorage from "@react-native-async-storage/async-storage/jest/async-storage-mock";
jest.mock("@react-native-async-storage/async-storage", () => mockAsyncStorage);For libraries that don't ship mocks, write them manually:
// __mocks__/react-native-camera.js
module.exports = {
RNCamera: {
Constants: {
Type: { back: "back", front: "front" },
FlashMode: { on: "on", off: "off" },
},
},
};The transformIgnorePatterns setting is the most common source of "cannot use import statement" errors in React Native Jest setups. The preset transforms react-native itself but not third-party packages by default. Add any package that ships untransformed ESM to the exception list.
Testing Navigation Flows
React Navigation components need a navigation context. Wrap them in a test navigator:
import { NavigationContainer } from "@react-navigation/native";
import { render, screen, fireEvent } from "@testing-library/react-native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import HomeScreen from "../screens/HomeScreen";
import ProfileScreen from "../screens/ProfileScreen";
const Stack = createNativeStackNavigator();
function TestNavigator() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Home">
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}
describe("HomeScreen navigation", () => {
it("navigates to Profile when profile button is pressed", async () => {
render(<TestNavigator />);
fireEvent.press(screen.getByRole("button", { name: "View Profile" }));
expect(await screen.findByText("Profile")).toBeOnTheScreen();
});
});This renders the actual navigator — not a mock. The findByText await handles the async transition. Avoid manually calling navigation.navigate() in tests — you'd be testing the test, not the component.
Detox E2E Testing
RNTL is fast but doesn't test the real app on a real platform. Detox does. It runs tests against your actual iOS simulator or Android emulator, controlling the app over a synchronization protocol that waits for React Native's bridge to settle before asserting.
Install and configure:
npm install --save-dev detox
npx detox initdetox.config.js for a standard RN project:
module.exports = {
testRunner: {
args: { $0: "jest", config: "e2e/jest.config.js" },
jest: { setupTimeout: 120000 },
},
apps: {
"ios.release": {
type: "ios.app",
build:
"xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build",
binaryPath: "ios/build/Build/Products/Release-iphonesimulator/MyApp.app",
},
},
devices: {
simulator: {
type: "ios.simulator",
device: { type: "iPhone 15" },
},
},
configurations: {
"ios.sim.release": {
device: "simulator",
app: "ios.release",
},
},
};A Detox test for the login flow:
// e2e/login.test.ts
describe("Login flow", () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
beforeEach(async () => {
await device.reloadReactNative();
});
it("logs in with valid credentials and shows dashboard", async () => {
await element(by.id("email-input")).typeText("user@example.com");
await element(by.id("password-input")).typeText("password123");
await element(by.id("sign-in-button")).tap();
await waitFor(element(by.text("Dashboard")))
.toBeVisible()
.withTimeout(5000);
});
it("shows error message for invalid credentials", async () => {
await element(by.id("email-input")).typeText("wrong@example.com");
await element(by.id("password-input")).typeText("wrongpassword");
await element(by.id("sign-in-button")).tap();
await waitFor(element(by.text("Invalid email or password")))
.toBeVisible()
.withTimeout(3000);
});
});Detox tests use testID (the prop on React Native elements) because the accessibility role query API isn't available in Detox the same way it is in RNTL. Add testID props to interactive elements you plan to test with Detox — this is the legitimate use case for testID.
Common React Native Testing Pitfalls
Act() warnings everywhere. The act() warning means state updates are happening outside of React's test utilities. Wrap async operations:
await act(async () => {
fireEvent.press(screen.getByRole("button", { name: "Load More" }));
});RNTL v12+ wraps most interactions automatically, but async state updates from timers or Promises still need it.
Timers in tests. Animations, debounced inputs, polling — use fake timers:
jest.useFakeTimers();
// ... trigger the thing that uses a timer
jest.runAllTimers();Platform-specific code. Platform.OS is "ios" in RNTL by default. Override it:
jest.mock("react-native/Libraries/Utilities/Platform", () => ({
OS: "android",
select: (obj: any) => obj.android,
}));FlatList rendering. RNTL doesn't virtualize FlatList — all items render. That's usually fine for tests, but if you're asserting on a specific item by index, be aware the rendered tree may differ from the device behavior.
Detox flakiness. Detox is reliable when you use waitFor consistently. Never use sleep() or fixed timeouts. If a test flakes, the usual cause is an assertion that fires before a network request completes — add a waitFor wrapping the network-dependent state.
What Automated Tests Miss in React Native
Your test suite can be comprehensive and complete and still leave real users experiencing broken app behavior. The gap is always the same: tests are isolated, production is not.
Backend contract changes. Your API returns a field called user_name. Your app reads user.username. Tests use mock data that matches whatever the app expects. In production, the field name changed two deploys ago. Every user sees a blank name field. No test caught it.
Push notification delivery. You can mock the push notification client, test that the right payload is constructed, verify the opt-in flow. You cannot test whether APNs or FCM actually deliver the notification to a device. Real push failures only surface in production.
Deep link handling in real OS contexts. RNTL and Detox can test deep link navigation flows. They can't test whether a deep link from an Instagram DM on iOS 17 routes correctly through the OS link handler to your app in a backgrounded state. That requires real device testing across OS versions.
Auth token expiry. Session tokens expire. Refresh token logic that looks correct in tests regularly fails in production because of clock skew, race conditions between simultaneous requests, or server-side session invalidation that the test mock doesn't replicate.
Network conditions. Your app works fine on WiFi. On a mobile network with 400ms latency and occasional packet loss, loading states time out, retry logic triggers, and UI transitions look broken. Detox tests run on a simulator with loopback networking.
Monitor the Backend Your App Depends On
HelpMeTest can't run Detox tests against your iOS app in production — but it can do the next best thing: continuously test your backend API and the mobile web version of your product, alerting you when something that your app depends on breaks.
This matters because most React Native app failures are backend failures. The app code is the same. The server started returning 500s. Users see a broken app. Your Jest tests never detected it because they mock the API layer.
curl -fsSL https://helpmetest.com/install | bash
helpmetest loginWrite a test against your API:
GET https://api.myapp.com/health
Verify status is 200
Verify response contains "ok"Or test the full auth flow against your API endpoints:
POST https://api.myapp.com/auth/login with email and password
Verify status is 200
Verify response contains auth_token
GET https://api.myapp.com/users/me with Authorization header
Verify status is 200
Verify response contains user emailRun this every 5 minutes. When the auth endpoint starts returning 503s after a deploy, you know before your users do.
If your app has a mobile web version or a web dashboard at the same domain, HelpMeTest tests those directly — full browser automation, real user flows, alerts when something breaks.
Free tier: 10 tests, unlimited health checks, CI integration. Pro: $100/month — unlimited tests, 5-minute monitoring intervals.
curl -fsSL https://helpmetest.com/install | bashStart free at helpmetest.com →
Your Detox suite verifies the app works. HelpMeTest verifies the backend the app depends on keeps working.