End-to-end tests
Most things are not an end-to-end test here. Every component already renders in a real browser under the story tests, against a mock transport, with an accessibility pass. So the question “does this work when clicked” is answered before a Playwright spec exists, and a spec that answers it again is slower, flakier and no more informative.
An end-to-end test earns its place only when it exercises something no single layer owns.
Does this belong in an e2e test?
Section titled “Does this belong in an e2e test?”Work down. The first row that matches decides, and most requests stop before the last one.
| If the behaviour… | Then it is | Because |
|---|---|---|
| is a pure function — a total, a format, a predicate | a unit test | No browser adds anything. |
| is one component reacting to input or to a mocked response | a story test | Real browser, real events, no environment to break. |
| is the same component in another viewport, locale or state | a story test | A second story costs nothing; a second spec costs a run. |
| crosses two or more pages and carries state between them | an e2e test | Nothing else holds a session across a navigation. |
| depends on the real backend answering — permissions, pricing, availability, an order actually existing afterwards | an e2e test | The mock transport can only confirm what it was told to say. |
| leaves the app and comes back — a payment redirect, an email link, SSO | an e2e test | The return is a fresh page load; nothing below this layer models it. |
| is business rules that differ per market — address validation, tax, available methods | an e2e test, with a market dimension | Configuration only present in a deployed environment. |
Two consequences worth stating, because they get argued:
- A page rendering is not a journey. A “home page loads” spec asserts what the first step of the floor journey already asserts. Do not add one; delete one when found.
- Same feature, several surfaces is usually one spec and several stories. Write the second spec only where the behaviour differs per surface, not where the layout does.
Setup is API-first, and the UI is driven exactly once
Section titled “Setup is API-first, and the UI is driven exactly once”The feature under test is driven through the UI. Everything else is created over the API. A checkout spec that registers through the form is also a registration test, and fails for reasons that have nothing to do with checkout.
Setup happens through operations that know how to undo themselves:
const customer = await api.run(customers.create());await api.run(auth.signIn(customer));await api.run(cart.addSkus([sku]));api.run(op) performs the operation and registers its rollback for teardown — last in, first out,
best-effort, and a failing rollback never fails a test. Never call a rollback from a spec.
Where the UI itself persists something, enrol the cleanup before driving the UI, so teardown still fires when the flow fails halfway through.
Operations write their own documents. Do not reuse the storefront’s generated fragments: those
track what the UI needs, so a UI-only field rename breaks setup for no reason. Select id,
version, and whatever the spec asserts on.
Which combinations it runs in
Section titled “Which combinations it runs in”Four variables, and their product is unaffordable. Vary each one for the reason it exists.
| Variable | Vary it |
|---|---|
| Device | Only where the component tree actually differs — a menu that becomes a drawer, filters that become a sheet. Same tree reflowed by CSS is one device plus a story. |
| Market | Only in checkout and address specs. Never crossed with device or browser. |
| Language | Never on its own. It rides with the market that ships it. |
| Browser | One per spec. Coverage is a property of the suite, not of a spec. |
Browser assignment is spread, not multiplied. Give each spec a single engine and let the assignment across the suite cover all of them; the floor journey runs on all three as the safety net. Tripling every spec buys little, because engine differences are broad and shallow — they show up wherever you look, so looking in twenty places once each is as good as looking everywhere three times.
Tags are explicit. An untagged test runs nowhere — there is no implicit default, so a spec without tags is silently dead.
The shape of a spec
Section titled “The shape of a spec”test.describe("Quote request", { tag: ["@desktop", "@smoke"] }, () => { test("Should request a quote from a filled cart", async ({ page, api, t }) => { const customer = await api.run(customers.create()); await api.run(auth.signIn(customer)); await api.run(cart.addSkus(["ABC-123"]));
await test.step("Given the buyer is in checkout", async () => { await page.goto("/checkout"); });
await test.step("When they conclude with a quote request", async () => { await page.getByRole("radio", { name: t("Checkout.outcome.quote") }).check(); await waitForGraphQLOperation(page, "QuoteRequestCreate", () => page.getByRole("button", { name: t("Checkout.submit.quote") }).click(), ); });
await test.step("Then the request is confirmed", async () => { await expect(page.getByRole("heading", { level: 1 })).toHaveText( t("Quote.confirmation.title"), ); await expect(page).toBeAccessible(); }); });});Title as Should …. Steps in Given/When/Then, grouped — one step per phase, not one per command.
Files under tests/<feature>/, named for the page the spec drives rather than the data it seeds.
Hard rules
Section titled “Hard rules”Never waitForTimeout. Never waitForLoadState("networkidle"). Both turn a suite into a coin
flip under CI load. Where an action triggers a request, wait on that operation by name.
Never hard-code visible text. Every string comes from t(key). A spec with literals is a
second translation file that goes stale without failing.
Locators, in order: getByRole first; getByLabel for form fields; getByText only for
non-interactive elements. A package’s data-component is a region scope — use it to say
which part of the page a role sits in, never as a substitute for the role itself.
Narrow with the getBy* options, not with page.locator. getByRole("button", { name })
survives a markup change; a CSS selector reaching for the second child does not, and it is the
form flakiness takes when a component gains a wrapper.
Create what you mutate; read what you do not. A spec that depends on a price, a stock level or a product relation creates that product. A spec that only needs a product to exist uses the catalogue. An assertion on a specific price or name is a dependency even though it reads like a read.
Every spec is parallel-safe. Branch previews share one backend, so two runs of the same spec overlap routinely — that is the normal case, not an edge case.
Bad accessibility is reported, not worked around. A locator that falls back to a CSS selector because a control has no accessible name is documenting a defect instead of fixing it. Say so, and fix the component.
Before writing
Section titled “Before writing”-
Check the decision table above. If it is a story test, say so and write that instead — this is the most valuable thing this skill does.
-
Inventory first. Look for an existing op, locator or helper before adding one: specs in
tests/**, locators insrc/locators/, ops insrc/data/. -
Decide locator placement before typing, not while. Extract to
src/locators/when a second spec needs it or construction is non-trivial; a baregetByRolestays inline. Never park helpers at the top of a spec file. -
State the flow back and get an ack — the steps in order, the data the spec creates, the closing assertion, and the tags. Redirecting here is cheap; redirecting a half-written spec is not.
-
Prove it is stable, then prove it can fail. Two different checks, and a new spec needs both:
pnpm exec playwright test -g "Should <title>" --repeat-each=5 --reporter=lineFive green runs in isolation is the bar for landing, because a spec that passes once and fails one run in four is worse than no spec — it is the failure that teaches a team to ignore the suite. Then make each new assertion fail once on purpose: an assertion that has never failed is indistinguishable from one that cannot.
Before landing
Section titled “Before landing”- Checked against the scope table — this is not a story test wearing a spec’s clothes
- Title reads
Should …, inside a nameddescribe - Given/When/Then steps, grouped by phase rather than one per command
- All setup through
api.run(op); the UI drives the feature under test exactly once - Cleanup enrolled before any UI step that persists something
- Every visible string via
t(key)— no literals - Locators by role/label; no
page.locator, no CSS selectors - Requests waited on by operation name — no
waitForTimeout, nonetworkidle - Tagged; device declared from the component tree; exactly one browser
- Green five times in isolation, and each new assertion seen to fail once
- Parallel-safe — no state shared with another spec
The reasoning behind all of this — why the gate sits on promotion rather than on merge, why rollbacks are best-effort, how eventual consistency produces flakiness nobody predicts — is on End-to-end Testing.

