100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Testing

Playwright Best Practices

A practical guide to writing reliable, maintainable Playwright tests by choosing the right locators, avoiding flaky waits, and keeping tests isolated and fast.

Practical PlaywrightIntermediate9 min readJul 10, 2026
Analogies

Why Best Practices Matter in Playwright

Playwright is designed around auto-waiting and web-first assertions, but tests still become flaky when authors fight the framework instead of using it as intended. The most common source of flakiness is choosing selectors that are tied to implementation details (CSS class names, DOM nesting) rather than to how a real user identifies an element, such as its accessible role or visible label. Writing tests the way Playwright's own documentation recommends dramatically reduces maintenance cost as the application's markup evolves.

🏏

Cricket analogy: Just as a commentator identifies a bowler by name and role (Bumrah, the death-over specialist) rather than by his shirt's stitching pattern, a resilient test should identify elements by role and label rather than by fragile CSS classes that change with every kit redesign.

Locator Strategy

Playwright's recommended locator priority is: getByRole, getByLabel, getByPlaceholder, getByText, and only then getByTestId as a fallback for elements without meaningful semantics. Role-based locators like page.getByRole('button', { name: 'Submit' }) mirror how assistive technology and real users perceive the page, which also nudges teams toward more accessible markup. CSS selectors chained several levels deep, or XPath expressions walking sibling axes, should be treated as a last resort because they break the moment a designer adds a wrapping div.

🏏

Cricket analogy: A fielding captain sets a field by role, third man, gully, deep square leg, rather than by which specific player happens to be standing there that match, the same way getByRole targets a button's function, not its incidental markup.

typescript
import { test, expect } from '@playwright/test';

test('user can submit the contact form', async ({ page }) => {
  await page.goto('/contact');

  // Prefer role + accessible name over CSS classes
  await page.getByLabel('Email address').fill('user@example.com');
  await page.getByLabel('Message').fill('Hello from Playwright!');

  await page.getByRole('button', { name: 'Send message' }).click();

  // Web-first assertion: auto-retries until it passes or times out
  await expect(page.getByText('Thanks, we received your message')).toBeVisible();
});

Waiting and Assertions

Playwright's expect() assertions are web-first: expect(locator).toBeVisible() polls the DOM automatically until the condition is true or the timeout expires, so there is rarely a legitimate reason to insert page.waitForTimeout(). Manual timeouts either wait too long, wasting CI minutes, or too little, producing intermittent failures on slower machines. Actions like click() and fill() also auto-wait for the target element to be attached, visible, stable, and enabled before interacting with it, which eliminates most of the 'element not interactable' errors seen in older Selenium-style scripts.

🏏

Cricket analogy: A third umpire doesn't guess when to check a run-out, they watch the replay until the bails are clearly dislodged, exactly as toBeVisible() polls the DOM until the real condition is confirmed rather than guessing a fixed delay.

Avoid page.waitForTimeout() in test code. It is almost never the right tool: it either slows down your suite unnecessarily or produces flaky failures under load. Use web-first assertions (expect(locator).toBeVisible(), toHaveText(), etc.) or explicit waits like waitForResponse() tied to a real condition instead.

Test Isolation

Each Playwright test should run in its own isolated browser context so that cookies, localStorage, and session state from one test never leak into another. The default Playwright Test runner already creates a fresh context (and therefore a fresh page) per test, which is why tests can safely run in parallel across workers without interfering with each other. Relying on shared global state, such as a single logged-in page reused across many test() blocks, reintroduces the exact ordering dependencies that make suites fragile and hard to debug.

🏏

Cricket analogy: Each net session gets a freshly rolled pitch so one bowler's footmarks don't affect the next batter's session, just as each Playwright test gets a fresh browser context so no leftover state carries over.

Use test.beforeEach() (or fixtures) for setup that must run before every test, rather than relying on module-level variables or a shared page created once. This keeps tests independent, so any single test can be run, retried, or reordered without breaking others.

  • Prefer getByRole, getByLabel, and getByText over brittle CSS/XPath selectors.
  • Use getByTestId only when no meaningful accessible attribute exists.
  • Rely on Playwright's auto-waiting and web-first expect() assertions instead of manual timeouts.
  • Never use page.waitForTimeout() as a substitute for a real wait condition.
  • Each test should run in its own isolated browser context with no shared state.
  • Use fixtures and test.beforeEach() for setup instead of module-level shared objects.
  • Isolated, semantically-targeted tests stay reliable even as the UI is redesigned.

Practice what you learned

Was this page helpful?

Topics covered

#Testing#PlaywrightStudyNotes#TestingQA#PlaywrightBestPractices#Playwright#Matter#Locator#Strategy#StudyNotes#SkillVeris