Skip to main content

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

ToolRole
VitestUnit, integration, and component test runner
Testing LibraryReact component and hook rendering
jsdomSimulated DOM for component tests
mongodb-memory-serverIn-process MongoDB for service layer tests
PlaywrightBrowser-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.

Why not Jest?

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

PropertyValue
EnvironmentNode.js
MockingNone
External processesNone
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

PropertyValue
EnvironmentNode.js
Mocking@/services, next/cache, @/lib/cache/cacheInvalidate, getHostnameServer, external APIs
External processesNone
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):

src/lib/mongoose/activityAdd.test.js
// @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.
  • cacheInvalidate is 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-Type and Content-Disposition headers on success.
  • Turnstile verification page is served when no token is present.
  • Browser/external clients are closed in finally blocks even on error.

→ See Tier 2 — Server Action Tests


Tier 3 — Services npm run test:services

PropertyValue
EnvironmentNode.js
MockingNone for DB layer
External processesmongodb-memory-server (started per suite)
Typical run time10 – 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:

src/services/activityService.test.js
// @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: true with a meaningful message.
  • Transactions roll back correctly on error.

Tier 4 — Components npm run test:components

PropertyValue
Environmentjsdom
Mocking@/lib/mongoose, next-intl, occasionally next/navigation
External processesNone
Typical run time15 – 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:

src/lib/forms/ouForm/ouForm.test.jsx
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 usePathname must be tested with at least two pathname values (e.g. /e and /) to verify that both the /e/[id] and /[id] routing contexts produce the correct href or router.push target.

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

PropertyValue
EnvironmentReal Chromium browser
MockingNone
External processesChromium + Next.js dev server
Typical run time2 – 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:

FlowFile
Create organisatione2e/organization.spec.js
Add OU + activitye2e/ou-activity.spec.js
ROPA report generation / PDF downloade2e/ropa.spec.js
Language switch (next-intl)e2e/i18n.spec.js
Auth not yet covered

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.

HTML, PDF and RTF route handlers

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.

GForms add-on

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

FilePurpose
vitest.config.mjsVitest runner config — jsdom default, @/ alias, setupFiles
vitest.setup.jsImports @testing-library/jest-dom matchers
src/services/testSetup.jsstartDb / stopDb / clearDb for Tier 3
playwright.config.jsPlaywright runner — chromium, e2e/ dir, auto dev server
eslint.config.mjsTest file overrides for vi, describe, expect globals

Command reference

CommandTierStarts
npm testAll (watch mode)Nothing extra
npm run test:unit1 — pure functionsNothing
npm run test:actions2 — server actionsNothing
npm run test:services3 — service layerMongoDB in-memory
npm run test:components4 — hooks / forms / UIjsdom
npm run test:run1–4 (CI, one-shot)MongoDB in-memory
npm run test:coverage1–4 + coverage reportMongoDB in-memory
npm run test:e2e5 — full browserChromium + Next.js dev server
npm run test:e2e:ui5 — interactiveChromium + Next.js dev server
CI recommendation

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.