TFS Digital
All insights

Playwright

Playwright E2E Without the Flakes: Patterns We Use on Every Project

Flaky end-to-end tests erode trust faster than having no tests at all. These Playwright patterns keep suites fast, stable and worth listening to.

Gurpreet Bhamra · QA & AI Solutions Lead

2 min read

A test suite is only useful if the team believes it. The moment developers start re-running failed pipelines "just in case", the suite has stopped protecting releases. Most flakiness comes from a handful of avoidable habits, and Playwright gives you the tools to design them out.

Locate elements the way users find them

Brittle CSS selectors are the most common source of breakage. Prefer, in order:

  1. Role-based locators such as getByRole("button", { name: "Send inquiry" }), which also double as a light accessibility check.
  2. Labels and text for form fields and content.
  3. Test IDs (getByTestId) for elements with no stable accessible name.
// Brittle: tied to markup and styling
await page.locator("div.form > button.btn-primary").click();

// Resilient: tied to what the user sees
await page.getByRole("button", { name: "Send inquiry" }).click();

Never sleep, always assert

Fixed waits like waitForTimeout(2000) are either too slow or not slow enough. Playwright's web-first assertions retry automatically until the condition holds or the timeout expires:

await page.getByTestId("contact-submit").click();
await expect(page.getByTestId("contact-status")).toHaveText(
  "Thanks! Your message has been sent.",
);

If you find yourself reaching for a sleep, there is almost always a state you can assert on instead: a spinner disappearing, a button becoming enabled, a URL changing.

Control the network at the boundaries

Tests that depend on third-party services inherit their downtime. Use page.route() to stub external APIs, payment providers and email services, so each test exercises your code deterministically:

await page.route("**/api/send", (route) =>
  route.fulfill({ json: { ok: true, message: "Sent" } }),
);

Keep a small number of separate, clearly labelled integration tests that hit real services, and run them on a schedule rather than on every commit.

Make every test independent

Each test should create the state it needs and never rely on another test having run first. That's what lets Playwright run tests in parallel across workers, which is where most of its speed comes from. Shared server-side state, such as rate limiters or seeded database rows, needs unique keys per test so parallel runs never collide.

Cover the devices your users actually use

Configure projects for desktop and mobile viewports and run the same specs against both. Layout-specific checks can opt out cleanly:

test("cards sit in a single row on desktop", async ({ page, viewport }) => {
  test.skip((viewport?.width ?? 0) < 1024, "desktop-only layout check");
  // ...
});

Build accessibility into the suite

Pairing Playwright with @axe-core/playwright turns WCAG checks into ordinary test assertions, so accessibility regressions fail the build like any other bug instead of surfacing in an audit months later.

Treat failures as information

Turn on traces for retried tests (trace: "on-first-retry") so every CI failure comes with a timeline, DOM snapshots and network log. A flaky test that's investigated and fixed within a day keeps trust high; one that's quietly retried forever teaches the team to ignore red builds.