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
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();
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.
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
waitForTimeoutis 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.