Every store, service, hook, component, route and pure module of an Expo app is proven in Jest (jest-expo, React Native Testing Library 14, fast-check, Stryker) through one root configuration, with components rendered inside the app's real providers on both iOS and Android presets. This skill builds that set-up, says how each kind of test is written, and its scripts prove the set-up, the test code and the mutation results.
Rules that must hold
- One Jest config at the repo root, run from the root, and every test file runs in exactly one place. Projects:
unit(presetjest-expo/ios),android(jest-expo/android,*.android.test.ts(x)only) andgolden(*.golden.test.tsonly, in every app: data goldens with the unit set-up, and Skia's CanvasKit environment only while the app depends on@shopify/react-native-skia). Long-running simulations run only throughjest.sim.config.js. Every test is a<unit>.test.ts(x)file insrc/,scripts/,plugins/(local config plugins) ortest/(no__tests__/<name>-test.tsx, no.spec.ts), and never inside the routes folder (src/app/). No second Jest config (apackage.jsonjestkey, ajest.config.ts). Why: Expo Router bundles every file insrc/app/as a route, so a test there breaksexpo exportwhile Jest still passes; jest-expo reads Babel options from the working directory;.android.tsxfiles,Platform.OSbranches and the hardware back button exist only under the Android preset; a CanvasKit environment without Skia fails every Jest run, and a golden project kept behind Skia silently never ran data goldens, and neither do tests in Expo's guide layout under this config; two configs stop Jest with "Multiple configurations found" (all verified). - Exact test-tool pins and one React. jest 29.7.0 (Expo's line), jest-expo from
expo/bundledNativeModules.json,@react-native/jest-presetequal toreact-native, RNTL 14.0.1 installed in the same command as test-renderer 1.2.0 (its minor equals React's), fast-check 4, the three Stryker packages on one version;reactandreact-nativeexact (when multi-package: rootoverrides); noreact-test-renderer. Why: npm alone picked test-renderer 1.3.0 (React 19.3), a monorepo without overrides hoisted a second React for the tests, and React 19 deprecatesreact-test-renderer, which jest-expo still installs, so a test importing it keeps passing unnoticed (verified). - Import through the path aliases,
@/tosrc/and@test/totest/, mirrored inmoduleNameMapper; when multi-package, another workspace by its package name, mapped to itssrc/. Why: jest-expo derives aliases only from the working directory'stsconfig.json, and through workspace symlinks Stryker mutates a copy the tests never run (verified). - Await every RNTL call (
render,renderHook,act,waitFor,waitForElementToBeRemoved,fireEvent,findBy*,user.*, the render-helper functions, andrerender/unmounton what a render returned). QuerygetByRolefirst,findBy*for what appears later,queryBy*only to prove absence; press throughuserEvent.setup(). Why: RNTL 14 is async; an un-awaited call runs after the assertions. - Render every component and hook through the render helper (
renderWithProviders,renderHookWithProviders,createProvidersWrapperfrom@test/render-with-providers), and every route test throughrenderRouterWithProviders(@test/render-router-with-providers), never Expo Router'srenderRouter. The helpers build the app's real providers fresh for each render fromtest/test-app.tsx: catalogs where a missing message throws, stores seeded through their real factories, only the services the test passes (the others throw and name themselves). They return what the tree holds. A test whose unit reads nothing from context, that supplies the provider under test itself (<I18nProvider>around<T />), or that proves a hook's no-provider default keeps plainrender/renderHookwith// no-providers: <why>in the file. Assert visible text, roles and accessible names; assert styles only in the design-system folders or under// allow-style-assertion: <why>; no component snapshots. Why: tests see what the app, VoiceOver and TalkBack see, and survive refactors; with RNTL 14,renderRouter's matchers throw and it leaves fake timers on (verified). - Native SDKs only through root
__mocks__/<sdk>.ts. An adapter test pairsjest.mock('<sdk>')with a locally typedjest.requireMock; no test imports the SDK. Skia is mocked once, injest.setup.ts. Why: these SDKs crash on import in Jest, andrequireMockwithoutjest.mockreads a second instance with zero calls (verified). - Everything above an adapter is tested through fakes (
fake-<port>.ts: a small real implementation with a shared call log); store state changes only by seeding and through the store's own actions, never by mocking a store module. Why: fakes make service rules millisecond tests and prove cross-service order with one assertion. - Tests are deterministic.
TZ=UTCin every Jest config, an injectedClockPortand seeded RNG,flushMicrotasks()instead of sleeps, fake timers only for UI timers, no retries, and Android behaviour in<unit>.android.test.ts(x), never by overwritingPlatform.OS. Why: a flaky test is a bug; an overwrittenPlatform.OSleaks into later tests and skips.androidfiles. - Pure logic gets examples, fast-check properties and one pinned value; a counterexample becomes an example test before the fix. Why: properties alone left 3 of 8 mutants of a random-number generator alive; the pinned sequence killed them.
- Tests that need Node APIs live under the root
test/(node:sqlite,node:fs,Buffer). Why: the app program has onlyjesttypes and lint bansnode:*imports insrc/. - Coverage 90/90/90/85 globally and 95/95/95/90 for the logic keys; mutation score at least 75%, measured on mutants a test really ran, survivors killed by boundary examples. Never lower a threshold, never
istanbul ignore, never// Stryker disable. Why: one agent writes code and tests; coverage stood at 97.8% while the mutation score was 77.1%, and Stryker passes a run whose every mutant was skipped as static (score NaN, verified); Stryker obeys a// Stryker disablecomment and reports those mutants as Ignored, outside the score, so quality-gates' bypass check fails both comments. - Every source file ships with a test or starts with
// device-only: covered by <check>in its first 6 lines, and only native adapters, Skia and frame-callback modules, composition files and tooling entry points may carry it.jest.config.jsleaves exactly the marked files out of coverage through the tested helperscripts/quality/device-only.ts; fakes and pure helpers always get tests. A route file insrc/app/meets this through the route test that renders its default export fromsrc/features/(never a test beside it) or the marker naming the flow that opens it. Why: untested composition files held a real app at 83.8% statements, and a hand-written exclusion hides real code.
Workflow
- Check the set-up from the repo root:
node ${CLAUDE_SKILL_DIR}/scripts/check-test-setup.mjs .. For each missing piece copy the template of the same path fromtemplates/(root configs,scripts/quality/,__mocks__/;babel.config.jsas it is: its Stryker override compiles only the files Stryker instrumented without the Worklets and Reanimated plugins, see step 8), and mergeassets/package.test-deps.jsonintopackage.json(scripts and exact devDependency pins). If the app followed Expo's unit-testing guide, delete its"jest": { "preset": "jest-expo" }key frompackage.jsonand its"test": "jest --watchAll"script:jest.config.jsis the one config. The guide also puts tests in__tests__/<name>-test.tsx, which no project runs: move each next to its unit as<unit>.test.tsxand removereact-test-renderer, as the guide itself says (jest-orphan-testsandtest-dep-deprecatedname each one). Install the Testing Library with its companion in one command:npm install -D --save-exact @testing-library/react-native@14.0.1 test-renderer@1.2.0.tsconfig.stryker.jsonextendstsconfig.base.jsonfrom typescript-and-lint-rules' tsconfig set: adopt that set first, or point itsextendsat./tsconfig.json(stryker-tsconfignames it). Tests another runner owns (Playwright for the web build, Cypress, Deno undersupabase/functions/) that the checker cannot detect go intocontracts/test-setup.jsonotherRunnerGlobs. Read references/jest-config.md before changing any config; the configs and the device-only helper are gated files (quality-gates). - Wire the render helpers once. Copy
templates/test/:render-with-providers.tsxwith its test,test-app.tsx,flush-microtasks.ts,play-actions.tswith its test,render-router-with-providers.tsxwith its test when the app uses Expo Router (an app on React Navigation has noexpo-router/testing-library; it follows the appendix of references/component-tests.md instead), andskia-mock.test.tsxwhen the app depends on Skia.test-app.tsxalready wrapsServicesProviderovercreateTestServices(options.services), the composition root of architecture-and-boundaries, and takes thelanguageanddirectionoptions (text locale, layout direction); each file it imports that the app lacks comes fromtemplates/src/(synced copies; never over an existing file such as the app'slanguages.ts). Then growtest/test-app.tsxwith the app's providers, in the order the root layout uses them, as each lands: catalogs, store factories with a seed, theme. examples/test-app.tsx shows a Tasks app fully wired. - Pick the kind of test and read its reference once per session:
- component, screen or hook: references/component-tests.md; models
templates/test/render-with-providers.test.tsxand examples/use-tasks-model.test.tsx; - navigation (a link, a deep link, a redirect, back): the same reference, section "Routes and navigation"; model
templates/test/render-router-with-providers.test.tsx; - Android-only behaviour (an
.android.tsxfile, the hardware back button, aPlatform.selectbranch): the same reference, section "Android behaviour"; model examples/task-editor.android.test.tsx; - adapter over a native SDK, fake, service or policy: references/mocks-and-fakes.md; models examples/expo-iap-purchase-adapter.test.ts, examples/fake-purchase.ts, examples/premium-service.test.ts;
- reducer, policy or other pure logic: references/properties.md; model examples/task-list-reducer.test.ts.
- component, screen or hook: references/component-tests.md; models
- Place and name the test: next to its unit as
<unit>.test.ts(x)(<unit>.android.test.tsxfor Android-only behaviour,test/integration/<area>/when it needs Node), never insidesrc/app/: a route test goes tosrc/features/<feature>/<feature>-navigation.test.tsx. Titles read as the spec and pass lint (jest/valid-title^(can|[a-z]+s)\b):describe('<exported name>'), optionaldescribe('when …'), thenit('clears …'), neverit('should …'); noit.skip,it.only,xit. - Run the one file and read the failure:
npx jest <file> --ci --selectProjects unit(--selectProjects androidfor an Android test; paths always before the flag). Red must be an assertion diff, not an import or type error. - Make it green, then check the test code:
node ${CLAUDE_SKILL_DIR}/scripts/check-test-code.mjs .. Fix eachFAILline (file, rule and fix are printed) and rerun untilRESULT: PASS. A unit that really reads nothing from context (or a test of the provider itself or of a no-provider default), a timer helper, or a style that is the contract gets the reasoned marker the fix names (no-providers,allow-fake-timers,allow-style-assertion), not a rewrite;--helplists them. - Run the whole suite with coverage:
npm run test:coverage. Below a threshold, add tests for the uncovered behaviour (references/coverage-and-mutation.md); a native adapter, Skia or frame-callback module, composition file or tooling entry point gets the device-only marker instead, naming the check that covers it. For a subset, put the paths first and switch the thresholds off:npx jest <paths> --ci --selectProjects unit --coverage --collectCoverageFrom='<path>/**/*.ts' --coverageThreshold='{}'. - Mutation-test finished logic (a reducer, policy, repository or
src/coremodule):npx stryker run --mutate <file>, thennode ${CLAUDE_SKILL_DIR}/scripts/check-mutation-report.mjs . --changed <file>. If Stryker stops with "There were failed tests in the initial test run" and the Reanimated error "useAnimatedStylewas used without a dependency array or Babel plugin" whilenpx jestpasses,babel.config.jsis an old form that compiles every file without the Worklets plugin: copytemplates/babel.config.js, whose override applies only to the files Stryker instrumented (references/jest-config.md, section babel.config.js). Kill each survivor with a boundary example and rerun both; an equivalent mutant is accepted only with--allow <id>and a sentence in the change report. Nightly and before a release:npm run test:mutation, then the same check without--changed(it also fails any file none of whose mutants was tested). - Rerun the three checks until each prints
RESULT: PASS.
Definition of done
-
npx jest --cipasses with noact(...)warnings, andnpm ls react react-nativeshows one version of each. - Every new test sits next to its unit (an Android-only case in
<unit>.android.test.tsx, a Node-API test undertest/, a route test insrc/features/, none insidesrc/app/) and was seen failing on an assertion first. - Components and hooks render through the render helper, routes through the router helper; adapters are tested against the root mocks; services over fakes.
-
npm run test:coveragepasses (90/90/90/85 global, 95/95/95/90 logic keys). - Finished logic modules:
node ${CLAUDE_SKILL_DIR}/scripts/check-mutation-report.mjs . --changed <file>printsRESULT: PASS(score at least 75%, no survivor in the file, its mutants actually tested). -
node ${CLAUDE_SKILL_DIR}/scripts/check-test-setup.mjs .printsRESULT: PASS(it prints no SKIP line;NOT APPLICABLEonly in a repo withoutexpo). -
node ${CLAUDE_SKILL_DIR}/scripts/check-test-code.mjs .printsRESULT: PASS
Anti-patterns
render(<X />)without the providers, or withoutawait. The component misses its catalog, stores and services, and the assertions run before React commits. Useawait renderWithProviders(<X />, options).- Mocking a store or one of the app's own modules to "make the test easy". The test then proves the mock. Seed through the render helper's options and call the actions of the returned
stores. - A bag of
jest.fn()standing in for a port. It cannot prove order or state. Write or reusefake-<port>.tswith a call log. import { fetchProducts } from 'expo-iap'in a test. It crashes or reads a second instance.jest.mock('expo-iap')plus a typedjest.requireMock.await new Promise((r) => setTimeout(r, 50)). Slow and flaky.findBy*for the screen,flushMicrotasks()for services.Platform.OS = 'android'in an iOS-project test. It leaks into the next test, and.android.tsxfiles still resolve to iOS. Write<unit>.android.test.tsx.renderRouter(...)withexpect(screen).toHavePathname(...), as Expo's SDK 57 testing guide shows it.renderRouterreturns RNTL's render promise with the route getters attached to the promise, so the awaited result has none; with RNTL 14 its matchers andtestRouterthrow (getPathname is not a function), and it leaves fake timers on for the rest of the file (verified with expo-router 57.0.24).await renderRouterWithProviders(routes), thenexpect(view.getPathname()).- Tests in
__tests__/<name>-test.tsxor*.spec.ts, as Expo's guide and Jest's defaults allow. No project runs them, so they pass by never running. Name every test<unit>.test.ts(x)beside its unit. expect(view.toJSON()).toMatchSnapshot()for a component. It fails on every harmless change and proves nothing a user sees. Assert roles, names and state; readable data snapshots belong in a golden test.queryByText(...)withtoBeOnTheScreen(), orgetByText(...)with.not.toBeOnTheScreen(). The first hides a missing element's error message, the second throws before asserting.const TASKS = sortTasks([...])above thedescribe. The unit runs at import, Stryker marks every mutant of it static and skips it, and reports a score of NaN as a pass (verified). Call the unit insideit(); module-level fixtures are plain data.- Weakening a gate to get green: lower coverage numbers, a break under 75,
istanbul ignore,Stryker disable,jest.retryTimes. This skill's checkers fail the numbers and the retries; quality-gates' bypass check fails both comments. Fix the test or the code; if a gate looks wrong, stop and ask the developer.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/jest-config.md | Versions and re-verify commands, file layout, Babel, jest.config.js part by part, jest.setup.ts, the simulation config, scripts, the contract, when multi-package, known failures, flaky tests |
Workflow step 1, and whenever a config or pin changes or Jest fails oddly |
| references/component-tests.md | RNTL 14 async model, the render helper and test/test-app.tsx, queries, matchers, user-event, hooks, stores, gestures, Skia, Android behaviour, styles, what not to test |
Workflow steps 2 and 3 for components, screens and hooks |
| references/mocks-and-fakes.md | Root native-SDK mocks and why each exists, adapter tests with requireMock, fakes with call logs, services and policies over fakes |
Workflow step 3 for adapters, services, policies |
| references/properties.md | Examples + fast-check properties + pinned values, playActions, the property checklist, counterexamples, simulations, node:sqlite placement |
Workflow step 3 for pure logic |
| references/coverage-and-mutation.md | Thresholds and key semantics, the device-only marker, subsets, Stryker config, reading survivors, a worked boundary example, equivalent mutants | Workflow steps 7 and 8 |
templates/jest.config.js |
The one Jest config: unit, android and golden projects (CanvasKit with Skia), aliases, UTC, transforms, coverage gates (synced from the library; do not edit here) | Copy to the repo root (step 1) |
templates/jest.setup.ts |
Gesture Handler, Worklets and Reanimated setup and the one guarded Skia mock, for unit, android and (without Skia) golden (synced; do not edit here) | Copy to the repo root (step 1) |
templates/jest.sim.config.js |
Plain-Node config for *.sim.test.ts simulations (synced; do not edit here) |
Copy only when the app has simulations (game-balance-and-bots) |
templates/babel.config.js |
Root Babel config whose Stryker override compiles only the instrumented files without the Worklets and Reanimated plugins (synced; do not edit here) | Copy to the repo root (step 1); replaces an old all-files branch (step 8) |
templates/stryker.config.json |
Stryker scope, thresholds, ignore list, type-check scope, reporters (synced; do not edit here) | Copy to the repo root (step 1) |
templates/tsconfig.stryker.json |
The jest-only program Stryker's type checker uses (synced; do not edit here) | Copy to the repo root (step 1) |
templates/scripts/package.json |
Marks scripts/ as ES modules so Node loads the helper (synced; do not edit here) |
Copy with the helper (step 1) |
templates/scripts/quality/device-only.ts |
The one helper that turns // device-only: covered by ... markers into coverage exclusions (synced; do not edit here) |
Copy with jest.config.js (step 1) |
templates/scripts/quality/device-only.test.ts |
The helper's test (synced; do not edit here) | Copy with the helper |
templates/test/render-with-providers.tsx |
renderWithProviders, renderHookWithProviders, createProvidersWrapper (synced; do not edit here) |
Copy once (step 2) |
templates/test/render-with-providers.test.tsx |
The helper's own test and the model of every component and hook pattern (synced; do not edit here) | Copy with the helper; read before the first component test |
templates/test/render-router-with-providers.tsx |
renderRouterWithProviders: Expo Router's in-memory router inside the same providers, with route getters (synced; do not edit here) |
Copy once when the app uses Expo Router (step 2) |
templates/test/render-router-with-providers.test.tsx |
Its test and the model of a route test: link press, deep link, navigation from outside React (synced; do not edit here) | Copy with the helper; read before the first route test |
templates/test/test-app.tsx |
The app's provider tree for tests: ServicesProvider over createTestServices, the language and direction options (react-intl that throws on a missing message, the text locale, the layout direction), a fresh TanStack Query cache (synced; do not edit here) |
Copy once, then grow it in the app (step 2) |
templates/src/ |
What test-app.tsx imports, synced from their owners (do not edit here): the composition root's test side (services-context.tsx, create-test-services.ts, the clock, error-log and direction-guard ports, fake-clock.ts, core/dates/date-key.ts, core/result.ts), i18n/{languages,digits,language-context,direction-context} and theme/text-locale.tsx |
Step 2, each file only when the app has none at that path (never over the app's languages.ts) |
templates/test/flush-microtasks.ts |
Lets queued promise callbacks run without fake timers (synced; do not edit here) | Copy with the helper |
templates/test/play-actions.ts |
Turns fast-check integers into legal action sequences for any pure model (synced; do not edit here) | Copy before the first property test of a reducer or policy |
templates/test/play-actions.test.ts |
Its test, a compact property-test model (synced; do not edit here) | Copy with play-actions.ts |
templates/test/skia-mock.test.tsx |
Proves the central Skia mock (synced; do not edit here) | Copy when the app depends on Skia |
templates/__mocks__/ |
Root manual mocks: expo-iap, expo-tracking-transparency, react-native-audio-api, react-native-google-mobile-ads, react-native-keyboard-controller (synced; do not edit here) |
Copy the mock of each SDK the app depends on |
templates/contracts/test-setup.json |
The optional contract with the app's facts (render and router helpers, root mocks, logic folders), filled with the defaults | Copy when the app's logic or helper lives elsewhere |
templates/contracts/test-setup.schema.json |
JSON Schema of the contract | With the contract |
| examples/test-app.tsx | test/test-app.tsx fully wired for a Tasks app: services, seeded stores, catalogs, theme, query cache |
Workflow step 2 |
| examples/use-tasks-model.test.tsx | A hook test: seeded through the helper, changed by a store action | To imitate for hooks |
| examples/task-editor.android.test.tsx | An Android-only test: the hardware back button under jest-expo/android, on a screen and on the router stack |
To imitate for Android behaviour |
| examples/expo-iap-purchase-adapter.test.ts | An adapter test against a root mock with a typed requireMock |
To imitate for adapters |
| examples/fake-purchase.ts | A fake port: a mutable script, a shared call log, emitted events | To imitate for fakes |
| examples/premium-service.test.ts | A service test over fakes: "save, then finish the transaction", and its failure paths (a refused save, a failed restore sync, the launch re-check, an unfinished purchase) | To imitate for services |
| examples/task-list-reducer.test.ts | Pure logic: examples, invalid input, determinism, invariants over any action sequence, round trip, pinned value | To imitate for reducers and policies |
assets/package.test-deps.json |
Test scripts, exact devDependency pins, and the React overrides for a multi-package repo | Workflow step 1 |
assets/shared.json |
Declares the shared files this skill copies in | When adding a shared file |
scripts/check-test-setup.mjs |
Checks pins and companions, one React and no react-test-renderer, scripts, Babel, one Jest config, the Jest projects and that every file Jest or Expo would take for a test runs in exactly one (other runners' folders left out), no test in the routes folder, aliases, transforms, coverage scope and the device-only exclusions, setup, simulations, Stryker and its tsconfig, root mocks, the render helper |
Workflow step 1 and the definition of done |
scripts/check-test-code.mjs |
Checks every test file of the repo (__tests__/ and *.spec ones too): awaits, the render helpers, queries, styles, snapshots, react-test-renderer, SDK imports, requireMock, store mocks, timers, sleeps, retries, Platform.OS, Expo Router's test API, imports across roots |
Workflow step 6 and the definition of done |
scripts/check-mutation-report.mjs |
Reads reports/stryker/mutation.json: score against the break threshold, survivors in --changed files, any file (or the whole report) with no tested mutant (a types-only file, or one whose every mutant failed to compile, needs none) |
Workflow step 8 |
scripts/lib/test-contract.mjs |
Shared by the two repo checkers: the contract, its defaults, manifests, the known root-mock SDKs, the helper names, the files Jest or Expo would take for tests, and the folders other runners own | Read when changing a checker |
scripts/lib/config-lines.mjs |
Finds the line of a key in package.json, stryker.config.json and jest.config.js (per Jest project) for check-test-setup's problem lines |
Read when changing check-test-setup |
scripts/lib/source-scan.mjs |
Shared lexer; check-test-code uses its maskCode to walk call chains without being misled by strings (synced; do not edit here) |
Never by hand |
scripts/selftest.mjs |
Proves the three checkers pass their good fixtures and catch every planted bug | After changing a checker |
scripts/check-lib.mjs |
Shared script helper (synced; do not edit here) | Never by hand |
tests/bases/ |
Base trees the fixtures start from: a single Expo app, and a layer that turns it into a monorepo | When adding a fixture |
tests/fixtures/ |
Good, pass, bad and error cases for each checker (overlay format) | When adding a rule to a checker |
Related skills
typescript-and-lint-rules- the tsconfig set (tsconfig.base.json) thattsconfig.stryker.jsonextends, and the lint rules tests obey.tdd-workflow- the red-green loop, test names and the order of layers.golden-and-snapshot-tests- data and pixel goldens, the golden project's matcher and whenjest -uis allowed.e2e-maestro- what flows prove on simulators and emulators instead of Jest; the device-only reason names a flow.quality-gates-check:fast,verifyand the guardrail around these configs.architecture-and-boundaries- ports, adapters,ServicesProviderandcreateTestServices.state-management-zustand- the store factories the test app seeds.persistence-and-migrations- thenode:sqlitedriver and migration tests undertest/.i18n-strings-and-catalogs- the catalogs the test app loads and the bidi isolates in accessible names.seeded-determinism-and-golden-vectors- pinned sequences and independent oracles.skia-canvas-rendering- draw-call tests and the frame clock.