Skip to main content

Writing e2e tests (Playwright)

Audience: developers & AI agents writing or changing the Playwright suite · Scope: how every spec is named, structured, and documented so the suite stays navigable and trustworthy at scale · Last reviewed: 2026-07-13

Part of the documentation standards; builds on writing.md. This is the normative rule set; the Testing hub is the how-to (local stack, accounts, the support API, the catalog).

TL;DR — Every feature ships with e2e coverage. A spec drives the real UI action a user takes and asserts in two places — the persisted state (read back via the test-support API) and the observable effect (a captured Pipedrive payload, a recorded sync row, the nudge due-set, the page state). Beyond what it tests, a spec must be legible: a <Actor> > <Feature> describe title, behaviour sentences for tests, a file header, and per-test Setup · Action · Checks JSDoc that says what data, how it was provisioned, driven how, checking what — mirrored one-row-per-test in the catalog.

Living standard — amend it when our practice changes.


1. What a spec must prove — the two-place rule

The behaviour standard (also in the Testing hub and the feature standard) is non-negotiable:

  • Drive the real action a user takes — a click, a form submit, an API call — in the right project (agent or anon, see §2). Don't shortcut the UI when the UI is what's under test.
  • Assert in two places: the persisted state (via the support APIsupport.latestQuote, stageSyncRows, collectNudgeDueRow, …) and the observable effect (the captured payload via capturesForSerial, a recorded stage-sync row, the nudge due-set, the rendered page). One without the other misses half the behaviour.
  • Seed through the app, not SQL (the application-factory pattern — e.g. seedDecorativeQuote), so pricing, BOM, and branch→org are correct by construction. Clean up what you create (afterEach / a finally).
  • Keep external writes off by default. The everyday suite runs in capture mode — Pipedrive/Sheets writes are recorded, never sent, and the always-on org-6034 guard blocks strays. Any real-destination check is opt-in (E2E_LIVE_SMOKE=1 / PIPEDRIVE_SEND=1) and confined to DEV org 6034.
  • Reuse the support API for state and fixtures; don't hand-roll DB/CLI glue.

2. Where a spec lives — grouping by actor

specs/
agent/ logged-in specs — driven as the automation agent (the `agent` project)
anon/ not-logged-in specs — public flows, e.g. the Quote Follow-Up landing page (the `anon` project)

The folder is the Playwright project (playwright.config.ts): agent replays the saved agent session, anon replays cookie consent only. Put a spec where its actor belongs.

One feature per file (create-and-rename-quote.spec.ts, quote-follow-up-auth.spec.ts). Split a file once it covers two distinct features.

3. Naming — Actor > Feature > behaviour

Wrap every file's tests in a describe titled <Actor> > <Feature> and title each test as a plain behaviour sentence (present tense, no IDs, no "should"):

test.describe('Agent > Quote maker', () => {
test('renames a quote and the captured deal payload reflects the new name', async ({ page }) => { /* … */ });
});
  • Actor is the project: Agent or Anon. Feature is the capability under test (Quote maker, Quote Follow-Up, Nudge cadence, Stage-sync, Deal updates, Link API, Session).
  • Reports then read Agent > Quote maker > renames a quote …, and greps are trivial: rg "Agent > Quote maker".
  • A single-test file still uses the describe — the title carries the Actor and Feature.

4. Documenting a spec — Setup · Action · Checks

A reader who "just wants to know what a test does" needs three concrete things; intent-only prose hides all three. So both the per-test JSDoc and the catalog row describe every test the same way — as a given / when / then:

PartAnswersBe concrete about
Setup (given)what data the test runs on, and how it got therethe mechanism (§4.1) + the entity — which quote/stage/line item.
Action (when)the one concrete thing the test doesdriven how — "click Create then fill the quote-name row", "mint a token and open /quote-follow-up/<serial>", "call support.processSerial".
Checks (then)what it assertsthe real assertions — selectors, support-API readbacks, captured-payload fields — not a paraphrase.

The rule of thumb: never write "seeds a quote" or "the configured product" and stop. Say which quote and how it was provisioned. If a reader can't tell UI-driven from support-API-seeded from your text, it's not done.

4.1 The provisioning vocabulary — name the mechanism

Every "Setup" states how the data got there, using one of these:

  • shared seeded quote — the realistic Decorative quote seeded once by the setup-seed project; read with readSeededQuote(). Use when a fresh one isn't needed; leave it as you found it.
  • a fresh quote driven through the UI — created in-test by driving My Quotes / the configurator (the application-factory). Name it and delete it in teardown.
  • a support-API state write — server-side state set via support.* with no browser (e.g. support.setStageCadence, support.seedStages, support.mintFollowupToken). Say which.
  • a minted grant token — the anon Quote Follow-Up authorisation (support.mintFollowupToken(serial)); it is not a login. Pass { expired: true } for expired-token cases.
  • a fresh anonymous session — nothing seeded; the anon project with consent only.

4.2 File header

Open every spec with a header comment carrying these tags:

/**
* @area Agent | Anon (the project/actor)
* @feature <the feature under test>
* @summary <what this file covers and why it matters — INCLUDING how its data is set up
* (shared seeded quote / UI-driven / support-API / minted token / fresh session)>
* @preconditions <what must be true to run: the agent branch→org-6034 mapping, a seeded quote,
* a setting, capture mode …>
* @covers <the specific behaviours/features exercised; link the feature doc>
*/

4.3 Per-test JSDoc

Immediately above each test(...), the same three parts:

/**
* Setup: <what data + how it's provisioned — see §4.1>.
* Action: <the one concrete thing the test does, driven how>.
* Checks: <the concrete assertions — support-API readbacks, captured fields, selectors>.
*/
test('renames a quote and the captured deal payload reflects the new name', async ({ page }) => { /* … */ });

5. The catalog — one row per test

docs/testing/catalog.md is the human index: as the suite grows, anyone can see what is covered, where a behaviour is tested, and where a new spec belongs without reading every file. Each spec gets a section with a one-line Setup: note (the file's default provisioning) and a four-column table — one row per test:

### `create-and-rename-quote.spec.ts` (agent) — Quote maker
**Setup:** a fresh quote driven through My Quotes; deleted in `afterEach`.

| Test | Setup (given) | Action (when) | Checks (then) |
|------|---------------|---------------|---------------|
| renames a quote and the captured deal payload reflects the new name | — (file default) | create a quote, open it, edit the project name | DB: name updated + `pipedrive_update_scheduled`; capture: `addDeal` title = new name (or `updateDeal` omits title); org set |

Fill Setup per row only when it differs from the file note (else ). Cells stay short but concrete — the three columns must answer what data, driven how, checking what without opening the spec. Mark opt-in live smokes with .

6. Keep site-specifics out of specs

Read the base URL, accounts, and toggles from the environment (support/env.ts, tests/.env); read runtime paths from the server via support.pageUrls() (My Quotes / login) and fixture ids from the seeded quote (readSeededQuote()). Never hard-code a serial, SEF URL, org id, or credential in a spec, and never assert against a value the test itself set. Drive UI through the shared helpers in support/*.ts (seedQuote, followupForm, nav) rather than re-implementing race-prone flows.

7. Skeleton — copy this

import { test, expect } from '@playwright/test';
import { support } from '../../support/cli';

/**
* @area Agent
* @feature Quote maker
* @summary Drives My Quotes as the automation agent: creates a fresh quote through the UI and
* asserts the persisted state plus the captured Pipedrive write. Capture mode — nothing sent.
* @preconditions agent session (setup-agent); agent branch mapped to DEV org 6034; PIPEDRIVE_TEST=capture.
* @covers quote create/rename → scheduled deal update (docs/pipedrive/deal-updates.md).
*/
test.describe('Agent > Quote maker', () => {
let createdSerial: string | null = null;
test.afterEach(() => { if (createdSerial) { support.deleteQuote(createdSerial); createdSerial = null; } });

/**
* Setup: a fresh quote created in-test by driving the My Quotes "Create" flow.
* Action: rename its project name on the single-quote view.
* Checks: DB — name updated + `pipedrive_update_scheduled`; capture — `addDeal`/`updateDeal` for the serial.
*/
test('renames a quote and the captured deal payload reflects the new name', async ({ page }) => {
// Arrange → Act → Assert …
});
});

8. Checklist — an e2e spec is done when…

  • It drives the real action and asserts two places (persisted state and observable effect).
  • It's under specs/agent/ or specs/anon/; one feature per file.
  • describe is Actor > Feature; tests are behaviour sentences (present tense, no IDs, no "should").
  • File header (@area/@feature/@summary/@preconditions/@covers, @summary states the provisioning) and per-test Setup / Action / Checks JSDoc.
  • Mechanics are explicit — a reader can tell what data, how provisioned (§4.1), and the concrete action.
  • Site-specifics out of the spec (env/config + support.*); cleans up what it created.
  • External writes stay capture-only; any real-destination check is opt-in and org-6034-confined.
  • Catalog updated in the same change — one row per test; every test(...) title has exactly one row.
  • It parsesnpx playwright test --list shows the test under its Actor > Feature describe (the suite transpiles via Playwright/esbuild; there's no separate tsc step) — and the spec passes.