unit-and-component-tests
Installation
SKILL.md
Unit and component tests
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.