SuperAPI AI Directory

unit-and-component-tests

Testing & quality

View repository

Updated 10-18 18:02

About this skill

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.

Workflow

  1. 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 from templates/ (root configs, scripts/quality/, __mocks__/; babel.config.js as it is: its Stryker override compiles only the files Stryker instrumented without the Worklets and Reanimated plugins, see step 8), and merge assets/package.test-deps.json into package.json (scripts and exact devDependency pins). If the app followed Expo's unit-testing guide, delete its "jest": { "preset": "jest-expo" } key from package.json and its "test": "jest --watchAll" script: jest.config.js is 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.tsx and remove react-test-renderer, as the guide itself says (jest-orphan-tests and test-dep-deprecated name 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.json extends tsconfig.base.json from typescript-and-lint-rules' tsconfig set: adopt that set first, or point its extends at ./tsconfig.json (stryker-tsconfig names it). Tests another runner owns (Playwright for the web build, Cypress, Deno under supabase/functions/) that the checker cannot detect go into contracts/test-setup.json otherRunnerGlobs. Read references/jest-config.md before changing any config; the configs and the device-only helper are gated files (quality-gates).
  2. Wire the render helpers once. Copy templates/test/: render-with-providers.tsx with its test, test-app.tsx, flush-microtasks.ts, play-actions.ts with its test, render-router-with-providers.tsx with its test when the app uses Expo Router (an app on React Navigation has no expo-router/testing-library; it follows the appendix of references/component-tests.md instead), and skia-mock.test.tsx when the app depends on Skia. test-app.tsx already wraps ServicesProvider over createTestServices(options.services), the composition root of architecture-and-boundaries, and takes the language and direction options (text locale, layout direction); each file it imports that the app lacks comes from templates/src/ (synced copies; never over an existing file such as the app's languages.ts). Then grow test/test-app.tsx with 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.
  3. Pick the kind of test and read its reference once per session:
  4. Place and name the test: next to its unit as <unit>.test.ts(x) (<unit>.android.test.tsx for Android-only behaviour, test/integration/<area>/ when it needs Node), never inside src/app/: a route test goes to src/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>'), optional describe('when …'), then it('clears …'), never it('should …'); no it.skip, it.only, xit.
  5. Run the one file and read the failure: npx jest <file> --ci --selectProjects unit (--selectProjects android for an Android test; paths always before the flag). Red must be an assertion diff, not an import or type error.
  6. Make it green, then check the test code: node ${CLAUDE_SKILL_DIR}/scripts/check-test-code.mjs .. Fix each FAIL line (file, rule and fix are printed) and rerun until RESULT: 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; --help lists them.
  7. 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='{}'.
  8. Mutation-test finished logic (a reducer, policy, repository or src/core module): npx stryker run --mutate <file>, then node ${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 "useAnimatedStyle was used without a dependency array or Babel plugin" while npx jest passes, babel.config.js is an old form that compiles every file without the Worklets plugin: copy templates/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).
  9. Rerun the three checks until each prints RESULT: PASS.

Definition of done

  • npx jest --ci passes with no act(...) warnings, and npm ls react react-native shows 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 under test/, a route test in src/features/, none inside src/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:coverage passes (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> prints RESULT: PASS (score at least 75%, no survivor in the file, its mutants actually tested).
  • node ${CLAUDE_SKILL_DIR}/scripts/check-test-setup.mjs . prints RESULT: PASS (it prints no SKIP line; NOT APPLICABLE only in a repo without expo).
  • node ${CLAUDE_SKILL_DIR}/scripts/check-test-code.mjs . prints RESULT: PASS

Anti-patterns

  • render(<X />) without the providers, or without await. The component misses its catalog, stores and services, and the assertions run before React commits. Use await 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 reuse fake-<port>.ts with a call log.
  • import { fetchProducts } from 'expo-iap' in a test. It crashes or reads a second instance. jest.mock('expo-iap') plus a typed jest.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.tsx files still resolve to iOS. Write <unit>.android.test.tsx.
  • renderRouter(...) with expect(screen).toHavePathname(...), as Expo's SDK 57 testing guide shows it. renderRouter returns RNTL's render promise with the route getters attached to the promise, so the awaited result has none; with RNTL 14 its matchers and testRouter throw (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), then expect(view.getPathname()).
  • Tests in __tests__/<name>-test.tsx or *.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(...) with toBeOnTheScreen(), or getByText(...) with .not.toBeOnTheScreen(). The first hides a missing element's error message, the second throws before asserting.
  • const TASKS = sortTasks([...]) above the describe. 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 inside it(); 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) that tsconfig.stryker.json extends, 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 when jest -u is allowed.
  • e2e-maestro - what flows prove on simulators and emulators instead of Jest; the device-only reason names a flow.
  • quality-gates - check:fast, verify and the guardrail around these configs.
  • architecture-and-boundaries - ports, adapters, ServicesProvider and createTestServices.
  • state-management-zustand - the store factories the test app seeds.
  • persistence-and-migrations - the node:sqlite driver and migration tests under test/.
  • 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.