End to end

Use a real browser for the few journeys that nothing else can prove, and keep them maintainable.

11of 16
≈ 12Min read
5Parts

What only a browser can tell you

That list is worth a handful of tests, not a suite of two hundred. Each one is slow, each one can fail for a reason unrelated to your change, and each one costs maintenance every time the interface moves.

  • The pages are actually wired together — the link goes where the route is.
  • The build output works, not the source: the bundle, the CSS, the assets.
  • The thing is visible, clickable and not covered by a cookie banner.
  • The session survives a real redirect, a real cookie, a real refresh.
  • The whole stack is up: the server, the database, the migrations, the environment.

How many, and which

Pick the journeys a business would notice within an hour: sign in, the main thing the product does, and paying for it. Three to ten, in most products. Everything else — validation messages, empty states, error handling — is cheaper and more reliable one layer down.

A useful test is the one that would have caught the last outage. A useless one is the fifth variation of the same form submission.

Selectors that survive a redesign

the same order as the DOM lesson
await page.getByRole("button", { name: "Add to the quote" }).click();
await page.getByLabel("Quantity").fill("3");
await page.getByText("3 doors in the quote").waitFor();

// the escape hatch, when nothing user-visible identifies it
await page.getByTestId("quote-total").textContent();
Never a CSS selector generated by a tool

div > div:nth-child(3) > button.sc-hKgILt is what a recorder produces and what breaks on the next deploy. If a recorder is the only way to write the test, the interface has no stable identity — which is also an accessibility problem.

The page object

The test reads as the journey; the selectors live in one class. When the button is renamed, one line changes instead of thirty.

one place to change when the interface moves
class QuotePage {
  constructor(page) { this.page = page; }

  async open() { await this.page.goto("/quote"); }
  async setQuantity(n) { await this.page.getByLabel("Quantity").fill(String(n)); }
  async add() { await this.page.getByRole("button", { name: "Add to the quote" }).click(); }
  async status() { return this.page.getByRole("status").textContent(); }
}

test("a customer can build a quote", async ({ page }) => {
  const quote = new QuotePage(page);

  await quote.open();
  await quote.setQuantity(3);
  await quote.add();

  expect(await quote.status()).toContain("3 doors");
});

Keeping them stable

  • Never sleep. Playwright and Cypress wait for an element to be actionable automatically; a waitForTimeout is an admission that something else is being waited for.
  • Control the data. Each test creates what it needs through an API call or a seeded fixture, and does not depend on what another test left behind.
  • Sign in through the API, once, and reuse the storage state — not through the login form in every test.
  • Run against a build, on the same machine, with a fixed clock and a fixed locale.
  • Record a trace on failure. A video and a DOM snapshot turn "it failed in CI" into a five-minute fix.

Practice