Run Web Automation Tests at Scale with Agentic AI

Generate tests with AI agents or automate test suites with Selenium, Playwright and other frameworks. Run tests in parallel on TestMu AI scalable cloud grid.

PlaywrightAutomationCI/CD

Playwright Best Practices: 9 Rules for Stable, Fast Tests

Playwright best practices for 1.65: nine rules for locators, web-first assertions, isolation and CI, each with the setting that enforces it and a measured run.

Published on:

Playwright best practices exist mostly to stop one failure: a test that passes on your laptop, fails in CI and reports an element that was plainly on the page as not found. In 10 runs against a TestMu AI demo page, a test that clicked a button and waited for the result with a web-first assertion failed 5 times, and an instrumented rerun traced each such failure to a click that landed before the page had attached its handler.

The usual causes are a check that does not wait, a sleep that guesses, state that leaks between tests and calls to a service the team does not own. Playwright has a setting, an assertion or a flag for each, and the rules below apply them in the order you would add them to a suite.

The rules are written for Playwright 1.65.0, which npm lists as the latest release on November 11, 2026, and each one names the config option, lint rule or CLI flag that enforces it, so it holds in code review and in tests an AI coding agent writes. If you are new to the framework, start with the Playwright tutorial.

Overview

The most important Playwright best practices are web-first assertions instead of sleeps, role-based locators, and test isolation with a one-time login. Enforce them with config and lint: forbid test.only and fail the build on flaky tests in CI, and lint for missing awaits with eslint-plugin-playwright.

What Are the Most Important Playwright Best Practices?

  • Playwright web-first assertions: expect(locator).toBeVisible() retries until the element appears or the 5-second default timeout passes, while locator.isVisible() checks once. In a measured run on a TestMu AI demo page, the one-shot isVisible() check failed 10 of 10 runs.
  • Playwright role-based locators: getByRole(), getByLabel() and getByTestId() find elements the way users and assistive technology do. Playwright locators are strict, so an action that matches two elements throws an error instead of clicking the wrong one.
  • Playwright test isolation: Each test gets a fresh browser context. A setup project logs in once and shares the saved session, testInfo.testId gives each test unique data, and npx playwright test --shuffle (Playwright 1.64 or later) exposes tests that depend on run order.
  • eslint-plugin-playwright: The eslint-plugin-playwright recommended config reports missing awaits, test.only calls and networkidle waits as errors and page.waitForTimeout() as a warning, so the same mistakes are caught in specs written by people and by AI coding agents.
  • Playwright retries: A stable Playwright suite retries on CI only, records a trace on the first retry with trace: 'on-first-retry', and sets failOnFlakyTests so a test that passes only on retry fails the build.

Can You Run Playwright Tests on a Cloud Grid?

Yes. TestMu AI runs existing Playwright Test suites on cloud browsers, and HyperExecute splits them across machines.

  • TestMu AI Playwright SDK: Runs an existing Playwright Test suite on TestMu AI cloud browsers from one configuration file. Requires test script changes: No. Configuration file: lambdatest.yml.
  • HyperExecute AutoSplit: Spreads the discovered Playwright tests across as many virtual machines as the concurrency key in the HyperExecute YAML sets, and retries failed tests when retryOnFailure is turned on.

9 Playwright Best Practices in the Order You Apply Them

Each rule names the failure it prevents and the setting that enforces it. The table is the quick reference; the sections after it show the code, and rule 3 includes the measured run.

RuleFailure it preventsEnforce it with
1. Strict configA stray test.only, a hung run, surprise upgradesforbidOnly, globalTimeout, a committed lockfile
2. User-facing locatorsSelectors that break when markup movesStrict locators, no-raw-locators
3. Web-first assertionsOne-shot checks and guessed sleepsprefer-web-first-assertions, no-wait-for-timeout
4. Independent testsTests that pass only in one orderfullyParallel, --shuffle, test locks
5. Stubs and seed dataThird-party latency, outages and random datapage.route() with route.fulfill()
6. Fixtures and page objectsSetup copied into every spectest.extend(), locators set in constructors
7. Lint rulesA missing await, a focused testno-floating-promises, missing-playwright-await
8. Traces and flaky testsRetries that hide defectstrace: on-first-retry, failOnFlakyTests
9. Parallel runs and shardsLong CI runs and split reports--shard, blob reports, merge-reports

1. Pin the Playwright Version and Make the Config Strict

Start from the config that npm init playwright@latest generates, then tighten it for CI. The 1.65 release notes say Playwright no longer tests on Node.js 20 and list Node.js 22, 24 and 26, so check the CI image first, then upgrade on purpose: commit the lockfile, run npm install -D @playwright/test@latest and then npx playwright install --with-deps so the browsers match.

The Playwright 1.65 release guide lists what to check before a suite moves up a version. A strict starting config looks like this:

// playwright.config.ts (Playwright 1.65)
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  globalTimeout: 60 * 60 * 1000,
  use: {
    baseURL: 'https://www.testmuai.com',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] }, default: false },
    { name: 'webkit', use: { ...devices['Desktop Safari'] }, default: false },
  ],
});

forbidOnly fails a CI run that still contains test.only, retries apply on CI only, and globalTimeout ends a hung run with a report instead of letting the CI job time out. default: false (1.64+) keeps Firefox and WebKit configured but out of local runs, and on CI npx playwright test --project="*" runs all three.

2. Locate Elements by Role, Label and Test ID

Find elements the way a user or a screen reader finds them. The Playwright locators docs recommend prioritizing role locators, the closest match to how users and assistive technology perceive the page; after role, reach for label, placeholder and text locators, and keep getByTestId() for elements with no stable accessible name.

Every locator is strict: an action on a locator that matches more than one element throws instead of picking one. Narrow a match with filter() or, since 1.64, within(), and use visible() (1.63+) in place of the :visible CSS pseudo-class.

// Role plus accessible name survives class and layout changes
await page.getByRole('button', { name: 'Get Random User' }).click();

// Label for form fields, test ID when nothing else is stable
await page.getByLabel('Email').fill('ada@example.com');
await page.getByTestId('order-summary').click();

// One Save button among several: scope it to the dialog (1.64+)
const save = page.getByRole('button', { name: 'Save' });
await save.within(page.getByRole('dialog')).click();

Set testIdAttribute in the config if your app uses an attribute other than data-testid, and turn on playwright/no-raw-locators, which the lint plugin leaves out of its recommended set, so new page.locator() strings get flagged in review. The guide to Playwright locators covers every getBy method and how to fix strict mode errors. For React component tests, React Testing Library vs Enzyme compares role and label queries with Enzyme's CSS selectors and component names.

3. Replace Sleeps and One-Shot Checks With Web-First Assertions

Playwright's auto-waiting docs list what happens before a click: the element must be visible, stable, receiving events, enabled and not inert. Web-first assertions such as toBeVisible() also retry until they pass or their timeout runs out, which is 5 seconds by default according to the Playwright assertions docs.

A one-shot read such as locator.isVisible() does neither: it returns immediately, and the API docs mark page.waitForTimeout() as discouraged, for debugging only. To measure the difference, the four tests below ran 10 times each against the Dynamic Data Loading demo on the TestMu AI Selenium Playground, which fetches a user from a third-party API after the click.

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

const demo = '/selenium-playground/dynamic-data-loading-demo/';

test('isVisible()', async ({ page }) => {
  await page.goto(demo);
  await page.getByRole('button', { name: 'Get Random User' }).click();
  expect(await page.getByText('First Name').isVisible()).toBe(true);
});

test('sleep 3s', async ({ page }) => {
  await page.goto(demo);
  await page.getByRole('button', { name: 'Get Random User' }).click();
  await page.waitForTimeout(3000);
  expect(await page.getByText('First Name').isVisible()).toBe(true);
});

test('toBeVisible()', async ({ page }) => {
  await page.goto(demo);
  await page.getByRole('button', { name: 'Get Random User' }).click();
  await expect(page.getByText('First Name')).toBeVisible();
});

test('toPass()', async ({ page }) => {
  await page.goto(demo);
  const result = page.getByText('First Name');
  await expect(async () => {
    await page.getByRole('button', { name: 'Get Random User' }).click();
    await expect(result).toBeVisible({ timeout: 3_000 });
  }).toPass({ timeout: 15_000 });
});

The run used npx playwright test wait.spec.ts --repeat-each=10 --workers=1 on November 11, 2026, with Playwright Test 1.61.1 and its bundled Chromium; every API in the file works the same way in 1.65. An excerpt of the list reporter output:

  x  13 wait.spec.ts:5:5 › isVisible() (860ms)
  x  14 wait.spec.ts:11:5 › sleep 3s (3.7s)
  x  15 wait.spec.ts:18:5 › toBeVisible() (5.6s)
  ok 16 wait.spec.ts:24:5 › toPass() (2.6s)
  x  17 wait.spec.ts:5:5 › isVisible() (796ms)
  ok 18 wait.spec.ts:11:5 › sleep 3s (3.7s)
  ok 19 wait.spec.ts:18:5 › toBeVisible() (4.3s)
  ok 20 wait.spec.ts:24:5 › toPass() (2.6s)
  ...
  17 failed
  23 passed (2.5m)
CheckPassed (of 10)Median time
isVisible() right after the click00.8 s (failing runs)
3-second sleep, then isVisible()83.8 s
toBeVisible()52.7 s (passing runs)
toPass() around the click and the assertion102.6 s

The one-shot check failed every run, because it read the page before the result existed. The sleep passed 8 times, but its passing runs took a median 3.8 seconds against 2.7 seconds for the passing toBeVisible() runs, and it still failed twice; toBeVisible() waited out the slow result and still failed half the runs with "element(s) not found".

A separate run of the same four tests, with a request counter added, showed why: every failure of the sleep and toBeVisible() versions came from a click that sent no request at all. The demo attaches its click handler with jQuery after the page loads, and in separate probes jQuery was not yet defined when goto() returned in 8 of 8 runs, so a click that passed every actionability check reached a button with no listener.

Wrapping the click and the assertion in expect(...).toPass() retried the click whenever the first one did nothing: 10 of 10 passed with a 2.6-second median, and the slowest run, which needed a second click, took 6.4 seconds. toPass() has no timeout unless you pass one, so always set it.

If you own the page, make readiness visible instead: keep the button disabled until its handler is attached, and Playwright's enabled check waits for it. The guide to Playwright flaky tests covers that hydration fix, and Playwright assertions lists every auto-retrying assertion. In lint, prefer-web-first-assertions is an error and no-wait-for-timeout a warning in the plugin's recommended config (rule 7).

4. Keep Every Test Independent and Log In Once

Playwright Test gives each test a fresh browser context, so cookies and storage never carry over; flaky tests come from state outside the browser, such as a record two tests edit or a module-level variable. The Playwright parallelism docs recommend deriving unique data from testInfo.testId, writing files to testInfo.outputPath() and setting up everything a test needs in the test or a fixture.

Prove the independence instead of assuming it. fullyParallel: true runs the tests inside a file in parallel too, and --shuffle (1.64+) runs files, and tests in parallel mode, in a random order and prints the seed, so a failing order can be replayed. The Playwright 1.64 release guide covers the flag and default projects.

npx playwright test --shuffle             # prints the seed it used
npx playwright test --shuffle 271828182   # replays the order of seed 271828182

When a few tests must share one resource, such as a global account setting, give them a named lock (1.63+) instead of switching the file to serial mode, which the docs do not recommend. Tests that hold the same lock never run at the same time, while everything else stays parallel.

test('update user settings', { lock: 'user-settings' }, async ({ page }) => {
  // never runs at the same time as other 'user-settings' tests
});

Logging in through the UI in every test is slow and repeats one fragile flow across the suite. A setup project signs in once and saves the session with storageState, and every other project lists it in dependencies:

// playwright.config.ts
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
    dependencies: ['setup'],
  },
],

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('sign in once', async ({ page }) => {
  await page.goto('/login/'); // your app's sign-in page
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page.getByRole('link', { name: 'Account' })).toBeVisible();
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

Add playwright/.auth to .gitignore, because the Playwright authentication docs warn that the state file can hold cookies and headers that impersonate the test account. When tests change server-side state, the same docs recommend one account per parallel worker, keyed on testInfo.parallelIndex, and Playwright projects explains setup projects and dependencies in more depth.

5. Stub Services You Do Not Own and Seed Data You Do

End-to-end tests should exercise your own front end and back end. Calls to services you do not control, such as a payment provider, a map tile server or the random-user API behind the demo page, make every run depend on someone else's latency, data and uptime; page.route() intercepts those requests and route.fulfill() answers them with fixed data.

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

test('stubbed user API', async ({ page }) => {
  await page.route('https://api.randomuser.me/**', route =>
    route.fulfill({
      json: {
        results: [{ name: { first: 'Ada', last: 'Lovelace' }, picture: { large: '' } }],
      },
    }),
  );
  await page.goto('/selenium-playground/dynamic-data-loading-demo/');
  const result = page.getByText('First Name');
  await expect(async () => {
    await page.getByRole('button', { name: 'Get Random User' }).click();
    await expect(result).toContainText('Ada', { timeout: 3_000 });
  }).toPass({ timeout: 15_000 });
  await expect(result).toContainText('Lovelace');
});

This exact spec passed 10 of 10 runs on November 11, 2026, and it can assert an exact name because the data no longer changes between runs. Each run took about 5 seconds: in a rerun with an attempt counter, the first click still landed before the page's handler in 8 of 10 stubbed runs, and toPass() clicked again after its 3-second inner timeout. The stub controls the data, and the readiness of the page stays the job of the toPass() block.

For data you do own, seed it through your API instead of clicking through setup screens. The request fixture sends HTTP requests straight from the test and respects the baseURL and extraHTTPHeaders set in the config.

Time is another dependency you do not own: page.clock.setFixedTime() pins Date.now(), so date-dependent text renders the same in every run. The guide to deterministic tests covers clock control and the other flake patterns coding agents produce, and Playwright API mocking covers routes, HAR files and modified responses.

6. Share Setup Through Fixtures and Page Objects

Copying the same setup into every spec spreads one change across many files. A fixture declared with test.extend() sets something up, hands it to the test through use() and tears it down afterwards, and Playwright sets up only the fixtures a test asks for; a page object keeps one page's locators and actions in a single class.

// fixtures.ts
import { test as base, type Page, type Locator } from '@playwright/test';

class DemoPage {
  readonly getUser: Locator;
  readonly result: Locator;

  constructor(readonly page: Page) {
    // Assign locators in the constructor. Since 1.65, a field initializer
    // that reads a constructor parameter property runs too early and throws.
    this.getUser = page.getByRole('button', { name: 'Get Random User' });
    this.result = page.getByText('First Name');
  }

  async open() {
    await this.page.goto('/selenium-playground/dynamic-data-loading-demo/');
  }
}

export const test = base.extend<{ demo: DemoPage }>({
  demo: async ({ page }, use) => {
    const demo = new DemoPage(page);
    await demo.open();
    await use(demo);
  },
});
export { expect } from '@playwright/test';

// demo.spec.ts
import { test, expect } from './fixtures';

test('loads a random user', async ({ demo }) => {
  await expect(async () => {
    await demo.getUser.click();
    await expect(demo.result).toBeVisible({ timeout: 3_000 });
  }).toPass({ timeout: 15_000 });
});

This fixture and page object passed 3 of 3 local runs on the same setup as rule 3. Assign locators in the constructor, as the class does: Playwright 1.65 compiles class fields the way tsc does under useDefineForClassFields, so a field initializer such as readonly submit = this.page.getByRole(...) runs before a constructor parameter property exists and throws a TypeError.

The Playwright Page Object Model guide shows a real-world example and how fixtures support page objects.

7. Lint for Missing Awaits and Banned Waits

A missing await is easy to overlook in review: the call starts, the test moves on, and the failure shows up somewhere else or not at all. Playwright's own guidance recommends the @typescript-eslint/no-floating-promises rule for it, which needs typed linting, and tsc --noEmit on CI to catch wrong signatures.

eslint-plugin-playwright adds rules for Playwright itself. Its recommended config reports missing-playwright-await, no-focused-test, no-networkidle and prefer-web-first-assertions as errors, and no-wait-for-timeout, no-element-handle and no-force-option as warnings:

// eslint.config.mjs (eslint-plugin-playwright 2.12.2, typescript-eslint)
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import playwright from 'eslint-plugin-playwright';

export default defineConfig({
  files: ['tests/**/*.ts'],
  extends: [
    tseslint.configs.recommendedTypeChecked,
    playwright.configs['flat/recommended'],
  ],
  languageOptions: {
    parserOptions: { projectService: true },
  },
  rules: {
    'playwright/no-raw-locators': 'warn',
    'playwright/no-wait-for-timeout': 'error',
  },
});

Raising no-wait-for-timeout to an error makes the sleep rule absolute, so a sleep kept for debugging needs an eslint-disable comment that a reviewer can see. Tests an AI coding agent writes go through the same lint, so the rules hold whoever wrote the spec.

8. Debug From Traces and Fail the Build on Flaky Tests

Treat a retry as a detector. Playwright reports a test that fails and then passes on retry as flaky, separately from passed and failed, and trace: 'on-first-retry' records a trace on that retry, which npx playwright show-trace or trace.playwright.dev opens.

failOnFlakyTests (1.52+) fails the run on any flaky test, so a pass on retry cannot hide a defect, and retryStrategy: 'isolated' (1.62+) runs retries after everything else, one at a time in a single worker. The failures reporter (1.65+) prints failures and successful retries as they happen, then a summary, with no progress output, which keeps CI logs short:

// playwright.config.ts (excerpt)
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  retryStrategy: 'isolated',
  failOnFlakyTests: !!process.env.CI,
  reporter: process.env.CI ? [['failures'], ['blob']] : 'html',
  use: { trace: 'on-first-retry' },
});

To reproduce a flaky test, rerun only the failures with npx playwright test --last-failed, or repeat one test with --repeat-each=20 --retries=0, and step through it in UI mode with --ui. For Rails system tests, the guide to Capybara flaky tests reproduces a failure by replaying the CI seed and slowing Chrome down.

9. Run in Parallel and Shard the Suite in CI

Playwright runs test files in parallel worker processes by default, and the config it generates sets workers to 1 on CI, which is safe but slow. Once rules 4 and 5 hold, give CI more workers, and split a large suite across machines with --shard.

With fullyParallel: true, sharding balances individual tests instead of whole files, which the Playwright sharding docs call the preferred mode for even shards. Each shard writes a blob report, and npx playwright merge-reports turns them into one HTML report:

# One command per CI machine
npx playwright test --shard=1/4
npx playwright test --shard=2/4   # and so on up to 4/4

# After every shard finishes, merge the blob reports
npx playwright merge-reports --reporter html ./all-blob-reports

Since 1.65, a sharded run fails with "No tests found" when no test matches across all shards, so pass --pass-with-no-tests only where that is expected. Playwright sharding covers project setup and troubleshooting in more detail.

Playwright Best Practices for Cloud Runs on TestMu AI

The rules above carry over unchanged when the browsers move off your machine. TestMu AI's Automation Cloud runs existing Playwright tests on 3,000+ browser and OS combinations, and the Playwright SDK runs a Playwright Test suite there from a lambdatest.yml file without changes to the test scripts:

npm install @lambdatest/playwright-node-sdk
npx playwright-node-sdk --init          # writes lambdatest.yml
npx playwright-node-sdk playwright test

Then match the cloud run to your local one:

  • Matching browser builds - The useSpecificBundleVersion capability picks the bundled Chromium, Firefox or WebKit build that matches your local Playwright version, so grid results line up with local runs.
  • A pinned Playwright version on HyperExecute - HyperExecute installs both the Playwright client and the server from your YAML, and the HyperExecute YAML creation for Playwright guide names a mismatch between the client and server versions as the reason tests pass locally and on the automation grid but fail on HyperExecute, so install the version from package.json in the pre step.
  • A report that never opens itself - On HyperExecute's headless machines, an HTML report set to open after the run hangs the task, so set the reporter to [['html', { open: 'never' }]].
  • Test-level splitting - AutoSplit mode, autosplit: true with testDiscovery and testRunnerCommand, spreads the discovered tests over as many virtual machines as the concurrency key sets, and retryOnFailure with maxRetries retries the tests that fail.

HyperExecute runs suites up to 70% faster than traditional grids, by TestMu AI's measure, because the test scripts and every execution component run in one isolated environment.

Run tests up to 70% faster on the TestMu AI cloud grid

Conclusion

To apply these Playwright best practices to a suite you already have, start with two files: the strict config from rule 1 and the lint config from rule 7. Then run npx playwright test --shuffle --repeat-each=5 once, and fix what fails with rules 3 to 5 before you add a single retry.

When the suite is stable locally, run your first Playwright test on TestMu AI, move the full suite with the Playwright SDK, and take the free Playwright 101 certification for a structured path through the framework.

Author

...

Himanshu Sheth

Blogs: 153

  • Twitter
  • Linkedin

Himanshu Sheth is the Director of Marketing (Technical Content) at TestMu AI, with over 8 years of hands-on experience in Selenium, Cypress, and other test automation frameworks. He has authored more than 130 technical blogs for TestMu AI, covering software testing, automation strategy, and CI/CD. At TestMu AI, he leads the technical content efforts across blogs, YouTube, and social media, while closely collaborating with contributors to enhance content quality and product feedback loops. He has done his graduation with a B.E. in Computer Engineering from Mumbai University. Before TestMu AI, Himanshu led engineering teams in embedded software domains at companies like Samsung Research, Motorola, and NXP Semiconductors. He is a core member of DZone and has been a speaker at several unconferences focused on technical writing and software quality.

Reviewer

...

Srinivasan Sekar

Reviewer

  • Linkedin

Srinivasan Sekar is Director of Engineering at TestMu AI, where he leads engineering and open-source initiatives behind the Selenium and Appium automation grid and owns TestMu AI's MCP Server. A committer to Appium and a contributor to Selenium, WebdriverIO, Taiko, and AppiumTestDistribution, he brings over 15 years of experience in quality engineering and open-source technologies. He is the author of the Apress book 'The MCP Standard: A Developer's Guide to Building Universal AI Tools with the Model Context Protocol,' a Certified Kubernetes and Cloud Native Associate, and an international conference speaker. Before TestMu AI he spent over eight years at Thoughtworks as a Principal Consultant and Quality Architect. Srinivasan holds a B.Tech in Information Technology from Anna University.

Add to Google preferred sources

Summarise with AI

Copied to Clipboard!
...

3000+ Browsers. One Platform.

See exactly how your site performs everywhere.

Try it free
...

Write Tests in Plain English with KaneAI

Create, debug, and evolve tests using natural language.

Try for free

Playwright Best Practices FAQs

Did you find this page helpful?

More Related Blogs

TestMu AI forEnterprise

Get access to solutions built on Enterprise
grade security, privacy, & compliance

  • Advanced access controls
  • Advanced data retention rules
  • Advanced Local Testing
  • Premium Support options
  • Early access to beta features
  • Private Slack Channel
  • Unlimited Manual Accessibility DevTools Tests