Testing Strategy
This document describes the testing philosophy, toolchain, and tier model used in ropa2.0. Read this first before writing any test.
Philosophy
Three principles guide all testing decisions in this project:
1. Test at the right altitude. Every bug has a natural level where it is cheapest to catch. A bug in a pure utility function is caught in milliseconds by a unit test; catching it only in an E2E test means a slow feedback loop and a hard-to-isolate failure. Match the test to the scope of what it exercises.
2. No mocking below the boundary. Mocking too deep hides real integration problems. The rule here is: mock at the boundary of what you own. Server actions mock the service layer (which they call). Service tests use a real in-memory MongoDB (not mocked queries). E2E tests hit a real running server. Each tier trusts the layers below it to be tested separately.
3. Cost must justify confidence. Slow, fragile tests that break on unrelated changes are a liability. A test suite that takes 20 minutes to run will be skipped. The tier model exists precisely to keep fast tests fast and reserve expensive infrastructure (real browsers, real servers) for the flows that truly need it.
Tool stack
| Tool | Role |
|---|---|
| Vitest | Unit, integration, and component test runner |
| Testing Library | React component and hook rendering |
| jsdom | Simulated DOM for component tests |
| mongodb-memory-server | In-process MongoDB for service layer tests |
| Playwright | Browser-based end-to-end tests |
Vitest and Playwright are complementary — they do not overlap. Vitest covers everything up to the browser; Playwright covers everything that requires one.
Vitest was chosen over Jest because it shares the Vite/ESM ecosystem already used by Next.js 16, requires no Babel transform, and supports the @/ path alias from jsconfig.json natively via its resolve.alias config.
Architecture context
Understanding the application's layered architecture is essential for deciding where a test belongs.
Browser / Next.js App Router (RSC + Client Components)
↓
Route Handlers ── src/app/**/route.js
(HTTP endpoints — PDF, RTF, Turnstile verification)
↓
Server Actions ── src/lib/mongoose/
(thin wrappers, cache invalidation)
↓
Service layer ── src/services/
(Mongoose queries, transactions)
↓
MongoDB (production: Atlas · tests: mongodb-memory-server)
Each layer has a corresponding test tier. Tests are not allowed to skip layers — a test for a server action mocks the service layer, not the database directly.
The five-tier model
Tests are organised into five tiers from least to most intrusive and expensive.
Tier 1 — Unit npm run test:unit
| Property | Value |
|---|---|
| Environment | Node.js |
| Mocking | None |
| External processes | None |
| Typical run time | < 2 s |
Pure functions only: Zod schemas, utility helpers, text/URL processors, OU lookups. If the function has no imports that call APIs, read files, or touch the DOM, it belongs here.
First test to write for any new pure function.
→ See Tier 1 — Unit Tests
Tier 2 — Server Actions & Route Handlers npm run test:actions
| Property | Value |
|---|---|
| Environment | Node.js |
| Mocking | @/services, next/cache, @/lib/cache/cacheInvalidate, getHostnameServer, external APIs |
| External processes | None |
| Typical run time | < 5 s |
This tier covers two kinds of server-side files:
Server actions (src/lib/mongoose/) are thin wrappers that resolve the org shortname, call a service function, conditionally invalidate the cache, and return the result. Tests here assert that wiring — not business logic.
Route handlers (src/app/**/route.js) are Next.js API endpoints that orchestrate HTTP-level logic: URL validation, Turnstile token verification, calling HTML generators, launching headless browsers (PDF), converting HTML (RTF), and returning the correct response shape and headers. Tests here mock all external dependencies and assert each response path.
Canonical mock setup (server action):
// @vitest-environment node
import { vi, describe, it, expect, beforeEach } from "vitest";
vi.mock("@/services");
vi.mock("@/lib/cache/cacheInvalidate");
vi.mock("@/lib/url/getHostnameServer", () => ({ default: vi.fn(() => "test-org") }));
vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }));
What to assert (server action):
- The correct service function is called with the correct arguments.
cacheInvalidateis called on success and not called on error.- The
{ isError, data, message }response shape is preserved.
What to assert (route handler):
- Each response status code (200, 400, 403, 404, 500, 503) is reachable.
- Correct
Content-TypeandContent-Dispositionheaders on success. - Turnstile verification page is served when no token is present.
- Browser/external clients are closed in
finallyblocks even on error.
→ See Tier 2 — Server Action Tests
Tier 3 — Services npm run test:services
| Property | Value |
|---|---|
| Environment | Node.js |
| Mocking | None for DB layer |
| External processes | mongodb-memory-server (started per suite) |
| Typical run time | 10 – 30 s |
Service functions in src/services/ are tested with real Mongoose queries against an in-memory MongoDB instance. No DB calls are mocked — the goal is to verify that queries, projections, and error handling are correct against a real schema.
Canonical setup:
// @vitest-environment node
import { startDb, stopDb, clearDb } from "./testSetup";
beforeAll(startDb);
afterAll(stopDb);
beforeEach(clearDb);
testSetup.js (at src/services/testSetup.js) manages the full MongoDB lifecycle. The DB binary is only started when test:services runs — it is never started for Tier 1 or Tier 2.
What to assert:
- The
{ isError, data, message }response shape on every path. - Not-found returns
isError: truewith a meaningful message. - Transactions roll back correctly on error.
Tier 4 — Components npm run test:components
| Property | Value |
|---|---|
| Environment | jsdom |
| Mocking | @/lib/mongoose, next-intl, occasionally next/navigation |
| External processes | None |
| Typical run time | 15 – 60 s |
React hooks, form components, and UI components are rendered into a simulated DOM. MUI v7 renders correctly in jsdom. The focus is on conditional rendering, user interactions, and the contract between UI and server actions.
Canonical mock setup:
import { vi, describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
vi.mock("@/lib/mongoose");
vi.mock("next-intl", () => ({
useTranslations: () => (key) => key,
}));
Mocking @/lib/i18n:
Components that use usePathname or useRouter from @/lib/i18n (next-intl's navigation wrappers) must mock that module explicitly. The mock must include supportedLocales only if the test also imports from @/constants indirectly (since @/lib/i18n/routing.js reads it at module load time).
vi.mock("@/lib/i18n", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: vi.fn(() => "/e/1"),
}));
// If @/constants is also mocked, include supportedLocales:
vi.mock("@/constants", () => ({
supportedLocales: ["en"],
// ...other exports your component needs
}));
What to assert for forms:
- Required fields are rendered.
- Submitting an invalid form does not call the server action.
- Submitting a valid form calls the correct server action with the correct payload.
What to assert for hooks:
- State transitions (loading → success / loading → error).
- Correct server action is called with the correct arguments.
What to assert for navigation-aware components:
- Components that build URLs or navigate using
usePathnamemust be tested with at least two pathname values (e.g./eand/) to verify that both the/e/[id]and/[id]routing contexts produce the correct href orrouter.pushtarget.
What to skip:
- Purely presentational components with no branching logic.
- Snapshot tests (brittle, low signal).
What to test even for "simple" display components:
- Components that consume context (e.g.
useOrganization,useRopa) — render them with a real mock context to catch field renames. A component that destructures{ partners }from context will crash at runtime if the field is renamed and no test exercises that path. These tests are cheap to write and catch a real class of regression.
Tier 5 — E2E npm run test:e2e
| Property | Value |
|---|---|
| Environment | Real Chromium browser |
| Mocking | None |
| External processes | Chromium + Next.js dev server |
| Typical run time | 2 – 10 min |
Playwright drives a real browser against the running Next.js dev server. No mocking of any kind. The dev server is started automatically by playwright.config.js.
Planned test flows:
| Flow | File |
|---|---|
| Create organisation | e2e/organization.spec.js |
| Add OU + activity | e2e/ou-activity.spec.js |
| ROPA report generation / PDF download | e2e/ropa.spec.js |
| Language switch (next-intl) | e2e/i18n.spec.js |
Authentication has not been developed yet. e2e/auth.spec.js will be added once the auth flow is implemented.
Only write E2E tests for flows where a regression would be directly visible to users and catastrophic to the business. A misaligned button is not E2E-worthy.
The HTML ([locale]/[id]/html/route.js), PDF ([locale]/[id]/pdf/route.js), and RTF ([locale]/[id]/rtf/route.js) route handlers are fully covered at Tier 2 — Turnstile verification, error paths, and response headers are all unit-tested. An E2E test for the full download flow (real browser → real Chromium render → real PDF bytes) is still valuable but not required before shipping.
The GForms GDPR clause generator (src/lib/gforms/, src/app/api/gforms/) is covered at Tiers 1, 2, and 4. No E2E test is planned because the critical paths (auth resolution, clause generation, API contract) are all exercised at lower tiers at low cost.
→ See GForms Add-on Tests
Decision tree: which tier?
Is the function pure (no API calls, no DB, no DOM)?
└─ Yes → Tier 1
Does it live in src/lib/mongoose/ (server action)?
└─ Yes → Tier 2
Does it live in src/app/**/route.js (route handler)?
└─ Yes → Tier 2 (route handler variant)
Does it live in src/services/ (DB query)?
└─ Yes → Tier 3
Is it a React hook, form, or UI component?
└─ Yes → Tier 4
Is it a critical user-facing flow that needs a real browser?
└─ Yes → Tier 5
File naming and location
Vitest (Tiers 1–4)
Test files are co-located with source files:
src/lib/utils/isValueEmpty.js
src/lib/utils/isValueEmpty.test.js
src/lib/mongoose/activityAdd.js
src/lib/mongoose/activityAdd.test.js
src/app/[locale]/[id]/html/route.js
src/app/[locale]/[id]/html/route.test.js
src/app/[locale]/[id]/pdf/route.js
src/app/[locale]/[id]/pdf/route.test.js
src/services/activityService.js
src/services/activityService.test.js
src/lib/forms/ouForm/ouForm.jsx
src/lib/forms/ouForm/ouForm.test.jsx
Playwright (Tier 5)
E2E tests live in a top-level e2e/ directory:
e2e/
organization.spec.js
ou-activity.spec.js
ropa.spec.js
i18n.spec.js
Response shape convention
All service functions and server actions return a consistent shape:
// Success
{ isError: false, data: <result>, message: "..." }
// Failure
{ isError: true, data: null, message: "Human-readable error" }
Every Tier 2 and Tier 3 test must assert this shape explicitly — both the success and the error path.
Configuration files
| File | Purpose |
|---|---|
vitest.config.mjs | Vitest runner config — jsdom default, @/ alias, setupFiles |
vitest.setup.js | Imports @testing-library/jest-dom matchers |
src/services/testSetup.js | startDb / stopDb / clearDb for Tier 3 |
playwright.config.js | Playwright runner — chromium, e2e/ dir, auto dev server |
eslint.config.mjs | Test file overrides for vi, describe, expect globals |
Command reference
| Command | Tier | Starts |
|---|---|---|
npm test | All (watch mode) | Nothing extra |
npm run test:unit | 1 — pure functions | Nothing |
npm run test:actions | 2 — server actions | Nothing |
npm run test:services | 3 — service layer | MongoDB in-memory |
npm run test:components | 4 — hooks / forms / UI | jsdom |
npm run test:run | 1–4 (CI, one-shot) | MongoDB in-memory |
npm run test:coverage | 1–4 + coverage report | MongoDB in-memory |
npm run test:e2e | 5 — full browser | Chromium + Next.js dev server |
npm run test:e2e:ui | 5 — interactive | Chromium + Next.js dev server |
Run test:run (Tiers 1–4) on every push. Run test:e2e on pull requests targeting main or on a nightly schedule. E2E tests are too slow for every push.