Tier 5 — E2E Tests (Playwright)
Tier 5 tests verify complete user-facing flows in a real Chromium browser against a running Next.js dev server. Nothing is mocked — the browser, the server, and the database all run as they do in production. This is the most expensive tier and is reserved for flows where a regression would be directly visible to users and catastrophic to the business.
Running Tier 5 tests
# Headless run (CI / one-shot)
npm run test:playwright
# Interactive Playwright UI (local development)
npm run test:playwright:ui
The dev server (npm run dev) is started automatically by playwright.config.js if one is not already running on http://localhost:3000. In CI the server is always started fresh; locally an existing server is reused (reuseExistingServer: true).
Configuration
All Playwright settings live in playwright.config.js.
export default defineConfig({
testDir: "./playwright-tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
navigationTimeout: 20_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
Key points:
| Setting | Value | Reason |
|---|---|---|
testDir | ./playwright-tests | All spec files live here |
fullyParallel | true | Tests within a file may run in parallel |
retries | 2 in CI, 0 locally | Absorbs transient timing failures in CI |
navigationTimeout | 20 000 ms | Next.js dev server cold starts are slow |
trace | on-first-retry | Trace captured only when a test is retried |
| Browser | Chromium (Desktop Chrome) | Single browser; add firefox/webkit only if cross-browser bugs are a concern |
File location and naming
All Playwright spec files live in the top-level playwright-tests/ directory. Do not co-locate them with source files.
playwright-tests/
navigation.spec.js ← app load + basic navigation
example.spec.js ← scaffold example (can be deleted)
File naming convention: <flow-name>.spec.js.
The following spec files are planned but not yet written — they will be added as the corresponding features are built:
| File | Flow |
|---|---|
organization.spec.js | Create / edit / delete an organisation |
ou-activity.spec.js | Add an OU, add an activity, fill in attributes |
ropa.spec.js | ROPA report generation and PDF download |
i18n.spec.js | Language switch via next-intl |
auth.spec.js | Sign-in / sign-out, protected-route redirect — see clerk-playwright.md |
Test files
playwright-tests/navigation.spec.js
App loads — 2 tests
| Test | Key assertion |
|---|---|
| has correct page title | expect(page).toHaveTitle("GDPR ROPA") |
| renders either the app shell or the DB error card | appShell.or(dbError) is visible within 10 s |
appShell targets the MUI AppBar (role="banner"). dbError targets the "Database Connection Error" text rendered by the NoMongoDB component. Both are acceptable outcomes — the test is tolerant of environments without a database.
Activity card navigation — 1 test
Flow: home page → click activity card → activity dialog opens → click Close → back on home page.
| Step | Implementation detail |
|---|---|
| Skip when DB unavailable | dbError.isVisible() → test.skip() |
| Skip when no activities exist | activityCards.count() === 0 → test.skip() |
| Locate activity cards | page.locator('main a[href*="/e/"]') — ActivityCaption renders <CardActionArea component="a" href="/e/{id}"> inside <main>; scoping to main excludes MiniDrawer nav links (/e/org, /e/ou/new, …) that also match href*="/e/" |
| Wait for navigation | Promise.all([page.waitForURL(/\/e\/\d+/), card.click()]) — plain <a> causes a hard reload + locale redirect (/e/{id} → /[locale]/e/{id}); waiting for URL before asserting the dialog avoids a race |
| Assert dialog opened | page.getByRole("dialog") is visible |
| Close dialog | page.getByRole("button", { name: "Close" }) — ButtonCancel with cancelText="close" resolves to t("close") = "Close" (EN) |
| Assert back on home | Dialog not visible; URL equals the pre-click home URL |
Why a[href*="/e/"] and not a semantic locator?
ActivityCaption does not expose a dedicated aria-label or data-testid on the card link. Using href matching is the next most stable option — it is tied to the routing contract (/e/{activityId}) rather than to display text, which is locale-dependent.
Anatomy of a spec file
// @ts-check
import { test, expect } from "@playwright/test";
// Use relative paths — baseURL is set in playwright.config.js
const BASE = "/";
test.describe("App loads", () => {
test("has correct page title", async ({ page }) => {
await page.goto(BASE);
await expect(page).toHaveTitle("GDPR ROPA");
});
test("renders either the app shell or the DB error card", async ({ page }) => {
await page.goto(BASE);
const appShell = page.getByRole("banner"); // MUI AppBar → <header>
const dbError = page.getByText("Database Connection Error");
await expect(appShell.or(dbError)).toBeVisible({ timeout: 10_000 });
});
});
Conventions:
// @ts-checkat the top — enables VS Code type inference without a full TypeScript setup.- Import only from
"@playwright/test"— never mix Vitest imports into spec files. - Use
page.goto("/relative/path")—baseURLfrom config is prepended automatically. - Prefer semantic locators:
getByRole,getByLabel,getByText,getByTestId. Avoid CSS selectors and XPath. - Set explicit
timeouton assertions that wait for slow operations (server-side rendering, PDF generation).
What to assert
App shell / navigation
| Concern | Assertion |
|---|---|
| Page title | await expect(page).toHaveTitle("...") |
| Visible landmark | await expect(page.getByRole("banner")).toBeVisible() |
| URL after action | await expect(page).toHaveURL(/\/expected-path/) |
| Heading on a page | await expect(page.getByRole("heading", { name: /text/i })).toBeVisible() |
Forms and mutations
| Concern | Assertion |
|---|---|
| Form submits successfully | await expect(page.getByText("success-message")).toBeVisible() |
| Validation error shown | await expect(page.getByText("error-key")).toBeVisible() |
| Record appears in list after create | await expect(page.getByText("new-item-name")).toBeVisible() |
| Record absent after delete | await expect(page.getByText("deleted-item")).not.toBeVisible() |
File downloads
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: /download/i }).click(),
]);
expect(download.suggestedFilename()).toMatch(/\.pdf$/);
Handling the dev-server state
Because the dev server uses a real database, tests are not isolated by default. Each spec that creates data should clean it up, or use a dedicated test organisation that is reset before the suite runs.
There is currently no globalSetup hook that resets the database before a Playwright run. Until one is added, write tests that are tolerant of pre-existing data, or target a dedicated test organisation whose state is known.
Pitfalls
Pitfall: next-intl locale prefix in URLs
The app uses next-intl for i18n. Navigating to / redirects to the active locale prefix (e.g. /en/). Always use page.goto("/") and let the redirect happen rather than hard-coding /en/ in spec files — the default locale may change.
// ✅ locale-agnostic
await page.goto("/");
await expect(page).toHaveURL(/\/en\//); // assert the redirect result
// ❌ fragile — breaks if default locale changes
await page.goto("/en/dashboard");
Pitfall: navigationTimeout fires before dev server is ready
On a cold start the Next.js dev server can take 10–15 seconds before the first request is served. playwright.config.js sets navigationTimeout: 20_000. Do not lower this value for local development.
If a test consistently times out on first run but passes on retry, it is a cold-start issue — not a test bug.
Pitfall: getByRole("banner") matches multiple elements
MUI renders <AppBar> as a <header> element (ARIA role banner). If a page also contains a <header> inside a card or dialog, getByRole("banner") returns multiple matches and the assertion fails.
Fix: use page.locator("header").first() or a more specific locator such as page.getByTestId("app-bar") when the page has nested <header> elements.
Pitfall: example.spec.js hits playwright.dev externally
playwright-tests/example.spec.js is the scaffold file generated by npm init playwright. It navigates to https://playwright.dev/ — an external URL. This test will fail in air-gapped CI environments and is not part of the application test suite. Delete it or replace it with a real spec once the scaffold is no longer needed.
When to write a Tier 5 test
Write an E2E test only when all three of the following are true:
- The flow is critical — a regression would be directly visible to users (not just an internal API shape change).
- The flow crosses the full stack — it cannot be decomposed into a component test (Tier 4) that mocks the server action.
- The flow involves real browser behaviour that jsdom cannot simulate — file downloads, PDF rendering via Chromium, multi-step navigation, or clipboard access.
Examples that are E2E-worthy:
- PDF export of a ROPA report (requires real Chromium PDF rendering).
- Complete organisation-creation wizard (multi-step form, DB write, redirect).
- Language switch that persists across pages (cookie + locale prefix + rerender).
Examples that are NOT E2E-worthy:
- A button that is disabled until a form is valid → Tier 4.
- A server action that returns
{ isError: true }on bad input → Tier 2. - A utility that formats a date → Tier 1.
Adding a new Tier 5 test
- Create
playwright-tests/<flow-name>.spec.js. - Add
// @ts-checkand import from"@playwright/test"only. - Use
page.goto("/relative/path")— never hard-codehttp://localhost:3000. - Prefer semantic locators over CSS selectors.
- If the test creates data, clean it up in an
afterEachorafterAllhook, or target a dedicated test organisation. - Run
npm run test:playwrightto confirm. Usenpm run test:playwright:uito step through the trace interactively if the test fails.
CI recommendation
Run test:playwright on pull requests targeting main and on a nightly schedule. Do not run it on every push — the dev server startup and real browser execution make it too slow for a per-commit gate.
- name: Run E2E tests
run: npm run test:playwright
env:
CI: true
In CI, reuseExistingServer is false, so Playwright always starts a fresh dev server. Ensure the MONGODB_URI environment variable (or equivalent) is set in the CI environment so the server can connect to the database.