rn-write-tests — Jest + RNTL + Maestro testing for Expo + RN
Contract
See references/contracts.md (vendored from dev-flow). Key facts:
- Reads
<project-root>/.workflow/meta.json#stack.framework— must be"expo-rn". - Adds dev dependencies + Jest config on first call (idempotent: detects existing setup).
- Writes test files under
<project-root>/__tests__/(Jest convention) for unit/integration. - Writes Maestro YAML flows under
<project-root>/.maestro/for e2e. - Does NOT modify
meta.json#phase.
When this skill applies
- User asks to write a test for a specific source file or flow.
- Orchestrator does NOT route here automatically — test writing is on-demand.
Knowledge dependencies (read these first)
rn-fundamentals/SKILL.md— TS strict, modern primitives the tests will see.rn-data-fetching/references/patterns.md— query/mutation patterns the tests must exercise.
Workflow
Step 1 — Verify preconditions
Read .workflow/meta.json. Abort if stack.framework != "expo-rn".
Step 2 — Detect / install the test stack
Check if jest, jest-expo, @testing-library/react-native are in package.json. If not:
npx expo install --dev \
jest jest-expo \
@testing-library/react-native@^14 \
test-renderer@^1 \
@types/jest -- --legacy-peer-deps
# jest-expo 57 declares `@react-native/jest-preset ^0.86.3` as a peer, and that package is
# NOT in the SDK's bundledNativeModules — so `expo install` would hand you npm latest (0.87.x),
# whose setup looks for `react-native/src/setup-env.js` and fails on RN 0.86. Pin it to the
# React Native version the project actually has:
RN_VERSION="$(node -p "require('react-native/package.json').version")"
npm install --save-dev --legacy-peer-deps "@react-native/jest-preset@${RN_VERSION}"
(Without the @react-native/jest-preset line the very first jest run dies with "The React Native Jest preset that jest-expo relies on has moved to a separate package"; with the wrong minor it dies with "Could not locate module react-native/setup-env" — observed 2026-09-08 on SDK 57 / RN 0.86.3. test-renderer@^1 is a required peer dep of RNTL v14 — it replaced react-test-renderer. Node ^22.13 || >=24 is also required. Native matchers like toBeOnTheScreen() are built into @testing-library/react-native v12.4+ — no separate @testing-library/jest-native needed. See references/jest-setup.md for the full config.)
⚠️ RNTL v14 is async: render, renderHook, fireEvent and act all return Promises and MUST be awaited, and the UNSAFE_* queries are gone. Read the v14 section of references/rntl-patterns.md before writing a test — a missing await produces a test that passes for the wrong reason. Migrating an existing suite: npx codemod@latest rntl-v14-update-deps --target . then npx codemod@latest rntl-v14-async-functions --target ./src.
For Maestro: detect .maestro/ directory. If missing AND user wants e2e, see references/maestro.md (manual install, not npm — Java 17+, .maestro/ flow layout, testID conventions, and ⚠️ the Expo Go vs dev-build caveat: Expo Go can't launchApp your own appId, use openLink: exp://…).
Step 3 — Choose what to test
Ask the user (one round-trip) what to test:
- Component (renders, props, interactions) → RNTL.
- Hook (
useQuery,useMutation, custom hook) → RNTLawait renderHook(...). - Pure function (utility, helper) → plain Jest.
- e2e flow (sign-in, navigation, form submit) → Maestro.
- Shared-state / two-user flow (user A takes a seat, a slot, an invite; user B must see it taken) → Maestro, two
runFlowsign-ins in one file withclearStatebetween them, against a real test backend —references/maestro.md§Two-user flows. Jest cannot express this; it is the check that finds authorization bugs between identities.
If the file under test is lib/api.ts, write a Jest test of the function with fetch mocked.
If the file under test is app/(auth)/sign-in.tsx, write an RNTL test of the screen + a Maestro flow of the user journey.
Step 4 — Write the test
Use the canonical patterns from references/rntl-patterns.md (or references/jest-setup.md for utilities, or references/maestro.md for e2e).
Test files:
- Unit/integration:
__tests__/<mirror-source-path>.test.tsx(or.test.tsfor non-JSX). - e2e:
.maestro/<flow-name>.yaml.
Step 5 — Run the test
cd <project-root> && npm test -- --runInBand --bail
(For Maestro: maestro test .maestro/<flow-name>.yaml.)
Test MUST pass. If it doesn't:
- Read the failure. Is it a missing mock? An assumption about the test API?
- Fix the test (NOT the source — that's a separate task).
- Re-run.
Step 6 — Commit
git add __tests__/<path>.test.tsx
git commit -m "test(<area>): cover <what>"
Common anti-patterns (NEVER do)
- ❌ Detox — heavy, Expo Go unfriendly, requires native build. Use Maestro.
- ❌ Enzyme — abandoned. RNTL only.
- ❌ Snapshot for every component — only for stable design-system primitives.
- ❌
jest.fn()without typing the return → tests pass on wrong shape. - ❌ Testing implementation details (state names, internal function calls) → brittle. Test user-facing behavior.
- ❌ Testing TanStack Query result shape directly → mock the queryFn instead and assert UI.
- ❌ Skipping cleanup → tests leak state. Always use RNTL's auto-cleanup (default).
- ❌ Sleeping
await new Promise(r => setTimeout(r, 1000))→ usewaitForwith explicit assertions.
Updating meta.json (recommended pattern)
This skill does NOT advance meta.json#phase (see Contract above) — never call set-phase. It only records the artifact and appends history, using the canonical script when available:
# Wherever dev-flow is installed (e.g. ~/.claude/skills/dev-flow/), invoke:
python3 .../dev-flow/scripts/update_meta.py <project-root> record-artifact \
--path <relative-path> --produced-by '<this-skill-name>' [--derived-from <p1> <p2> ...]
python3 .../dev-flow/scripts/update_meta.py <project-root> append-history \
--skill '<this-skill-name>' --inputs '{...}' --outputs '{...}' --phase-after <current_phase>
The script normalizes legacy kebab-case aliases (e.g. module-added → module_added) and writes the canonical sha256 + timestamp into meta.json#artifacts. Fall back to direct JSON editing only if the script is not on PATH (and warn the user).
Sources
- Course: codewithbeto.dev/rnCourse — "Testing" module (paid, distilled).
- Official: https://callstack.github.io/react-native-testing-library/
- Official: https://docs.expo.dev/develop/unit-testing/
- Official: https://maestro.mobile.dev/