Skip to main content

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.

playwright.config.js (summary)
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:

SettingValueReason
testDir./playwright-testsAll spec files live here
fullyParalleltrueTests within a file may run in parallel
retries2 in CI, 0 locallyAbsorbs transient timing failures in CI
navigationTimeout20 000 msNext.js dev server cold starts are slow
traceon-first-retryTrace captured only when a test is retried
BrowserChromium (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.

Planned spec files

The following spec files are planned but not yet written — they will be added as the corresponding features are built:

FileFlow
organization.spec.jsCreate / edit / delete an organisation
ou-activity.spec.jsAdd an OU, add an activity, fill in attributes
ropa.spec.jsROPA report generation and PDF download
i18n.spec.jsLanguage switch via next-intl
auth.spec.jsSign-in / sign-out, protected-route redirect — see clerk-playwright.md

Test files

playwright-tests/navigation.spec.js

App loads — 2 tests

TestKey assertion
has correct page titleexpect(page).toHaveTitle("GDPR ROPA")
renders either the app shell or the DB error cardappShell.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.

StepImplementation detail
Skip when DB unavailabledbError.isVisible()test.skip()
Skip when no activities existactivityCards.count() === 0test.skip()
Locate activity cardspage.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 navigationPromise.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 openedpage.getByRole("dialog") is visible
Close dialogpage.getByRole("button", { name: "Close" })ButtonCancel with cancelText="close" resolves to t("close") = "Close" (EN)
Assert back on homeDialog 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

playwright-tests/navigation.spec.js
// @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-check at 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")baseURL from config is prepended automatically.
  • Prefer semantic locators: getByRole, getByLabel, getByText, getByTestId. Avoid CSS selectors and XPath.
  • Set explicit timeout on assertions that wait for slow operations (server-side rendering, PDF generation).

What to assert

App shell / navigation

ConcernAssertion
Page titleawait expect(page).toHaveTitle("...")
Visible landmarkawait expect(page.getByRole("banner")).toBeVisible()
URL after actionawait expect(page).toHaveURL(/\/expected-path/)
Heading on a pageawait expect(page.getByRole("heading", { name: /text/i })).toBeVisible()

Forms and mutations

ConcernAssertion
Form submits successfullyawait expect(page.getByText("success-message")).toBeVisible()
Validation error shownawait expect(page.getByText("error-key")).toBeVisible()
Record appears in list after createawait expect(page.getByText("new-item-name")).toBeVisible()
Record absent after deleteawait expect(page.getByText("deleted-item")).not.toBeVisible()

File downloads

PDF download assertion
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.

No automatic DB reset

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:

  1. The flow is critical — a regression would be directly visible to users (not just an internal API shape change).
  2. The flow crosses the full stack — it cannot be decomposed into a component test (Tier 4) that mocks the server action.
  3. 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

  1. Create playwright-tests/<flow-name>.spec.js.
  2. Add // @ts-check and import from "@playwright/test" only.
  3. Use page.goto("/relative/path") — never hard-code http://localhost:3000.
  4. Prefer semantic locators over CSS selectors.
  5. If the test creates data, clean it up in an afterEach or afterAll hook, or target a dedicated test organisation.
  6. Run npm run test:playwright to confirm. Use npm run test:playwright:ui to 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.

GitHub Actions example
- 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.