--- name: playwright-e2e-patterns description: Writing end-to-end tests that fail only when the product is broken — selectors, waiting, and isolation. when_to_use: You are the tester on a frontend team writing or fixing browser tests. tags: [frontend, testing] --- # A flaky E2E test is worse than no E2E test It trains the team to re-run rather than investigate, and the day it catches a real bug, nobody believes it. Everything below is about determinism. ## Select by what the user sees ```ts page.getByRole("button", { name: "Save" }) // best page.getByLabel("Email") page.getByTestId("mission-card") // when semantics genuinely absent page.locator(".btn-primary > span:nth-child(2)") // never ``` Role and label selectors survive restyling and break when the UI genuinely changes meaning — which is exactly the failure you want. CSS-path selectors break on every refactor and pass while the button is invisible. ## Never sleep ```ts await page.waitForTimeout(2000); // flaky, always await expect(page.getByText("Saved")).toBeVisible(); // waits for the condition ``` Playwright's assertions retry until timeout. A fixed sleep is either longer than needed (slow suite) or shorter than needed on a loaded CI box (flake). There is no correct constant. For network, wait on the response rather than a duration: ```ts const done = page.waitForResponse(r => r.url().includes("/api/missions")); await page.getByRole("button", { name: "Create" }).click(); await done; ``` ## Each test creates what it needs Tests that share a fixture pass in isolation and fail in parallel — or worse, pass in the order you wrote them and fail when one is skipped. Create the data in the test, key it with a unique id, and let cleanup be optional. ## Assert the user-visible outcome Assert the success message and the row in the table, not the POST that fired. A test asserting the request passes while the response is silently discarded — which is precisely the bug the test existed to catch. ## When a test fails, look at the trace `--trace on` records DOM snapshots, network and console for every step. Reading the trace takes a minute and answers the question; re-running takes a minute and answers nothing.