---
name: react-native-testing
description: >
  Design a React Native / Expo test strategy and write the tests — Jest with the jest-expo preset, React
  Native Testing Library queries and userEvent, mocking Expo modules, Reanimated and MMKV, faking the network
  with MSW, routing tests with renderRouter from expo-router/testing-library, and Maestro end-to-end flows on
  EAS Build. Use this whenever the user asks how to test an Expo screen, component, hook, store or navigation
  flow; sets up jest-expo, jest.setup.js or a testing preset; asks about @testing-library/react-native,
  getByRole vs getByTestId, userEvent.press, act() warnings, "cannot find variable __DEV__", mocking
  expo-secure-store or useSafeAreaInsets, flaky timers, or device tests in CI. Also use it when generating
  tests for React Native code the user just wrote, even if they do not say the word "test". For web React
  use react-testing.
metadata:
  technology: React Native
  type: testing
---

# React Native Testing
> **Targets:** Expo SDK 54+ · jest-expo · React Native Testing Library 13 · MSW 2 · Maestro · **Verified:** 2026-09 against https://docs.expo.dev, https://callstack.github.io/react-native-testing-library, https://mswjs.io, https://docs.swmansion.com

A mobile test suite earns its keep by catching the bugs you cannot see in the simulator: a screen that breaks with no network, a permission denial you never handled, a form that cannot be submitted with the keyboard up. Test through the rendered screen with real hooks and a faked network; keep the device-level flows to a handful of Maestro journeys, because every one of them costs minutes of CI on real hardware.

## 1. Decide first

| Question | Default | Change when |
|---|---|---|
| Which layer? | Component test of a screen with RNTL + MSW, rendered with the real providers | Pure logic in `model/` or a reducer → plain Jest unit test with no React |
| Runner | **Jest with the `jest-expo` preset** — it handles the RN transform, Expo modules and the platform extensions | A non-Expo bare app → `preset: 'react-native'` plus the same RNTL rules |
| Query | `getByRole('button', { name: … })`, `getByLabelText`, `getByText` | An element genuinely has no accessible name → fix the component; `getByTestId` only with a comment |
| Interaction | `const user = userEvent.setup()` then `await user.press(...)` | A low-level event RNTL cannot express → `fireEvent` with a comment |
| Network | **MSW 2**, imported from `msw/native` | A native module (camera, biometrics) → `jest.mock` the Expo module |
| Navigation | Assert what the screen renders, not that the router was called | The flow *is* the feature (guards, redirects, deep links) → `renderRouter` from `expo-router/testing-library` |
| End-to-end | 3–8 **Maestro** flows on EAS Build for the critical journeys | The team already runs Detox and it is green → keep it; do not run both |
| Snapshots | No | A small serialisable value (a parsed config, a reducer output) → `toMatchInlineSnapshot` |

## 2. Setup

```bash
npx expo install jest-expo jest @types/jest --dev
npx expo install @testing-library/react-native --dev
```

Five things the configuration must have, or the suite fails before it asserts anything:

- `"preset": "jest-expo"` — supplies the transform, the Expo module resolution and `__DEV__`.
- `"resolver": "react-native-reanimated/jest/resolver"` plus `require('react-native-reanimated').setUpTests()` in the setup file.
- MSW started in `setupFilesAfterEnv` with `onUnhandledRequest: 'error'`, `server.resetHandlers()` in `afterEach`, using `setupServer` from **`msw/native`**.
- A `SafeAreaProvider` with `initialMetrics` in the render wrapper — without it `useSafeAreaInsets()` never resolves and screens render nothing.
- A `QueryClient` with `retry: false`, so an error-path test does not wait out three attempts.

RNTL's matchers (`toBeOnTheScreen`, `toHaveTextContent`) need no setup import. Full config, the wrapper and the Expo/MMKV module mocks: `references/setup.md`.

## 3. Component tests — the rules

- **Render the screen, not the pieces.** Real query hooks, real form library, faked HTTP. That is what catches the wiring bugs.
- **Query the way a screen reader does.** `getByRole` with an accessible name works because the component set `accessibilityRole` and `accessibilityLabel` — so a failing query is usually a real accessibility bug, not a test problem.
- **`findBy*` for anything async**; never `setTimeout`, never a bare `waitFor` around a synchronous assertion.
- **One behaviour per test**, named as a sentence.

```tsx
import { screen, userEvent } from '@testing-library/react-native';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithProviders } from '@/test/render';
import { OrdersScreen } from './OrdersScreen';

it('shows the empty state when there are no orders', async () => {
  server.use(http.get('*/orders', () => HttpResponse.json({ items: [] })));
  renderWithProviders(<OrdersScreen />);

  expect(await screen.findByText(/no orders yet/i)).toBeOnTheScreen();
});

it('opens an order when the row is pressed', async () => {
  const user = userEvent.setup();
  renderWithProviders(<OrdersScreen />);

  await user.press(await screen.findByRole('button', { name: /order 1001/i }));

  expect(await screen.findByText(/order details/i)).toBeOnTheScreen();
});
```

`user.press()` replays the full React Native touch sequence and takes at least ~130 ms of fake or real time, so prefer fake timers with `userEvent.setup({ advanceTimers: jest.advanceTimersByTime })` in suites with many interactions.

## 4. Mobile cases worth a test

These are the ones that break in production and never in the simulator:

- **Offline**: the request fails → cached content plus the offline banner, and a queued mutation shows as pending.
- **Permission denied**: the Expo permission hook returns `granted: false`, `canAskAgain: false` → the "open settings" path renders.
- **Auth guard and deep links**: signed out lands on sign-in; a link to a missing entity renders the not-found state instead of crashing (`renderRouter` with an `initialUrl`).
- **Platform forks and large fonts**: `.ios.tsx` / `.android.tsx` pairs run on both platforms (`jest-expo` has per-platform presets), and a label at `maxFontSizeMultiplier` still fits its container.

## 5. What to mock, and what never to mock

- **Mock** native Expo modules with no JS implementation (`expo-secure-store`, `expo-camera`, `expo-notifications`, `expo-haptics`), MMKV, and anything that reaches the device clock or the network stack directly.
- **Do not mock** your own hooks, the query client, navigation, or the component under test's children. A test that mocks the thing it is testing proves only that the mock works.
- Keep every module mock in `src/test/mocks/` and register it once in the setup file, so a screen test never carries ten `jest.mock` calls at the top.

## 6. End-to-end with Maestro

Maestro drives the real binary through the accessibility tree with short YAML flows (`launchApp` with `clearState: true`, `tapOn` a visible label, `assertVisible`). Build the binary with an EAS profile using `withoutCredentials`, iOS `simulator: true` and Android `buildType: apk`, then run the flows from an EAS workflow on every pull request. Keep flows short, independent and seeded through the API — a flow depending on another flow's leftovers fails as soon as the suite is sharded. Flows, profile and workflow: `references/navigation-and-e2e.md`.

## 7. Deliverable format

```
## Behaviours to cover
1. <behaviour> — happy / error / empty / offline / permission-denied

## Tests
<file path>  (imports included, one it() per behaviour)

## Mocks and handlers
<Expo modules mocked, MSW handlers added or reused>

## How to run
npm test -- <path>   ·   maestro test .maestro/<flow>.yaml
```

## Checklist

- [ ] Behaviours listed before any test code was written
- [ ] `jest-expo` preset configured, with the Reanimated resolver and `setUpTests()`
- [ ] Queries are role/label/text based; every `getByTestId` justified in a comment
- [ ] `userEvent` used for interaction; fake timers get `advanceTimers`
- [ ] Network faked with MSW from `msw/native`, `onUnhandledRequest: 'error'`
- [ ] Offline, error and permission-denied paths asserted, not just the happy path
- [ ] Expo native modules mocked centrally; no mocks of the app's own hooks
- [ ] No full-tree snapshots
- [ ] Navigation-critical flows covered with `renderRouter`, not by asserting router calls
- [ ] A small Maestro suite runs on a real build in CI, with seeded data

## Anti-patterns

- **Snapshot tests of screens** → they break on every style change and assert nothing a user cares about.
- **`getByTestId` everywhere** → add roles and labels; the queries and the accessibility both improve.
- **`fireEvent.press` for a real user interaction** → `await user.press()` fires the full sequence a device does.
- **Mocking `useOrders` to test `OrdersScreen`** → fake the HTTP response instead and keep the hook real.
- **Asserting `router.push` was called** → assert the destination screen rendered.
- **Importing `setupServer` from `msw/node`** → `msw/native`; the Node build needs `http` and fails to resolve.
- **A Detox and a Maestro suite in parallel** → pick one; two flaky device suites get ignored twice as fast.
- **Testing that a component is memoised** → the React Compiler makes it unobservable; assert output.

## Go deeper

- `references/setup.md` — full Jest config, `jest.setup.ts`, provider wrapper, Expo module and MMKV mocks, MSW for React Native, fake timers, per-platform runs.
- `references/navigation-and-e2e.md` — `renderRouter` recipes for guards, deep links and tabs; Maestro flows, EAS build profile and workflow, device-test CI shape.
- Sibling skills: `react-native-development` (the code under test), `react-native-architecture`, `react-native-performance`, `react-testing` (the web equivalent), `frontend-code-review`.
