Playwright end-to-end tests that don't flake
- Write locators in Playwright's recommended order —
getByRolewith an accessible name first,getByTestIdas the fallback — and fix a strict-mode violation - Replace
waitForTimeoutandexpect(await locator.isVisible())with auto-waiting actions and web-first assertions - Configure
webServer, and explain howreuseExistingServercan run the suite against a different app already on the port - Diagnose flake — clicking before hydration, shared state, non-retrying assertions — and debug it with retries, traces and
--repeat-each - Reuse a signed-in state with a setup project and
storageState, and share setup through fixtures
Read
Find elements the way a user does
FlagA Playwright test drives a real browser: go to a page, find things, act on them, check the result. Everything starts with a locator — a description of how to find an element. This is Playwright 1.64.
import { expect, test } from "@playwright/test";
test("adds a todo", async ({ page }) => {
await page.goto("/todos");
await page.getByLabel("New todo").fill("Buy milk");
await page.getByRole("button", { name: "Add" }).click();
await expect(page.getByRole("listitem")).toHaveText(["Buy milk"]);
});
Playwright's docs list the built-in locators in a recommended order, getByRole first, because
it "reflects how users and assistive technology perceive the page":
getByRole("button", { name: "Add" })— the element's role plus its accessible namegetByText("Buy milk")— visible textgetByLabel("New todo")— form fields by their labelgetByPlaceholder,getByAltText,getByTitlegetByTestId("todo-input")— matchesdata-testidby default; for when nothing a user can see identifies the element
CSS and XPath selectors work, but the docs advise against them: "the DOM can often change leading to non resilient tests". A class name is styling; a role and a label are the contract with the user. And a test that can't find "the Add button" by role has found an accessibility bug too.
Locators are strict, and lazy
A locator isn't an element — it's re-resolved every time you use it, so it always sees the current page. And it must match exactly one element when you act on it:
Error: locator.click: Error: strict mode violation: getByRole('button', { name: 'Save' })
resolved to 2 elements
That error is Playwright refusing to guess. Narrow the locator rather than reaching for .first():
scope it to a region (page.getByRole("dialog").getByRole("button", { name: "Save" })) or give the
buttons distinct names. Testing Library's query priority makes the same call — role first, test IDs
as the last resort — for the same reason.
The config — and the server you didn't mean to test
Flag// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
retries: process.env.CI ? 2 : 0, // retry on CI only
use: {
baseURL: "http://localhost:3002", // page.goto("/todos") is relative to this
trace: "on-first-retry", // record a trace when a failed test is retried
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "pnpm dev", // how to start the app
url: "http://localhost:3002", // ready when this answers 2xx, 3xx or 400–403
reuseExistingServer: !process.env.CI, // locally, use a server that's already running
},
});
webServer starts the app before the tests and stops it after. reuseExistingServer: true means
"re-use an existing server on the port or url when available" — so locally you can keep your
dev server running between test runs. The docs recommend !process.env.CI.
The trap: "available" means "something answered"
Playwright doesn't check which app is on that port — only that the URL answered with an accepted status. Run another project's dev server on the same port, and your suite runs against it. Checked with Playwright 1.64 and a different app already on the port:
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('heading', { level: 1 })
Expected: "My Todos"
Received: "Some Other Project"
Here the heading made it obvious. A suite that only checks a status code or a "page loads" assertion
can pass against the wrong app. With reuseExistingServer: false, the same situation fails loudly
before any test runs: http://localhost:4610 is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in config.webServer.
Defences, cheapest first:
- A port per project. This app runs on
:3002because another app owns:3000. - A readiness URL only your app serves, like
/api/health. Another app answers it with a 404, so Playwright won't reuse it; it starts your command instead, which can't bind the busy port, and the run stops withProcess from config.webServer was not able to start(checked). - Never reuse in CI, which
!process.env.CIalready gives you.
Traces instead of guesses
A trace records each action with DOM snapshots before and after, the console, and the network.
trace: "on-first-retry" records one only when a failed test is retried, so passing runs stay
cheap. Open it from the HTML report (npx playwright show-report) or with
npx playwright show-trace trace.zip, and step through what the browser actually saw. Playwright's
best-practice guide recommends traces over videos and screenshots for CI failures.
Clicking before hydration, and other flake
FlagThe click that became a page load
A server-rendered React page arrives as HTML first. The form is real HTML, so the browser can submit
it on its own; the onSubmit handler that calls preventDefault() only exists once React has
hydrated — downloaded the JavaScript and attached its handlers. Until then, a click on "Add"
does what a plain HTML form does: a GET to the form's URL with the fields in the query string.
Playwright's actionability checks don't know about hydration. The button is visible, stable,
enabled and receives events from the moment the HTML renders, so click() goes ahead immediately.
Checked with Playwright 1.64 against a page whose handlers attach 1.5 s after load (standing in for slow hydration):
await page.goto("/todos");
await page.getByLabel("New todo").fill("Buy milk");
await page.getByRole("button", { name: "Add" }).click();
page.url(); // "http://localhost:4610/todos?title=Buy+milk" — a full page load, not a new todo
The item never appears, and the test fails waiting for it. With no named fields the URL ends in a
bare ? (checked: /todos?). It's worst under a dev server — Next.js's docs: next dev
"compiles routes in your application as you open or navigate to them", and nothing is minified, so
hydration lags — and on a loaded CI machine: the classic "fails on CI, passes when I retry".
Fixes:
- Wait for a signal that only exists after hydration. For example, set
document.body.dataset.hydrated = "true"in an effect in the root layout, thenawait expect(page.locator("body")).toHaveAttribute("data-hydrated", "true")aftergoto— checked: the same test then adds the todo and the URL stays/todos. Put it in a fixture so every test gets it. - Assert on something the click must change, so a premature click fails loudly rather than silently reloading.
- Run the suite against a production build (
next build && next startas thewebServercommand): hydration is faster, and it's the build you ship.
Other common sources of flake
- Shared server state. Two tests editing the same record in parallel, or one test's leftovers breaking another. Create the data each test needs, with unique names.
- Assertions that don't retry.
expect(await locator.textContent()).toBe(…)reads once; the web-firsttoHaveTextkeeps checking. - Fixed sleeps.
waitForTimeoutis a race against the machine's speed. - Things you don't control. A third-party widget or payment page. Playwright's guide: "Only test
what you control" — route those requests to a stub with
page.route.
Retries reveal flake; they don't fix it
With retries set, Playwright reports a test that failed and then passed as flaky — a separate
category from passed and failed. Treat that label as a bug report, not as green: the retry's trace
(trace: "on-first-retry") shows what differed. To reproduce locally, run the one test many times:
npx playwright test todos.spec.ts --repeat-each=20.
Quiz
Which locator does Playwright recommend for a row's "Delete" button?
Flagpage.getByRole("button", { name: "Save" }).click() throws strict mode violation … resolved to 2 elements. What's the best fix?
"Saved" appears 800 ms after clicking Save. expect(await page.getByText("Saved").isVisible()).toBe(true) fails immediately. Why?
A suite uses await page.waitForTimeout(1000) before its assertions and currently passes. What's wrong with it?
Locally, reuseExistingServer: true and url: "http://localhost:3000". A trace shows a page from a different project. What happened?
Under next dev, a test fills the "New todo" field and clicks Add right after goto. The URL becomes /todos?title=Buy+milk and no todo is added. Why?
Why should playwright/.auth/user.json be git-ignored?
With retries: 2, a test fails, then passes on retry, and the report marks it flaky. What should you do?
Fill in the blank
Click the button by role and name, then assert with a web-first assertion that waits for the list.
Flagawait page.("button", { name: "Add" }).click(); expect(page.getByRole("listitem")).toHaveText(["Buy milk"]);
Reuse an already-running dev server locally, but always start a fresh one in CI.
FlagwebServer: { command: "pnpm dev", url: "http://localhost:3002/api/health", reuseExistingServer: , },
Record a trace only when a failed test is retried.
Flaguse: { trace: "" },
Save the signed-in state in the setup test, and make the browser project wait for setup.
Flag// auth.setup.ts await page.context().({ path: "playwright/.auth/user.json" }); // playwright.config.ts { name: "chromium", use: { storageState: "playwright/.auth/user.json" }, : ["setup"] }
Define a fixture that opens the todo page; the test runs where the fixture hands control over.
Flagexport const test = base.<{ todoPage: TodoPage }>({ todoPage: async ({ page }, use) => { await page.goto("/todos"); await (new TodoPage(page)); }, });
Predict the output
// playwright.config.ts
webServer: { command: "PORT=4610 node my-app.mjs", url: "http://localhost:4610", reuseExistingServer: true }
// heading.spec.ts — my-app's <h1> is "My Todos"
await page.goto("/");
await expect(page.getByRole("heading", { level: 1 })).toHaveText("My Todos");
// Before the run, another project's server is already listening on :4610,
// serving <h1>Some Other Project</h1>.// /todos is server-rendered:
// <form><label>New todo <input name="title"></label><button>Add</button></form>
// The onSubmit handler (preventDefault + add item) attaches only after hydration,
// which takes ~1.5 s here.
await page.goto("/todos");
await page.getByLabel("New todo").fill("Buy milk");
await page.getByRole("button", { name: "Add" }).click();
console.log(page.url());// "Saved" appears 800 ms after page load.
test("C", async ({ page }) => {
await page.goto("/");
expect(await page.getByText("Saved").isVisible()).toBe(true);
});
test("D", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("Saved")).toBeVisible();
});Further reading
- Playwright docs: Locators
- Playwright docs: Auto-waiting (actionability)
- Playwright docs: Assertions
- Playwright docs: Page API (waitForTimeout)
- Playwright docs: Web server
- Playwright docs: Authentication
- Playwright docs: Fixtures
- Playwright docs: Retries
- Playwright docs: Trace viewer
- Playwright docs: Best Practices
- Next.js docs: Local development (next dev compiles on demand)