Testing methodology
6 Jun 2026
Pick the narrowest layer that proves the behavior. Do not repeat the same assertion across layers.
Layers
| Layer | Use for | Runners |
|---|---|---|
| Unit | Pure functions, mappers, Zod, hook logic — no DOM/network | Vitest (web, packages) · Jest (Expo) |
| Component | One fragment: dialog, form, conditional UI, a11y | Vitest + RTL (Next) · Jest + RNTL (Expo) |
| API unit | tRPC procedure contracts | Vitest (packages/api) |
| E2E | Real stack: auth, routing, DB, cross-page journeys | Playwright · Maestro |
Demote rule: if E2E only checks one field/state on one screen → component test + one happy-path E2E for the journey.
Coverage goals
| Layer | Target |
|---|---|
| Unit | 80%+ on shared logic (packages, utilities) |
| API unit | All tRPC procedures |
| Component | High-value dialogs, forms, permission-gated UI — not every leaf |
| E2E | Critical user journeys only; no duplicate atomic UI assertions across layers |
| Stress | Large shopping-list scenarios — budgets live in apps/enduser-web/e2e/stress.spec.ts |
Per-feature E2E status: end-user feature specs under docs/3-End-User/.
Next.js component harness
Vitest · jsdom · RTL · user-event.
renderWithProviders: TranslationProvider → QueryClientProvider (retries off) → TRPCProvider (noop client; errors on unseeded queries).
Seed reads: seedQuery(queryClient, trpc.*.queryKey(input), fixture) — no network (Tier 1). MSW only when a test must assert submit + HTTP (Tier 2).
Vitest setup: CI=1 + NEXT_PUBLIC_APP_URL so env.ts validation does not block imports.
E2E stays for parallel routes, middleware, built app + Postgres.
Expo component harness
One runner: Jest + jest-expo for all of enduser-app (not Vitest). Web/packages keep Vitest.
RN 0.81: Babel-transform Expo’s ESM jest/setup.js or Jest fails at startup. Map ~/* → src/*.
Start harness with TranslationProvider; add QueryClient/tRPC when needed.
Maestro for tabs, deep links, offline queue, native gestures.
Blind spots (real bugs tests missed)
Three production failures, all green in CI:
ButtonIconin Actionsheet — GluestackButtonIconneeds a parentButtoncontext. Component test mockedButton/ButtonIconas stubs → never ran real composition.- Back button after hook split —
useItemMetadatagainedhandleCancelEditNotes/handleCancelEditImageUrl;[itemId].tsxstill calledhandleCancelEdit. Section tests mock the hook; the screen has no test. Loader2not imported —shopping-list-item-detail-dialog.unit.test.tsonly imports pure helpers (aggregateByUnit,hasMultipleUnits); contributions JSX never rendered.
Pattern: tests proved callbacks and testIDs on mocks, not real UI kit wiring or every hook consumer.
Proposed test additions (after review)
- Composition: do not mock Gluestack/Radix primitives that enforce parent context; mock network/tRPC only.
- Smoke render: one
renderWithProviders(<LargeDialog …>)/ screen mount per shell — catches missing imports and throw-on-render. - Consumer grep: when a hook return type changes, test each importing screen (thin navigation/back test is enough).
- CI:
pnpm typecheckon apps that render the changed files.
Cursor rules (review)
test-type-selection.mdc (current)
---
description: When to write unit vs component vs E2E tests — decision matrix for MikNik web and mobile
alwaysApply: true
---
# Test type selection
Pick the **narrowest** test layer that proves the behavior. Do not duplicate the same assertion across layers.
## Unit test
**Runner:** Vitest (`packages/*`, Next.js web apps) · Jest (`apps/enduser-app` only)
**Write when:**
- Pure function, mapper, formatter, Zod schema, or hook logic with no DOM
- Input → output with no providers, routing, or network
- Business rules already covered at API layer but you need a fast regression on a single function
**Do not write when:** the bug is "button hidden", "dialog shows error", or "list row renders X" — that is component or E2E.
**Location:** colocate `*.test.ts` next to source, or `packages/*/src/__tests__/` for API routers.
## Component test
**Runner:** Vitest + `@testing-library/react` (Next.js) · Jest + `@testing-library/react-native` (Expo)
**Write when:**
- One component or dialog: conditional rendering, validation messages, callbacks, disabled states
- Loading / empty / error / optimistic UI variants (mock query cache or props)
- Accessibility: roles, labels, `aria-*` (prefer role + name; add `data-testid` per data-testid-hygiene.mdc)
- Permission-gated UI with **seeded** tRPC query data (no real DB)
**Harness:** use `renderWithProviders` from `apps/<app>/src/test/render.tsx`. Seed reads with `seedQuery(queryClient, trpc.<router>.<proc>.queryKey(input), data)`. Mutations needing network: add MSW later — not default.
**Do not write when:** the behavior spans multiple pages, real auth, middleware, SSR, or offline sync.
**Do not mock** design-system primitives (`Button`, `ButtonIcon`, `DropdownMenu`, Gluestack slots) unless the test is explicitly unit-testing your wrapper. Mock tRPC/query data instead. See blind spots above.
## E2E test
**Runner:** Playwright (`apps/*-web/e2e/`) · Maestro (`apps/enduser-app/maestro/`)
**Write when:**
- Critical user journey end-to-end (sign-in → create → mutate → verify)
- Real stack: tRPC + DB + auth + routing + middleware
- Cross-feature flows (recipe → list → share)
- Visual regression (`visual.spec.ts`) and stress (`stress.spec.ts`)
- Mobile: navigation, deep links, offline queue, native gestures
**Do not write when:** a single dialog field or empty state is the only assertion — demote to component test.
## Demote rule
If an E2E test only checks one UI state inside one screen, **add a component test first**, then remove or slim the E2E case. Keep one happy-path E2E per journey.
## Bug fixes
Per sentry-test-first.mdc: failing test at the appropriate layer **before** the fix. UI bugs → component test preferred; integration bugs → API or E2E.
## Quick matrix
| Signal | Layer |
|--------|-------|
| `formatQuantity()` wrong output | Unit |
| Delete button hidden for read-only share | Component (seeded permissions) |
| Guest can create list and see it in sidebar | E2E |
| tRPC procedure rejects invalid input | API unit (`packages/api`) |
| Offline catalog sync replays queue | Maestro E2E |
| Hook renamed; screen still calls old method | Component smoke on screen OR integration test on consumer |
| DS child used outside required parent | Component test with real DS primitives |
sentry-test-first.mdc (current)
---
description: Sentry production errors — add a failing unit or E2E test to replicate before fixing
alwaysApply: true
---
# Sentry issues — test first, then fix
1. **Understand** — map stack to code paths.
2. **Replicate in tests (required before fix)** — unit/integration (Jest/Vitest) or E2E (Playwright); narrowest layer that proves the bug.
3. **Fix** — only after the test fails on the broken behavior.
4. **Verify** — run affected `pnpm test` / E2E slice.
Exceptions: emergency hotfix, Sentry noise filter with no behavior change, explicit waiver in PR.
data-testid-hygiene.mdc (selector priority only)
Selector priority:
1. Role + accessible name
2. Label / placeholder / text
3. `data-testid` — when role/name unstable; kebab-case, feature-prefixed, i18n-safe
4. CSS / structure — last resort
Rename/remove: update source and e2e + __tests__ in the same change.