React Native App Testing: The End-to-End Playbook
Master React Native app testing from unit to E2E. Our step-by-step guide covers Jest, Detox, CI/CD, and preparing your app for a successful store launch.

You push a React Native build on Friday afternoon, watch CI turn green, and still feel uneasy. The login screen worked on your simulator. The payment flow worked once on a test device. Navigation looked fine after that last refactor. But you know what usually happens next. One native dependency behaves differently on Android, one async state update lands late, and one reviewer finds a broken edge case during store review.
That's the gap most React Native app testing advice leaves open. It teaches unit tests, or E2E tests, or snapshot tests in isolation. Shipping a real app needs a connected system. You need one playbook that starts with a reducer, moves through components and screen flows, handles native modules that don't cooperate in CI, and ends with a build you can submit with confidence.
Table of Contents
- From Code to Confidence a Modern Testing Strategy
- The Foundation Unit and Component Testing
- Connecting the Pieces with Integration Tests
- Validating the Full Experience with E2E Testing
- Mastering Mocks and Automating with CI/CD
- The Final Mile Pre-Submission Testing and Store Approval
From Code to Confidence a Modern Testing Strategy
Most broken releases don't fail because nobody wrote tests. They fail because the team wrote the wrong mix of tests.
A common pattern looks like this: business logic lives in hooks, API requests sit inside service modules, UI state gets split across screens, and native SDKs sneak in through push notifications, analytics, payments, camera access, or biometrics. Then the test suite leans too hard on one layer. Some teams overinvest in E2E and end up with slow, fragile coverage. Others stop at component tests and never verify that navigation, state, and native behavior work together.
A solid React Native app testing strategy treats the app like a stack of risk. At the bottom, you lock down logic and rendering in fast tests. In the middle, you verify that screens, providers, and side effects cooperate. At the top, you run a small set of full user journeys on a simulator or device. Then you add one more layer many guides skip: mocking native modules well enough that CI can exercise code paths that usually only fail after a real build.
Practical rule: Test the cheapest thing that can prove the behavior. If a hook test can catch a bug, don't wait for an E2E script to find it.
That mindset changes how you ship. You stop hoping a final manual pass catches everything. You start treating each layer as a filter. Unit and component tests catch logic mistakes early. Integration tests catch wiring problems. E2E tests confirm the app works from the user's side. Submission checks catch the release details that stores care about but your simulator doesn't.
The Foundation Unit and Component Testing
A bad base layer wastes time in every other layer. I've seen teams blame Detox, CI, or flaky simulators when the underlying problem was simpler. Core logic was barely tested, components were coupled to too many providers, and native dependencies leaked into tests that should have stayed fast and deterministic.
This layer is where React Native app testing starts paying off. Unit and component tests should answer the cheap questions early, before a screen flow or device run is involved.
According to Maestro's guide to React Native testing frameworks, a healthy React Native test pyramid puts most effort into unit tests, with smaller slices for integration and E2E. That matches what works in practice. Fast tests let you catch regressions in hooks, reducers, formatters, validation rules, and presentational components before those bugs spread into larger flows.

Why this layer carries so much weight
The goal is simple. Prove behavior with the smallest possible test.
A unit test should tell you whether pricing math is correct, whether a hook flips from loading to success, or whether a permission gate returns the right branch for a denied status. A component test should tell you whether the user can press the button, whether an error message appears, or whether a disabled state blocks interaction.
That sounds basic, but it changes how the whole test strategy holds together. If business rules are already proven here, integration tests can focus on app wiring. E2E can stay small and high-value. Store submission checks become the final filter instead of the first time release-critical behavior gets exercised.
A Jest setup that stays maintainable
For most projects, the baseline is Jest plus React Native Testing Library. The setup does not need to be fancy. It needs to be predictable.
// jest.config.js
module.exports = {
preset: 'react-native',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native|@react-navigation)/)',
],
};
// jest.setup.js
import '@testing-library/jest-native/extend-expect';
npm install -D jest @testing-library/react-native @testing-library/jest-native
That gets a lot of teams far enough to be productive. The significant work starts when your app imports navigation, gesture handlers, reanimated, push notifications, or Expo modules. Then test setup becomes part of app architecture, not just tooling.
A few patterns help keep this layer under control:
- Keep tests near the feature so the intent stays tied to the code.
- Create
test-utils/render helpers for shared providers such as theme, i18n, Redux, or query clients. - Centralize
__mocks__/for native modules and third-party SDKs so every test does not reinvent a fake implementation. - Split pure logic from platform wiring so you can test decisions without booting half the app.
If a "unit" test needs a navigation container, Redux store, React Query client, and three native mocks just to render one button, the component is carrying too much responsibility.
What good component tests actually prove
Good component tests check the public contract. They do not mirror implementation details and they do not freeze every render tree in a snapshot.
Here's a simple component:
import React from 'react';
import { Text, Pressable, View } from 'react-native';
type Props = {
label: string;
onPress: () => void;
disabled?: boolean;
};
export function PrimaryButton({ label, onPress, disabled = false }: Props) {
return (
<View>
<Pressable accessibilityRole="button" onPress={onPress} disabled={disabled}>
<Text>{label}</Text>
</Pressable>
</View>
);
}
A useful test checks behavior that matters to the user and to other code calling the component:
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { PrimaryButton } from '../PrimaryButton';
it('calls onPress when tapped', () => {
const onPress = jest.fn();
const { getByText } = render(<PrimaryButton label="Continue" onPress={onPress} />);
fireEvent.press(getByText('Continue'));
expect(onPress).toHaveBeenCalled();
});
it('does not call onPress when disabled', () => {
const onPress = jest.fn();
const { getByRole } = render(
<PrimaryButton label="Continue" onPress={onPress} disabled />
);
fireEvent.press(getByRole('button'));
expect(onPress).not.toHaveBeenCalled();
});
That is enough for this component. A snapshot adds little value here and usually creates review noise later.
For hooks, the same rule applies. Test state transitions and branching logic. If useLoginForm validates fields, submits, handles loading, and maps API errors to UI state, cover those paths directly. Do not force every hook bug to surface through a full screen test.
The trade-off most teams miss
Component tests in React Native are rarely "pure UI only" for long. Real components often depend on useNavigation, feature flags, localization, safe area context, or a native module wrapper. That is where many test suites start to drift.
The fix is not to mock everything blindly. The fix is to choose a boundary and stick to it.
Mock external edges such as API clients, analytics calls, and native SDK entry points. Keep your component logic, props contract, and user interactions real. That approach catches regressions without turning every test into a fragile copy of implementation details.
A few rules have held up well across production apps:
- Query by visible behavior first, such as text, role, or accessibility label.
- Mock the module boundary, not internal React state.
- Keep each test focused on one behavior so failures are easy to diagnose.
- Use snapshots sparingly, mainly for stable presentational output with low churn.
- Avoid real network calls in this layer. They add timing noise and hide ownership of failures.
Native modules deserve special attention because they connect this section to the rest of the testing strategy. If a component imports react-native-keychain, expo-notifications, react-native-biometrics, or a payments SDK, wrap that dependency behind your own module and mock your wrapper in tests. That keeps unit and component tests stable, and it pays off later when integration, CI, and pre-submission checks need consistent behavior across iOS and Android.
The teams that ship reliably do this early. They keep the base layer fast, strict, and boring. That gives every higher layer less to prove, which is exactly what you want before you start testing full app flows and release builds.
Connecting the Pieces with Integration Tests
Unit tests tell you a component works alone. Integration tests tell you whether your app behaves when a few real parts meet.
Often, many bugs reside here. The submit button is fine. The form reducer is fine. The API module is fine. But when the button dispatches, the screen shows stale state, the loading spinner never clears, or navigation fires before the auth context updates.
Where unit tests stop helping
A login flow is a good example. You don't need a full device test just to learn whether the form submits and redirects to the home screen after a successful response. That's too heavy. But you also shouldn't fake every child component so aggressively that the whole screen becomes a shallow unit test with better branding.
Integration tests work best when they cover a real path through multiple app pieces:
- a screen component
- one or more child components
- a provider such as Context or Redux
- a mocked API boundary
- navigation behavior or route-dependent rendering
The rule is simple. Keep your real app wiring where the bug risk is, and mock the expensive external edges.
A realistic screen flow to test
Suppose your app has an auth stack and an app stack. The login screen renders two inputs and a submit button. On success, it stores the user and shows the dashboard.
A practical test might do this:
- Render the screen inside your actual auth provider.
- Mock the login service to resolve with a fake user.
- Type into the email and password fields.
- Press submit.
- Assert that the loading state appears, then clears.
- Assert that the dashboard content renders or navigation changes.
That catches a different class of bugs than a pure hook test. It confirms that your UI, your state update, and your side effects line up in the right order.
Integration tests should feel close to how a developer thinks about a feature, not how the component tree is arranged.
State and navigation without over-mocking
The easiest way to ruin integration tests is to mock navigation so hard that it stops representing the app. If every invocation of a screen transition method is a jest function and no route change ever happens, you're mostly testing event wiring.
A better pattern is to render with a small real navigator in test utilities. The same goes for state. If your app uses Redux or Context, prefer a real provider with a test store or lightweight test state over stubbing every selector.
This is also the right place to test error handling and retry behavior. For example:
- Failed auth request: Show the error message and keep the user on the screen.
- Expired token on mount: Render the session-expired state and force re-auth.
- Permission-dependent screen: Show fallback content when the permission service says no.
A short checklist helps keep integration tests honest:
| Check | Why it matters |
|---|---|
| Render with real providers | Bugs often hide in provider wiring |
| Mock only network and platform edges | Keeps tests realistic without becoming slow |
| Assert screen outcomes, not implementation calls | Protects against refactors |
| Cover one full happy path and one failure path per feature | Gives confidence without suite bloat |
When this layer is healthy, E2E doesn't need to prove every branch. It only needs to prove the app still behaves correctly in a production-like runtime.
Validating the Full Experience with E2E Testing
A login flow can pass unit tests, look fine in integration, and still fail on a real device because the keyboard covers the submit button, the permission prompt interrupts the sequence, or the app resumes into the wrong screen after auth. E2E testing exists to catch that class of problem. It verifies the app as a user experiences it, with real rendering, real navigation, app startup, and device-level behavior in play.
That scope is also why E2E needs discipline. It is the slowest layer, the most sensitive to environment issues, and the easiest place to waste effort by re-testing details already covered below. The job here is not broad coverage. The job is release protection.

Detox and Appium compared
For React Native teams, the decision usually comes down to Detox or Appium. Both can validate end-to-end flows, but they solve slightly different problems.
| Criterion | Detox | Appium |
|---|---|---|
| Best fit | React Native teams focused on in-app flows | Teams that need broader platform and system interaction coverage |
| Setup experience | Tighter fit with React Native, but native config still takes care | More general-purpose, often heavier to configure |
| Test style | Strong fit for app synchronization and React Native workflows | Strong fit for cross-platform automation across many app types |
| Team profile | JavaScript-heavy product teams | Teams comfortable with broader mobile automation tooling |
| Good use case | Core app journeys like auth, onboarding, checkout | Flows that need more system-level or cross-environment coverage |
Detox is usually the first tool I recommend for a React Native app. It understands the app lifecycle well, works naturally with JavaScript-heavy teams, and keeps test code close to the way product teams describe flows. Appium earns its place when the test needs to reach farther into the OS, interact with more device surfaces, or fit into a wider mobile automation stack that is not React Native-specific.
A practical Detox baseline
A simple Detox test often looks like this:
describe('Login flow', () => {
beforeAll(async () => {
await device.launchApp({ delete: true });
});
it('logs in successfully', async () => {
await element(by.id('email-input')).typeText('test@example.com');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();
await expect(element(by.text('Welcome back'))).toBeVisible();
});
});
That is the right level for this layer. It proves the app can launch, accept input, complete auth, transition screens, and render the next state in a production-like runtime.
I keep the E2E suite small and business-critical:
- Authentication
- Onboarding
- Purchase or subscription flows
- Deep link entry points
- Critical account or settings changes
- Push notification entry paths, if the app depends on them
What E2E should prove in an end-to-end strategy
A lot of testing guides stop at "run a happy path on a simulator." That misses the bigger job. E2E sits between integration confidence and store submission confidence. It should prove that the JavaScript layer, native shell, build config, navigation, and backend assumptions still work together after a real install and launch.
That means checking things like cold start behavior, persisted session restore, app state transitions, and key permission paths. If a signed-in user kills the app and reopens it, E2E should tell you whether they return to the correct screen. If a marketing deep link opens a gated screen, E2E should confirm the route, auth gate, and final destination all line up.
Those are the failures that reach QA, beta users, and store reviewers.
Pitfalls that make E2E suites expensive
E2E suites usually become unreliable for predictable reasons:
- Testing too many visual variations: copy and layout changes start breaking tests that belong at the component layer
- Weak selectors: stable
testIDvalues age much better than text-only selectors - Uncontrolled test data: accounts, flags, and backend state drift until failures stop meaning anything
- Ignoring device conditions: permissions, keyboard behavior, network delay, and app relaunch paths often expose the real bugs
- Trying to prove every branch through the UI: execution time climbs, failures get noisy, and the suite stops running often enough to help
One practical rule helps here. If a failure would block a release or create a support incident, it probably deserves E2E coverage. If it is mainly about rendering logic or conditional UI, keep it in component or integration tests.
A small E2E suite that runs on every pull request and a broader one that runs before release is usually enough. That gives you one connected strategy from a single component to the final signed build, without forcing the top layer to carry the whole testing load.
Mastering Mocks and Automating with CI/CD
A pull request can pass every Jest test and still fail the first time someone hits Face ID, notification registration, or a payment sheet. In React Native, that gap usually starts at the native boundary.
Push notifications, biometrics, camera access, analytics SDKs, in-app purchases, secure storage, and share sheets are where JavaScript test confidence often stops. Those features depend on platform APIs, native initialization order, entitlements, and build-time config. They also behave differently across local simulators, CI runners, and signed release builds.

Native module mocking is the layer many teams skip
A lot of React Native app testing advice covers API mocks and stops there. Real apps need one more layer. The Medium piece on React Native testing practices calls out a gap that shows up fast in production: mocking native modules for SDKs such as analytics, push notifications, and biometrics, especially in Expo and EAS setups where local behavior and build behavior can diverge.
That gap causes expensive bugs because teams delay testing native paths until QA, TestFlight, or store review. By then, failures are slower to reproduce and harder to isolate.
Common examples show up quickly:
- Biometric login passes UI checks but never exercises a successful or cancelled native prompt.
- Push registration works in development but fails in signed builds because token generation or permissions differ.
- Camera and microphone flows break on one platform because permission status values are not identical on iOS and Android.
- Analytics wrappers crash tests because the SDK runs native setup during import.
Mock the contract you own
The cleanest fix is to stop importing native SDKs directly inside screens and components. Put each native dependency behind an app-owned service, then test against that service contract.
// services/biometrics.ts
export async function authenticateWithBiometrics() {
// wraps native implementation
}
Then mock the wrapper in tests:
jest.mock('../services/biometrics', () => ({
authenticateWithBiometrics: jest.fn(),
}));
That setup gives tests explicit control over success, failure, cancellation, lockout, and unsupported-device states. It also keeps vendor-specific behavior out of your component tests.
For modules that still initialize at import time, use manual Jest mocks in __mocks__ or set them up in jest.setup.ts before the app code loads. Consistency matters more than cleverness. If one test file mocks expo-notifications differently from another, the suite starts failing for reasons unrelated to the feature.
A pattern that holds up in CI looks like this:
- Wrap third-party SDKs behind app services so test code targets your API, not vendor internals
- Mock before import side effects run for SDKs that initialize native code on load
- Return realistic platform-shaped objects for permission and device APIs
- Cover unavailable and denied states because those are the branches users encounter
- Keep mock data deterministic so CI failures mean something
I also recommend one rule that saves time later. If a native module affects login, payments, notifications, or anything a store reviewer can touch, model it in tests before the first release candidate build.
This walkthrough is worth watching before wiring everything into automation:
Put the suite on a pipeline that matches release risk
Once mocks are stable locally, CI needs to enforce the same discipline on every change. The goal is not to run every possible test on every commit. The goal is to catch the right failures early, keep feedback fast, and reserve slower jobs for branches and builds that justify them.
A practical React Native pipeline usually includes:
- Pull request checks: Jest unit and integration tests, type checks, linting, and coverage reporting
- Merge protection: block merges when required checks fail or coverage drops below the team's target
- Build verification: create at least one installable app artifact regularly, because native config breaks can hide behind passing JavaScript tests
- Release or nightly E2E: run Detox or the E2E layer against stable builds and controlled test data
- Failure artifacts: save screenshots, logs, videos, and JUnit reports so engineers can debug CI failures without rerunning blindly
The provider matters less than the shape of the pipeline. GitHub Actions, CircleCI, Bitrise, and EAS Workflows can all do the job if the steps reflect how React Native apps fail.
One detail gets missed a lot. CI should build the app often enough to catch native drift. A renamed permission string, a missing Android manifest entry, a broken Pod install, or an SDK version mismatch will not show up in isolated Jest runs. Those issues surface during build, launch, or store review. That is why this section sits between E2E and pre-submission checks. It connects component confidence to a build that can survive real devices, signed binaries, and reviewer hands.
The Final Mile Pre-Submission Testing and Store Approval
Friday release. All CI checks are green. The signed build still gets rejected because the notification prompt appears without context, the reviewer cannot reach the paid feature you advertised, or the privacy form says you do not collect data while an SDK clearly does. That last stretch is where React Native testing has to connect code quality, native behavior, and store requirements.

What stores care about that your test suite may miss
App Store review and Play review exercise the product in a way your normal test pipeline usually does not. Reviewers install a release build on a clean device, tap through first-run flows, check whether permissions are justified, and compare the app's actual behavior with your listing, screenshots, privacy declarations, and review notes.
That creates a different test target from unit, integration, and even most E2E runs.
The gaps show up in predictable places:
- First launch state: onboarding, splash timing, forced updates, deep links, and remote config defaults on a fresh install
- Permission UX: camera, photos, notifications, location, contacts, microphone, and biometrics, including the deny path and the second attempt
- Reviewer access: test accounts, OTP bypass instructions if allowed, feature flags, seeded data, and any hardware or regional requirements
- Store metadata: screenshots, feature claims, age rating, subscription text, and app description matching what is present in the build
- Privacy and SDK behavior: tracking disclosures, analytics, crash reporting, ad SDKs, sign-in providers, and any data leaving the device
- Release-only behavior: code signing, push configuration, entitlement files, Proguard or R8 rules, Hermes settings, and production API endpoints
I treat the submitted artifact as its own environment. Debug builds hide too much.
A release checklist that catches last-minute failures
A short checklist works better than a long ritual nobody finishes. The point is to verify the paths that break in signed builds and the details reviewers use.
| Area | What to verify |
|---|---|
| Authentication | New account flow, existing login, logout, password reset, session restore, and clear errors for bad credentials |
| Permissions | Prompt timing, pre-prompt copy, deny handling, retry behavior, and settings recovery path |
| Payments or subscriptions | Product list loads, purchase completes, restore works, receipt state updates correctly, and canceled purchases recover cleanly |
| Offline and recovery | Launch with no network, retry after reconnect, cached content, loading states, and failed request messaging |
| Legal and store content | Privacy policy, terms, support URL, screenshots, feature descriptions, review notes, and any required demo credentials |
Two checks deserve extra attention because they sit between app testing and store approval.
First, test native integrations in the exact release flavor you plan to submit. Push notifications, Sign in with Apple, Google Sign-In, in-app purchases, camera access, and location permissions often pass mocked tests and still fail in signed builds because of entitlements, manifest entries, provisioning, SHA keys, or product configuration.
Second, audit what your dependencies declare and collect. React Native teams often add analytics, attribution, chat, payments, and crash reporting one SDK at a time. Store privacy forms need to match that reality. If a package starts collecting device identifiers or diagnostic data, the app listing and disclosure answers need to reflect it before review.
What I check before I upload
My pre-submission pass is simple and repeatable:
- Install the release build on a clean iPhone and a clean Android device.
- Run through first launch without developer shortcuts or preloaded state.
- Verify every permission path, including deny, allow, and recovery from Settings.
- Confirm deep links, push opens, sign-in providers, and purchase restore on production config.
- Compare the build against the store listing, screenshots, privacy answers, and reviewer notes.
- Hand the build to someone who has not touched the feature recently and watch where they get stuck.
That final human pass catches issues automation misses. Confusing copy, dead-end onboarding, hidden reviewer credentials, and mislabeled buttons are common rejection fuel.
Coverage still matters here, but as a floor under the process, not the finish line. High coverage helps keep regressions out of release candidates. Store approval depends on something broader: the JavaScript code, native setup, third-party SDK behavior, release config, and submission metadata all have to agree.
A reliable launch comes from treating submission as part of testing. Start at the component level, verify the wiring with integration tests, prove core journeys with E2E, mock native modules carefully, build signed artifacts in CI, and finish with a reviewer-style pass on the exact binary you plan to upload.
If you want help getting a React Native or Expo app through App Store and Google Play submission without getting stuck on screenshots, reviewer notes, privacy paperwork, EAS setup, or the closed testing requirement for new Play accounts, LetsDeployIt handles the launch process end to end.