Testing & Quality
Testing & Quality

Playwright end-to-end tests that don't flake

Lesson · ~40 min
After this unit you can
  • Write locators in Playwright's recommended order — getByRole with an accessible name first, getByTestId as the fallback — and fix a strict-mode violation
  • Replace waitForTimeout and expect(await locator.isVisible()) with auto-waiting actions and web-first assertions
  • Configure webServer, and explain how reuseExistingServer can 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

Simple
1 / 2

Find elements the way a user does

Flag

A 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":

  1. getByRole("button", { name: "Add" }) — the element's role plus its accessible name
  2. getByText("Buy milk") — visible text
  3. getByLabel("New todo") — form fields by their label
  4. getByPlaceholder, getByAltText, getByTitle
  5. getByTestId("todo-input") — matches data-testid by 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.

Medium
1 / 2

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 :3002 because 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 with Process from config.webServer was not able to start (checked).
  • Never reuse in CI, which !process.env.CI already 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.

Complex

Clicking before hydration, and other flake

Flag

The 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, then await expect(page.locator("body")).toHaveAttribute("data-hydrated", "true") after goto — 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 start as the webServer command): 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-first toHaveText keeps checking.
  • Fixed sleeps. waitForTimeout is 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?

Flag

page.getByRole("button", { name: "Save" }).click() throws strict mode violation … resolved to 2 elements. What's the best fix?

Flag

"Saved" appears 800 ms after clicking Save. expect(await page.getByText("Saved").isVisible()).toBe(true) fails immediately. Why?

Flag

A suite uses await page.waitForTimeout(1000) before its assertions and currently passes. What's wrong with it?

Flag

Locally, reuseExistingServer: true and url: "http://localhost:3000". A trace shows a page from a different project. What happened?

Flag

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?

Flag

Why should playwright/.auth/user.json be git-ignored?

Flag

With retries: 2, a test fails, then passes on retry, and the report marks it flaky. What should you do?

Flag

Fill in the blank

Click the button by role and name, then assert with a web-first assertion that waits for the list.

Flag
await 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.

Flag
webServer: {
  command: "pnpm dev",
  url: "http://localhost:3002/api/health",
  reuseExistingServer: ,
},

Record a trace only when a failed test is retried.

Flag
use: { 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.

Flag
export 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>.
Flag
// /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());
Flag
// "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();
});
Flag

Further reading