Best Practices August 5, 2026 18 min read

15 Playwright Best Practices That Eliminate Flaky Tests in 2026

Selector fragility accounts for roughly half of all "it worked last week" failures. Shared state causes most of the rest. These 15 battle-tested practices — with code examples for every one — will transform your Playwright suite from a flaky liability into a reliable safety net that your team actually trusts.

A test suite nobody trusts is worse than no test suite at all. It gives the illusion of coverage while the team ignores red pipelines and ships anyway. The difference between teams that trust their E2E suite and teams that don't almost always comes down to the same 15 practices.

These aren't theoretical suggestions — they're patterns extracted from production suites with 500–2,000+ tests. Each practice includes a concrete "do this, not that" example so you can apply it immediately.


Locator Strategy

1
Use Role-Based Locators First, Always

Locate elements by their ARIA role and accessible name. These locators survive UI redesigns because they target what the element is, not where it sits in the HTML. Use getByRole as your default, falling back to getByLabel, getByText, and getByTestId in that order.

Do this
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email address')
page.getByRole('heading', { level: 1 })
Not this
page.locator('.btn-primary')
page.locator('#email-input')
page.locator('div > h1:first-child')

Bonus: Role-based locators double as an accessibility audit. If you can't find an element with getByRole, that element likely has accessibility problems that affect real users with screen readers.

2
Reserve getByTestId for Elements Without Semantic Roles

Use data-testid only for elements that have no meaningful ARIA role: custom canvas components, generic wrapper <div>s used as drop zones, or dynamically generated containers. If you're adding data-testid to a <button>, <input>, or <link>, that's a code smell — those elements already have roles.

3
Never Chain Locators to Match DOM Structure

Chaining like page.locator('.sidebar').locator('.menu').locator('.item') creates a tight coupling to your HTML hierarchy. When a developer wraps elements in a new <div>, the chain breaks. Target the end element directly: page.getByRole('menuitem', { name: 'Settings' }).


Assertions & Waiting

4
Use Web-First Assertions — They Auto-Retry

Playwright's await expect() assertions automatically retry until the condition is met or the timeout expires. This eliminates the single largest category of flaky tests.

Do this
await expect(page.getByText('Success')).toBeVisible()
await expect(page).toHaveURL(/dashboard/)
Not this
const text = await page.textContent('.msg');
expect(text).toBe('Success');

The "don't" example fails intermittently because textContent() evaluates once at the exact moment it's called. If the page hasn't rendered yet, you get null. The "do" example retries until "Success" appears or the timeout expires.

5
Never Use Hardcoded Waits

page.waitForTimeout(3000) is the #1 cause of flaky tests across every framework. It's either too short (test fails) or too long (suite is slow). Replace every hardcoded wait with a specific condition.

Do this
await page.waitForResponse('**/api/data')
await expect(spinner).not.toBeVisible()
Not this
await page.waitForTimeout(3000)
await page.waitForTimeout(5000)

One exception: waitForTimeout is acceptable in rare debugging scenarios (await page.pause() is better). It should never appear in committed test code.


Test Isolation

6
Every Test Must Be Independent

Each test should create its own data, perform its own actions, and assert its own outcomes. No test should depend on another test running first. No test should leave state that affects other tests. Playwright creates a fresh browser context per test by default — use this isolation instead of fighting against it.

TypeScript — Isolated test with API-seeded data
test('user can update their display name', async ({ page, request }) => {
  // Arrange: create test user via API (fast, isolated)
  const res = await request.post('/api/test-users', {
    data: { name: 'Test User', email: `user-${Date.now()}@test.com` }
  });
  const user = await res.json();

  // Act: update name through UI
  await page.goto('/profile');
  await page.getByLabel('Display name').fill('New Name');
  await page.getByRole('button', { name: 'Save' }).click();

  // Assert
  await expect(page.getByText('New Name')).toBeVisible();
});
7
Seed Test Data via API, Not UI

Using the UI to create test data is 10–20x slower than an API call and introduces dependencies on unrelated UI components. If the registration flow breaks, every test that creates users through the UI also breaks — even though those tests have nothing to do with registration.

Use the request fixture to seed data in beforeEach or in custom fixtures. Clean up in afterEach with DELETE calls.

8
Use storageState for Authentication

Don't log in through the UI for every test. Authenticate once in a globalSetup script, save the session to a JSON file with storageState, and load it in your config. This saves 2–5 seconds per test and eliminates a dependency on the login page.

TypeScript — playwright.config.ts
export default defineConfig({
  globalSetup: './global-setup.ts',
  use: {
    storageState: 'auth.json',
  },
});

Test Architecture

9
Use Page Object Model for Reusable Pages

Encapsulate page interactions into classes. When a selector changes, you update it in one place instead of across 30 test files. Keep POM classes focused: locators as readonly properties, user actions as async methods. Don't put assertions in POM classes — assertions belong in tests.

TypeScript — Clean POM class
export class LoginPage {
  readonly email = this.page.getByLabel('Email');
  readonly password = this.page.getByLabel('Password');
  readonly submitBtn = this.page.getByRole('button', { name: 'Sign In' });

  constructor(private page: Page) {}

  async login(email: string, password: string) {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submitBtn.click();
  }
}
10
Use Custom Fixtures for Shared Setup

Extract repeated setup logic into Playwright fixtures instead of duplicating beforeEach blocks. Fixtures are composable, lazy (only instantiated when requested), and automatically cleaned up. Combine fixtures with POM: create a fixture that provides an authenticated DashboardPage instance.

TypeScript — Fixture + POM
export const test = base.extend<{ dashboardPage: DashboardPage }>({
  dashboardPage: async ({ page }, use) => {
    // Automatic setup
    await page.goto('/dashboard');
    const dashboard = new DashboardPage(page);
    await use(dashboard);
    // Automatic teardown (if needed)
  },
});

// Tests are clean and readable
test('shows recent activity', async ({ dashboardPage }) => {
  await expect(dashboardPage.activityFeed).toBeVisible();
});
11
Mock External Services, Not Your Own

Use page.route() to intercept calls to third-party services (payment gateways, email APIs, analytics) that are slow, expensive, or unreliable. Don't mock your own API in E2E tests — the whole point of E2E testing is to verify the real integration between frontend and backend.

Mock this
page.route('**/stripe.com/**', ...)
page.route('**/analytics.google.com/**', ...)
Don't mock this
page.route('**/your-api/products', ...)
page.route('**/your-api/users', ...)

Exception: Mock your own API when testing specific error states that are hard to trigger (500 errors, timeouts, rate limits). In those cases, mock the specific endpoint for that specific test, not globally.


CI/CD & Performance

12
Configure Different Settings for CI vs Local

Local development and CI have fundamentally different needs. Use environment-aware config:

TypeScript — playwright.config.ts
const isCI = !!process.env.CI;

export default defineConfig({
  retries: isCI ? 2 : 0,             // Retry only in CI
  workers: isCI ? 4 : undefined,      // Limit workers in CI
  use: {
    trace: isCI ? 'on-first-retry' : 'off',  // Traces only in CI
    video: isCI ? 'on-first-retry' : 'off',  // Video only on failure
    headless: isCI,                   // Headed locally for debugging
  },
  reporter: isCI
    ? [['html'], ['junit', { outputFile: 'results.xml' }]]
    : [['list']],
});
13
Use Sharding for Large Suites

When your suite exceeds 10 minutes in CI, shard it across multiple machines. Playwright's --shard flag splits tests evenly. In GitHub Actions, use a matrix strategy:

YAML — .github/workflows/playwright.yml
jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --shard=${{ matrix.shard }}/4
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: report-shard-${{ matrix.shard }}
          path: playwright-report/

This turns a 20-minute suite into a 5-minute run. Each shard gets a proportional subset of tests with no configuration needed beyond the flag.

14
Always Upload Trace & Report Artifacts

Configure CI to upload Playwright's HTML report and trace files as build artifacts. When a test fails in CI, you need the trace to diagnose the issue without reproducing it locally. Set if: always() on the upload step so artifacts are saved even when tests fail.


AI-Assisted Testing

15
Use AI for Generation, Not Blind Trust

In 2026, Claude AI with the Playwright MCP Server can generate complete test suites from natural language. This is a genuine 3–5x speed multiplier — but it requires the same discipline as hand-written code:

  • Always review generated tests — verify assertions match your business logic, not just that the code compiles
  • Run tests locally before committing — first-run pass rates are high but not 100%
  • Use MCP Server for live DOM context — prompt-only generation produces less accurate locators
  • Apply the same architecture rules — request POM structure, proper test isolation, and meaningful test names from Claude
  • Use AI for self-healing — when a selector breaks, paste the failure into Claude with MCP connected to fix it in under 2 minutes instead of 30

The best workflow: Use Claude to generate the initial test files with POM structure, then review and commit. When tests break due to UI changes, use Claude + MCP to self-heal. This combines AI speed with human judgement for a test suite that's both fast to build and reliable to maintain.


Quick-Reference Checklist

Before You Commit a Test

  • Locators use getByRole or getByLabel
  • No hardcoded waits (waitForTimeout)
  • All assertions use await expect()
  • Test creates its own data
  • Test doesn't depend on other tests
  • Test runs in parallel without flaking
  • POM class used for reusable pages
  • External services are mocked

Before You Merge to Main

  • CI config has retries: 2
  • Trace artifacts uploaded on failure
  • storageState used for authentication
  • Sharding enabled if suite > 10 min
  • All tests pass locally and in CI
  • No test.skip without a linked issue
  • New tests reviewed by a teammate
  • HTML report uploaded as artifact

Frequently Asked Questions

What are the most important Playwright best practices?

The five most impactful: (1) Use getByRole/getByLabel instead of CSS selectors, (2) Use web-first await expect() assertions, (3) Isolate every test with its own data, (4) Never use hardcoded waits, (5) Seed data via API instead of UI. These five eliminate the majority of flaky tests.

How do I fix flaky Playwright tests?

Most flakiness comes from: hardcoded waits (replace with assertions), brittle CSS selectors (switch to role-based locators), shared test data (give each test its own), animation interference (use reducedMotion), and race conditions in parallel (ensure no shared state). Use Trace Viewer to diagnose exactly where tests fail.

How do I speed up Playwright tests in CI?

Enable fullyParallel, use sharding across CI machines, run headless, seed data via API, mock external services, use storageState for auth, and block unnecessary resources (images, fonts) with route.abort().

Should I use Page Object Model or fixtures?

Use both. POM encapsulates page interactions (locators, actions). Fixtures handle setup/teardown (auth, data seeding). Combine them: fixtures that provide pre-configured POM instances to tests. This gives you clean, readable test files.

How many tests should run in parallel?

Start with Playwright's default (CPU cores / 2). For CI, 4–8 workers per machine is typical. If parallel runs introduce flakiness, check for shared state before reducing workers. For 500+ tests, shard across multiple CI machines.


Asim Noaman - Playwright and Claude AI course instructor

Asim Noaman

Senior QA Automation Engineer & AI Testing Specialist

With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.

Udemy Instructor Published course author
Playwright + AI Expert Specialized in AI-powered QA
Production Experience Enterprise-grade frameworks
Connect on LinkedIn