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
(
agentoranon, 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 API —
support.latestQuote,stageSyncRows,collectNudgeDueRow, …) and the observable effect (the captured payload viacapturesForSerial, 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/ afinally). - 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:
AgentorAnon. 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:
| Part | Answers | Be concrete about |
|---|---|---|
| Setup (given) | what data the test runs on, and how it got there | the mechanism (§4.1) + the entity — which quote/stage/line item. |
| Action (when) | the one concrete thing the test does | driven 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 asserts | the 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-seedproject; read withreadSeededQuote(). 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
anonproject 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/orspecs/anon/; one feature per file. -
describeisActor > Feature; tests are behaviour sentences (present tense, no IDs, no "should"). - File header (
@area/@feature/@summary/@preconditions/@covers,@summarystates 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 parses —
npx playwright test --listshows the test under itsActor > Featuredescribe (the suite transpiles via Playwright/esbuild; there's no separatetscstep) — and the spec passes.