← All posts

Testing methodology

6 Jun 2026

Pick the narrowest layer that proves the behavior. Do not repeat the same assertion across layers.

Layers

LayerUse forRunners
UnitPure functions, mappers, Zod, hook logic — no DOM/networkVitest (web, packages) · Jest (Expo)
ComponentOne fragment: dialog, form, conditional UI, a11yVitest + RTL (Next) · Jest + RNTL (Expo)
API unittRPC procedure contractsVitest (packages/api)
E2EReal stack: auth, routing, DB, cross-page journeysPlaywright · Maestro

Demote rule: if E2E only checks one field/state on one screen → component test + one happy-path E2E for the journey.

Coverage goals

LayerTarget
Unit80%+ on shared logic (packages, utilities)
API unitAll tRPC procedures
ComponentHigh-value dialogs, forms, permission-gated UI — not every leaf
E2ECritical user journeys only; no duplicate atomic UI assertions across layers
StressLarge 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: TranslationProviderQueryClientProvider (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:

  1. ButtonIcon in Actionsheet — Gluestack ButtonIcon needs a parent Button context. Component test mocked Button/ButtonIcon as stubs → never ran real composition.
  2. Back button after hook splituseItemMetadata gained handleCancelEditNotes / handleCancelEditImageUrl; [itemId].tsx still called handleCancelEdit. Section tests mock the hook; the screen has no test.
  3. Loader2 not importedshopping-list-item-detail-dialog.unit.test.ts only 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 typecheck on 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.