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

  1. One Jest config at the repo root, run from the root, and every test file runs in exactly one place. Projects: unit (preset jest-expo/ios), android (jest-expo/android, *.android.test.ts(x) only) and golden (*.golden.test.ts only, 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 through jest.sim.config.js. Every test is a <unit>.test.ts(x) file in src/, scripts/, plugins/ (local config plugins) or test/ (no __tests__/<name>-test.tsx, no .spec.ts), and never inside the routes folder (src/app/). No second Jest config (a package.json jest key, a jest.config.ts). Why: Expo Router bundles every file in src/app/ as a route, so a test there breaks expo export while Jest still passes; jest-expo reads Babel options from the working directory; .android.tsx files, Platform.OS branches 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).
  2. Exact test-tool pins and one React. jest 29.7.0 (Expo's line), jest-expo from expo/bundledNativeModules.json, @react-native/jest-preset equal to react-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; react and react-native exact (when multi-package: root overrides); no react-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 deprecates react-test-renderer, which jest-expo still installs, so a test importing it keeps passing unnoticed (verified).
  3. Import through the path aliases, @/ to src/ and @test/ to test/, mirrored in moduleNameMapper; when multi-package, another workspace by its package name, mapped to its src/. Why: jest-expo derives aliases only from the working directory's tsconfig.json, and through workspace symlinks Stryker mutates a copy the tests never run (verified).
  4. Await every RNTL call (render, renderHook, act, waitFor, waitForElementToBeRemoved, fireEvent, findBy*, user.*, the render-helper functions, and rerender/unmount on what a render returned). Query getByRole first, findBy* for what appears later, queryBy* only to prove absence; press through userEvent.setup(). Why: RNTL 14 is async; an un-awaited call runs after the assertions.
  5. Render every component and hook through the render helper (renderWithProviders, renderHookWithProviders, createProvidersWrapper from @test/render-with-providers), and every route test through renderRouterWithProviders (@test/render-router-with-providers), never Expo Router's renderRouter. The helpers build the app's real providers fresh for each render from test/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 plain render/renderHook with // 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).
  6. Native SDKs only through root __mocks__/<sdk>.ts. An adapter test pairs jest.mock('<sdk>') with a locally typed jest.requireMock; no test imports the SDK. Skia is mocked once, in jest.setup.ts. Why: these SDKs crash on import in Jest, and requireMock without jest.mock reads a second instance with zero calls (verified).
  7. 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.
  8. Tests are deterministic. TZ=UTC in every Jest config, an injected ClockPort and 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 overwriting Platform.OS. Why: a flaky test is a bug; an overwritten Platform.OS leaks into later tests and skips .android files.
  9. 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.
  10. Tests that need Node APIs live under the root test/ (node:sqlite, node:fs, Buffer). Why: the app program has only jest types and lint bans node:* imports in src/.
  11. 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 disable comment and reports those mutants as Ignored, outside the score, so quality-gates' bypass check fails both comments.
  12. 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.js leaves exactly the marked files out of coverage through the tested helper scripts/quality/device-only.ts; fakes and pure helpers always get tests. A route file in src/app/ meets this through the route test that renders its default export from src/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.
Installs
5
GitHub Stars
12
First Seen
12 days ago