Skip to content

Testing Strategy ​

STALE — there is no "4-layer Vitest model" (2026-08-08)

Vitest-for-everything, src/test/setup.ts and .env.test supplying VITE_SUPABASE_* all belong to the retired Vite SPA (legacy/). The current model is FOUR runners split by workspace, and which one you get is decided by where the code lives, not by what layer it is:

RunnerWhereWhat
node --testpackages/*Pure computation. @qrsetu/domain needs no DB and no bundler — that is deliberate.
jest-expoapps/mobileHooks/services against the packages/data stub (not a mock of it) + component smoke: import the real component, never mock it or its icons.
vitestapps/webNo jsdom; renderToStaticMarkup smoke tests.
Playwrightapps/mobile/e2e, apps/web/e2eWeb surface only. Plus pgTAP (test:db) and Deno (test:ef) for the backend.

⚠ apps/mobile/e2e/visual.spec.ts does not exist (QRS-364) — the folder holds layout-invariants, parity-probe and theme-consistency only. apps/web/e2e/visual.spec.ts (M14) is the first real implementation of the Linux-only-screenshot pattern.

⚠ No automated layer covers Android or iOS native. For anything touching packages/tokens/**, apps/*/src/ui/**, theme plumbing or press/gesture handling, the native builds are the gate. Authoritative list: CLAUDE.md § Testing model.

Co-located tests/ per feature. Coverage thresholds: 70% lines / 80% functions.

The 4-layer Vitest model ​

LayerWhatKey rule
L1Pure functionsNo mocks needed; fast.
L2Hooks/servicesMock supabase + TanStack Query. Assert correct rpc/invoke calls + query keys.
L3Component smokeImport the real component. First test = "mounts without error."
L4Page integrationOptional. Plus a compliance test asserting the feature never calls from().

The RiEdit3Line lesson: never mock the component under test or its icon imports — a smoke test that mocks the thing it's testing proves nothing. Import the real component and real icons.

Config essentials ​

  • vitest.config.ts is standalone — deliberately does NOT merge vite.config.js (keeps the dev-only Horizons/visual-editor plugins out of the runner). It duplicates only resolve.alias/extensions + @vitejs/plugin-react.
  • Setup: src/test/setup.ts (@testing-library/jest-dom/vitest).
  • globals: false → the setup file must register afterEach(cleanup) itself (otherwise render() calls silently accumulate across tests in the same file).
  • .env.test (committed, dummy values) supplies VITE_SUPABASE_* so the client doesn't throw.

Commands ​

bash
npm test                 # full suite
npm run test:watch       # watch mode
npm run test:coverage    # coverage (70% lines / 80% functions)
npx vitest run path/to/file.test.tsx   # single file
npm run test:ef          # Deno edge-function tests

Other layers ​

LayerToolStatus
Edge FunctionsDeno test:ef✅ live — every EF ships co-located tests/.
SQL / RPCpgTAP test:db🎯 planned — RPC projections don't leak columns; RLS isolation; grants.
E2EPlaywright e2e:*🎯 planned — ephemeral local Supabase stack (schema snapshot, not migration replay); functions serve as a separate process; mutation guard; deploy-smoke on the real URL.

What EF tests must cover ​

Success · validation failure · auth/authz failure · safe 500 (no leakage) · edge cases. Green before deploy. See Adding an Edge Function.

Testing model (operating-manual text) ​

Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)

This is the verbatim text of CLAUDE.md § "Testing model" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.

Testing model ​

Co-located __tests__/ next to the code. Layers, by runner:

  • node --test (packages/*) — L1 pure computation. @qrsetu/domain is the home for anything derivable (recurrence expansion, DST, notification budgets) precisely so it needs no database and no bundler.
  • jest-expo (apps/mobile) — L2 hooks/services (against the packages/data stub, not a mock of it) · L3 component smoke: import the real component; first test = "mounts without error" — never mock the component or its icon imports (the RiEdit3Line lesson). Also used for build-config assertions (app/__tests__/native-splash|web-manifest|sentry-build-config).
  • Playwright (apps/mobile/e2e, web surface only) — two layers, order deliberate: layout-invariants.spec.ts is deterministic DOM measurement (overflow, truncation, off-viewport controls, touch-target size; no baselines, no flake) and is the primary gate; visual.spec.ts DOES NOT EXIST (QRS-364 — see the note above; the intended second layer, screenshot baselines Linux-only by design, was never actually written for this app). Four viewport projects — phone-small (360×740, the tightest real case) · phone-large · tablet · desktop.
  • Playwright (apps/web/e2e, M14) — the SAME two-layer shape, actually built this time: layout-invariants.spec.ts + a11y.spec.ts (@axe-core/playwright, zero violations) as the primary gate, visual.spec.ts (Linux-only screenshot baselines) as the second layer. Runs against a static fixture rendered from the real SetuCardRenderer + real compiled CSS — no live Supabase route is reachable in this environment.
  • pgTAP (test:db) — RPC projections don't leak columns; RLS isolation; least-privilege grants ⚠ neither of those filenames exists — supabase/tests/database/ holds ten files covering chat, feature grants, orders/payments, the collected-payment predicate and reconciliation. This named two profiles-era files until 2026-08-28.
  • Deno (test:ef) — every EF ships co-located tests/ (success · validation · auth/authz · safe 500s · edges).
  • Static parity (check:parity) — the gate prints its own rule count; R1-R7 each encode a real incident (QRS-201/203/206/207), and R8 (ADR-0019's "one renderer" seam) and R9 (expo-camera has exactly one importer, so the per-surface fallback is single-sourced and the module stays swappable) were added pre-emptively, before an incident forced either. ⚠ "Every rule is mutation-tested" WAS FALSE and this line asserted it until 2026-08-24. There is no check-parity.test.mjs anywhere in the repo — measured by enumerating every *.test.mjs and *.test.js outside node_modules. The parity gate, which encodes four real incidents (QRS-201/203/206/207), is itself unproven in both directions (QRS-872). Add a rule for every new parity incident — this layer only ever encodes yesterday's defects, which is an acceptable trade at 200 ms provided it keeps learning.
  • Static analysis (npm run lint, type-aware, + CI's sonar job) — ~217 sonarjs rules plus eslint-plugin-security, and it is a defect layer, not a style layer: it has already found a catastrophically backtrackable regex in the check:sql gate itself, a quadratic PII scrubber on the crash path, and an always-true comparison whose "obvious" fix would have caused data loss (QRS-247/253). The type-aware program is what makes it work at all — without it ~15 rules that are already error silently never run, which is how the gate stayed green over 92 findings. Nothing is sequenced off any more (QRS-247 closed 2026-07-30): every sonarjs rule is ON, at zero. If a rule ever has to be turned off it needs a measured COUNT and a REASON in that layer's comment — that format is what let a 158-finding backlog finish instead of drift.

Coverage target 70% lines / 80% functions (not yet wired as a hard gate). No automated layer covers Android or iOS native — the native builds are the gate for anything systemic; see "Cross-platform feature parity".