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.
When agents use itUse 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.
Install
Copilot (VS Code, Visual Studio, Copilot CLI and github.com) reads skills from the repository — commit them so the whole team gets them.
npx skills add AGCO-Global/org-skills --skill react-native-testing -a github-copilot
# or with the org installer (adds .github/skills/react-native-testing):
npx -y github:AGCO-Global/org-skills add skill react-native-testing
Uses the open skills CLI. Works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, OpenCode, Windsurf and 60+ others — it asks which agent to install into.
Installs the frontend-react-native-skills plugin, which bundles all Frontend / React Native skills and keeps them updated.
/plugin marketplace add AGCO-Global/org-skills
/plugin install frontend-react-native-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill react-native-testing -a claude-code
Use Download .zip above, then upload it under Customize → Skills → Upload skill. Team and Enterprise admins can sync this repo as a plugin marketplace instead.
Installs into .agents/skills/, which Codex reads.
npx skills add AGCO-Global/org-skills --skill react-native-testing -a codex
Installs into Cursor's skills folder.
npx skills add AGCO-Global/org-skills --skill react-native-testing -a cursor
Installs into Gemini CLI's skills folder.
npx skills add AGCO-Global/org-skills --skill react-native-testing -a gemini-cli
Any tool with rules, instructions or custom prompts: use Copy SKILL.md above and paste it in. It is plain Markdown.
Commands use your normal git sign-in to GitHub, so they work while the repository is private. Node.js 20+ required.
Try it — example prompts
Prompts this skill is tested against, and what a good answer includes.
Set up testing for our Expo SDK 57 app — we have nothing yet.
jest-expo preset in package.json or jest.config.js@testing-library/react-nativejest.setup.ts with MSW server.listen and onUnhandledRequest: 'error'react-native-reanimated/jest/resolver and setUpTests()SafeAreaProvider with initialMetrics in the render wrapper
Write tests for this OrdersScreen — it uses useOrders (TanStack Query) and shows an offline banner.
lists behaviours firstrenders the real screen with the provider wrapperMSW handlers for the list, empty and error responsesfindBy queries by role, label or textasserts the offline and empty states, not just the happy path
My RNTL test times out on await user.press(...) after I enabled jest.useFakeTimers().
userEvent.setup({ advanceTimers: jest.advanceTimersByTime })jest.useRealTimers() in afterEachnotes that press replays a full touch sequence and takes simulated time
How do I test that the (app) routes are only reachable when the user is signed in?
renderRouter from expo-router/testing-libraryinitialUrl pointing at the protected routeexpect(screen).toHavePathname('/sign-in')both directions tested: signed in and signed out
We need end-to-end tests on real devices in CI for sign-in and checkout.
Maestro flows in .maestro/ with launchApp clearState and assertVisibleEAS build profile with withoutCredentials, ios simulator: true, android buildType apka small number of seeded, independent flowsrun on pull requests via an EAS workflow
Our screen calls expo-secure-store and the test fails at import.
jest.mock('expo-secure-store') with a Map-backed fake implementing setItemAsync/getItemAsync/deleteItemAsyncmock registered centrally in the setup fileassert the token round-trips rather than that a function was called
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
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.
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.
---
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`.