17 Playwright Best Practices Every Engineer Should Follow in 2026

Find best practices that separate production-grade Playwright setups from tutorial code, with working examples for every pattern.

Thumbnail 2

Playwright in 2026 ships with AI agents, MCP servers, and a CLI that can write tests autonomously. But raw tooling without the right patterns still produces fragile suites that break on every deploy.

This guide covers 17 practices that separate production-grade Playwright setups from tutorial code, from role-based locators and API data seeding to CI sharding and installing battle-tested Playwright Skills with a single command.

Best practices for Playwright

  1. Define your test coverage goals

  2. Test what users see, not how it's built

  3. Use stable locators to find elements

  4. Keep tests focused and isolated

  5. Write assertions that wait automatically

  6. Use APIs to seed test data

  7. Avoid testing 3rd party integrations

  8. Mock external dependencies with page.route()

  9. Structure your project for scale

  10. Master Playwright's debugging tools

  11. Parallelize and shard across CI

  12. Eliminate flaky tests systematically

  13. Test across browsers and devices

  14. Use Playwright AI to generate and heal tests

  15. Give your AI agent Playwright Skills

  16. Centralize reporting for CI

  17. Lint, type-check, and stay updated

1. Define your test coverage goals

Not all workflows deserve E2E tests. Testing everything is slower, more fragile, and burns through CI minutes without returning much value. Be strategic.

Start by listing critical user journeys: authentication, core features, transactions, and error recovery. Then use your analytics to prioritize. If 80% of users follow path A and 10% follow path B, prioritize A.

The reality of coverage:

100% coverage is a myth. After 70-80%, adding more tests costs more than the bugs they catch. Keep E2E tests to ~30% of total tests. The remaining ~70% should be unit and API tests.

example.spec.ts
// ✅ Good: Test critical happy path + common error states
test('user can complete checkout'async ({ pagerequest }) => {
  // Setup
  const userId = await request.post('/api/test/user/create').then(r => r.json());

  // Happy path
  await page.goto('https://storedemo.testdino.com/');
  await page.getByText('Apple iPad Air').first().click();
  await page.getByRole('button', { name/add to cart/i }).click();
  // ... checkout steps
  await expect(page.getByText('Cart')).toBeVisible();
});

test('checkout shows error when payment fails'async ({ page }) => {
  // Mock payment API to fail
  await page.route('**/api/payments'route => route.fulfill({
    status400,
    bodyJSON.stringify({ error'Card declined' })
  }));

  // Proceed to checkout and verify error handling
  await expect(page.getByText('Payment failed')).toBeVisible();
});

Pro Tip: Use your analytics data quarterly to revisit coverage priorities. What users do today might change in 6 months. Keep your test portfolio aligned with reality.

2. Test what users see, not how it's built

Most flaky test suites share a root cause: they test how the app is built instead of what the user sees. The Playwright team is explicit about this in their official best practices.

Click buttons by their visible label, not by a class name. Assert on text content the user actually reads, not internal state.

example.spec.ts
// ❌ Bad: Testing implementation details
await page.locator('button.bg-primary.add-to-cart').click();
expect(await page.evaluate(() => window.__cartState.added)).toBe(true);

// ✅ Good: Testing user-visible behavior
await page.getByRole('button', { name/add to cart/i }).click();

Rule of thumb: If you delete the element's class attribute and the user experience stays the same, your test shouldn't break either.

3. Use stable locators to find elements

Locators are the single most important decision you make in every test. The right locator survives a complete UI redesign. The wrong one breaks when a developer renames a CSS class.

Playwright provides built-in locators with auto-waiting and retry-ability. The priority order (most stable to least stable):

example.spec.ts
// 1. Role-based (most resilient)
page.getByRole('button', { name/add to cart/i });

// 2. Text-based
page.getByText('TestDino Demo Store');

// 3. Label-based (great for forms)
page.getByLabel('Search products...');

// 4. Placeholder-based
page.getByPlaceholder('Your Email');

// 5. Test ID (explicit contract)
page.getByTestId('cart-total');

// 6. CSS selector (last resort)
page.locator('.add-to-cart');

Role locators (getByRole()) mirror how screen readers interpret the page. When you use getByRole('button', { name: /add to cart/i }), you're selecting the element the way a real user would identify it.

For complex DOM structures, chain locators to narrow the scope:

example.spec.ts
// Find the "Add to cart" button inside the "Apple iPad Air" product card
const product = page.getByRole('listitem').filter({ hasText'Apple iPad Air' });
await product.getByRole('button', { name/add to cart/i }).click();

// Verify the cart count updated
await expect(page.getByTestId('cart-count')).toHaveText('1');

Use npx playwright codegen https://storedemo.testdino.com/ to auto-generate locators by recording interactions.

4. Keep tests focused and isolated

Each Playwright test runs in its own browser context with independent cookies, localStorage, session storage, and cache. No test should depend on the state left behind by another test.

Use beforeEach hooks for shared setup:

example.spec.ts
import { testexpect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  await page.goto('https://storedemo.testdino.com/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('securepassword');
  await page.getByRole('button', { name'Sign in' }).click();
  await expect(page.getByText('My Account')).toBeVisible();
});

test('user can view their profile'async ({ page }) => {
  await page.getByRole('link', { name'Profile' }).click();
  await expect(page.getByRole('heading', { name'My Profile' })).toBeVisible();
});

test('user can update their email'async ({ page }) => {
  await page.getByRole('link', { name'Settings' }).click();
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByRole('button', { name'Save' }).click();
  await expect(page.getByText('Settings saved')).toBeVisible();
});

For larger suites, logging in before every test wastes time. Use Playwright's setup project to authenticate once and share the session:

playwright.config.ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name'setup',
      testMatch/.*\.setup\.ts/,
    },
    {
      name'chromium',
      dependencies: ['setup'],
      use: {
        storageState'./auth.json',
      },
    },
  ],
});

auth.setup.ts
// auth.setup.ts
import { test as setupexpect } from '@playwright/test';

setup('authenticate'async ({ page }) => {
  await page.goto('https://storedemo.testdino.com/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name'Sign in' }).click();
  await expect(page.getByText('My Account')).toBeVisible();
  await page.context().storageState({ path'./auth.json' });
});

Now every test starts already logged in with zero repeated login steps.

5. Write assertions that wait automatically

This is the single most common source of flakiness in Playwright suites: using manual assertions instead of web-first assertions.

Web-first assertions (toBeVisible(), toHaveText(), toHaveURL()) automatically retry until the condition is met or the timeout expires. Manual assertions (isVisible(), textContent()) execute once and return immediately.

example.spec.ts
// ❌ Bad: Manual assertion, checks once
expect(await page.getByText('Cart').isVisible()).toBe(true);

// ✅ Good: Web-first assertion, retries until visible or timeout
await expect(page.getByText('Cart')).toBeVisible();

The difference is subtle but critical. In the bad example, await is inside the expect. In the good example, await is before expect, which tells Playwright to retry the assertion.

Never use page.waitForTimeout(). If you find yourself writing await page.waitForTimeout(2000), you're masking a real problem. Use web-first assertions or page.waitForResponse() instead.

6. Use APIs to seed test data

Setting up test data through the UI is slow, brittle, and defeats the purpose of automated testing. Use Playwright's request API to seed data via backend calls.

Creating a user through the UI takes ~5 seconds. The same via API takes 50 milliseconds. That's a 50x difference that compounds across hundreds of tests.

The request fixture gives every test a context for making authenticated HTTP calls:

example.spec.ts
import { testexpect } from '@playwright/test';

test.beforeEach(async ({ request }) => {
  // Create test user via API
  const userResponse = await request.post('https://storedemo.testdino.com/api/test/users', {
    data: {
      email'[email protected]',
      password'secure-password-123',
      firstName'Test',
      lastName'User'
    }
  });

  const user = await userResponse.json();
  console.log('Created test user:'user.id);
});

test('user can view their profile'async ({ page }) => {
  // User already exists, just log in
  await page.goto('https://storedemo.testdino.com/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('secure-password-123');
  await page.getByRole('button', { name'Sign in' }).click();

  await page.getByRole('link', { name'My Account' }).click();
  await expect(page.getByRole('heading', { name'My Account' })).toBeVisible();
});

For complex scenarios, create a factory to generate consistent test data:

fixtures/test-data-factory.ts
// fixtures/test-data-factory.ts
import { APIRequestContext } from '@playwright/test';

export class TestDataFactory {
  constructor(private requestAPIRequestContextprivate baseUrlstring) {}

  async createUser(overrides?: Partial<{ emailstringnamestring }>) {
    const response = await this.request.post(`${this.baseUrl}/api/test/users`, {
      data: {
        email`user-${Date.now()}@test.com`,
        password'test-password-123',
        firstName'Test',
        lastName'User',
        ...overrides
      }
    });
    return response.json();
  }

  async createProduct(overrides?: any) {
    const response = await this.request.post(`${this.baseUrl}/api/test/products`, {
      data: {
        name'Apple iPad Air',
        price660.00,
        inventory100,
        ...overrides
      }
    });
    return response.json();
  }

  async createOrder(userIdstringitemsany[]) {
    const response = await this.request.post(`${this.baseUrl}/api/test/orders`, {
      data: { userIditems }
    });
    return response.json();
  }
}

tests/my-test.spec.ts
// tests/my-test.spec.ts
import { testexpect } from '@playwright/test';
import { TestDataFactory } from '../fixtures/test-data-factory';

test.beforeEach(async ({ request }, testInfo) => {
  const factory = new TestDataFactory(requestprocess.env.BASE_URL || 'https://storedemo.testdino.com');

  // Create test data
  testInfo.user = await factory.createUser();
  testInfo.product = await factory.createProduct();
  testInfo.order = await factory.createOrder(testInfo.user.id, [testInfo.product.id]);
});

test('user can view their order history'async ({ pagerequest }, testInfo) => {
  // Data is already seeded. Just navigate and verify.
  await page.goto(`https://storedemo.testdino.com/orders/${testInfo.order.id}`);
  await expect(page.getByText('Apple iPad Air')).toBeVisible();
  await expect(page.getByText('$660.00')).toBeVisible();
});

Always clean up test data to keep your environment clean:

example.spec.ts
test.afterEach(async ({ request }, testInfo) => {
  if (testInfo.user?.id) {
    await request.delete(`https://storedemo.testdino.com/api/test/users/${testInfo.user.id}`);
  }
});

Pro Tip: Create a dedicated /api/test/* endpoint on your backend that only exists in non-production environments.

7. Avoid testing 3rd party integrations

You don't own third-party APIs. They have rate limits, change without notice, and come with side effects. Don't test external auth providers, payment processors, email delivery services, analytics platforms, or embedded widgets directly.

Mock the response instead:

example.spec.ts
// ✅ Good: Mock the Stripe API, test your integration point
test('user can complete checkout'async ({ page }) => {
  // Intercept calls to Stripe's API
  await page.route('**/stripe.com/v1/**'route => route.fulfill({
    status200,
    contentType'application/json',
    bodyJSON.stringify({
      id'ch_test_123',
      status'succeeded',
      amount660
    })
  }));

  // Now your test is reliable, fast, and doesn't charge the test card
  await page.getByRole('button', { name'Pay with Stripe' }).click();
  await expect(page.getByText('Payment successful')).toBeVisible();
});

Test your integration with the third party, not the third party itself.

8. Mock external dependencies with page.route()

page.route() is Playwright's Network API for intercepting HTTP requests during test execution. It lets you mock, modify, or abort any outgoing request before it reaches an external server.

Mock when: third-party APIs, rate-limited services, slow endpoints, or non-deterministic data.

Don't mock: your own backend during integration tests, or auth flows you need to verify E2E.

example.spec.ts
// Mock a third-party payment API
await page.route('**/api.stripe.com/v1/charges'route => route.fulfill({
  status200,
  contentType'application/json',
  bodyJSON.stringify({
    id'ch_test_123',
    status'succeeded',
    amount660,
  }),
}));

// Now test the checkout flow — Stripe never gets called
await page.goto('https://storedemo.testdino.com/checkout');
await page.getByRole('button', { name'Pay $660' }).click();
await expect(page.getByText('Payment successful')).toBeVisible();

9. Structure your project for scale

Group tests by feature, not by test type. One test file per workflow. Page Object Models live in a separate pages/ directory. Fixtures stay separate from tests.

Pattern

Use When

Example

Page Object Model

A page has many interactions reused across 3+ test files

LoginPage.ts with login(), forgotPassword() methods

Fixtures

Shared setup/teardown logic tied to the test lifecycle

Auth fixture providing a logged-in page

Helper functions

One-off utility functions used in a few places

generateRandomEmail(), formatDate()

10. Master Playwright's debugging tools

When a test fails, the speed of your debugging determines how fast you ship. Use each tool for its intended purpose:

  • Local: The Playwright VS Code extension lets you set breakpoints, step through tests, and inspect locators live.

  • Interactive: npx playwright test --debug opens the Playwright Inspector for stepping through actions one at a time.

  • Visual: npx playwright test --ui gives you a time-travel debugging experience with DOM snapshots, network logs, and console output.

  • CI: The Trace Viewer captures a complete trace (screenshots, DOM snapshots, network requests) as a single file you can replay. Set trace: 'on-first-retry' in your config.

11. Parallelize and shard across CI

Playwright runs tests in parallel by default across files. For large suites (500+ tests), shard across multiple CI machines:

For large test suites (500+ tests), shard across multiple machines:

.github/workflows/playwright.yml
# .github/workflows/playwright.yml 
jobs:
  test:
    strategy:
      matrix:
        shard: [1/42/43/44/4]
    steps:
      - usesactions/checkout@v4
      - usesactions/setup-node@v4
        with:
          node-version20
      - runnpm ci
      - runnpx playwright install chromium --with-deps
      - runnpx playwright test --shard=${{ matrix.shard }}
      - usesactions/upload-artifact@v4
        ifalways()
        with:
          nameplaywright-report-${{ strategy.job-index }}
          pathplaywright-report/

Pro Tip: Only install the browsers you need on CI. Replace npx playwright install --with-deps (installs all browsers) with npx playwright install chromium --with-deps to save download time and disk space.

12. Eliminate flaky tests systematically

Flaky tests erode trust. When tests randomly fail, the team starts ignoring failures, and real bugs slip through. The top causes and their fixes:

The top 5 causes of flakiness (and their fixes)

  1.  Missing await on async actions
    example.spec.ts
    // ❌ Bad: Missing await — action executes in background
    page.getByRole('button', { name'Subscribe' }).click();
    await expect(page.getByText('Success')).toBeVisible(); // May fail if click hasn't completed
    // ✅ Good: Always await actions await page.getByRole('button', { name'Subscribe' }).click();
    await expect(page.getByText('Success')).toBeVisible();
    Fix: Run eslint with @typescript-eslint/no-floating-promises to catch these automatically.
  2.  Manual waits instead of web-first assertions
    example.spec.ts
    // ❌ Bad: Fixed timeout — doesn't adapt to network speed
    await page.waitForTimeout(2000);
    await expect(page.getByText('Loaded')).toBeVisible();


    // ✅ Good: Web-first assertion — waits as long as needed up to timeout
    await expect(page.getByText('Loaded')).toBeVisible();
    Fix: Replace all waitForTimeout() with web-first assertions or waitForResponse().
  3.  Clicking hidden or disabled elements
    example.spec.ts
    // ❌ Bad: Element is hidden or disabled, click fails sometimes
    await page.locator('.submit-btn').click();

    // ✅ Good: Playwright auto-checks visibility and enabled state
    await page.getByRole('button', { name'Subscribe' }).click();
    Fix: Use role-based locators. Playwright automatically waits for visibility and enabled state.
  4. Non-deterministic test data
    example.spec.ts
    // ❌ Bad: Test email is the same every time. Second run fails because user already exists
    test('signup'async ({ page }) => {
      await page.getByPlaceholder('Your Email').fill('[email protected]');
    });

    // ✅ Good: Generate unique test data
    test('signup'async ({ page }) => {
      const email = `test-${Date.now()}@example.com`;
      await page.getByPlaceholder('Your Email').fill(email);
    });
    Fix: Use timestamps, UUIDs, or random strings to generate unique test data.
  5. Interdependent tests
    example.spec.ts
    // ❌ Bad: Test 2 depends on Test 1 passing first
    test('create item'async ({ page }) => {
      // Create item, store ID globally
    });

    test('update item'async ({ page }) => {
      // Uses the item ID from test 1 — fails if test 1 was skipped
    });

    // ✅ Good: Each test is independent
    test.beforeEach(async ({ request }) => {
      // Create item for this test only
      testItemId = await request.post('/api/items');
    });

    test('update item'async ({ page }) => {
      // Uses testItemId from beforeEach — always available
    });

    Fix: Use beforeEach hooks and API seeding to set up data per test, not globally.

Detect and isolate flaky tests

terminal
# Run the same test 10 times to see if it flakes
npx playwright test login.spec.ts --repeat-each 10

Pro Tip: When you fix a flaky test, add it to a low-flakiness test suite that you run more often. This signals to the team that the test is stable and can be trusted.

13. Test across browsers and devices

Playwright supports Chromium, Firefox, and WebKit out of the box. Use analytics to guide your browser matrix. If 80% of users are on Chrome and 15% on Safari, prioritize those.

playwright.config.ts
export default defineConfig({
  projects: [
    { name'chromium'use: { ...devices['Desktop Chrome'] } },
    { name'firefox'use: { ...devices['Desktop Firefox'] } },
    { name'webkit'use: { ...devices['Desktop Safari'] } },
    { name'mobile-chrome'use: { ...devices['Pixel 7'] } },
    { name'mobile-safari'use: { ...devices['iPhone 14'] } },
  ],
});

14. Use Playwright AI to generate and heal tests

Playwright 1.56 introduced 3 built-in AI agents:

Planner explores your app and produces a structured test plan in markdown. Generator converts that plan into executable Playwright test files. Healer runs failing tests, inspects the current UI, and patches them automatically.

terminal
# Initialize agent definitions for your preferred AI tool
npx playwright init-agents --loop=vscode      # VS Code + Copilot
npx playwright init-agents --loop=claude       # Claude Code

Playwright CLI is a command-line interface for snapshot-based browser automation. Microsoft built it for AI coding agents because it's ~4x more token-efficient than MCP.

terminal
# Open a page and snapshot it
playwright-cli open https://your-app.com
playwright-cli snapshot

# Interact using element references from the snapshot
playwright-cli click e15
playwright-cli fill e5 "[email protected]"
playwright-cli press Enter
playwright-cli screenshot

Playwright MCP is an MCP server providing real-time accessibility snapshots for AI agents that need continuous page state. Use it when:

  • Real-time AI agent interactions with live web pages

  • When the AI agent needs a continuous, up-to-date page state

  • Complex multi-step workflows where the agent needs to react to dynamic content

  • Integration with AI tools that support MCP natively (Claude Desktop, Cursor, etc.)

Scenario

Use This

Why

AI agent needs to browse a website

Playwright CLI

Most token-efficient, snapshot-based

AI agent needs real-time page state

Playwright MCP

Continuous accessibility tree access

Generate a full test suite

Test Agents

Planner → Generator → Healer pipeline

Debug and fix a failing test

Test Agents (Healer)

Auto-inspects UI and patches tests

Record a user flow for test creation

Playwright CLI

Built-in test generation from recordings

AI-powered exploratory testing

Playwright MCP

Real-time interaction and observation

15. Give your AI agent Playwright Skills

AI agents write decent Playwright tests out of the box. But they fall apart on real-world sites. Wrong selectors, broken auth flows, flaky CI runs. Agents don't have context about battle-tested patterns.

The Playwright Skill by TestDino contains 70+ guides organized into 5 skill packs: core, playwright-cli, pom, ci, and migration.

terminal
# Install all 70+ guides
npx skills add testdino-hq/playwright-skill

# Or install individual packs
npx skills add testdino-hq/playwright-skill/core
npx skills add testdino-hq/playwright-skill/ci
npx skills add testdino-hq/playwright-skill/playwright-cli

Without the Skill, an AI agent generates tutorial-quality code with CSS selectors and no assertions. With the Skill loaded, the same agent uses role-based locators, proper assertions, and patterns from authentication.md.

The Skill works with Claude Code, GitHub Copilot, Cursor, and Windsurf. It's MIT licensed. Fork it, customize it for your team, and share it internally.

16. Centralize reporting for CI

Playwright's built-in HTML reporter is excellent for local development. But on CI, the report disappears after the pipeline finishes. Your team can't see it or compare against last week's run.

playwright.config.ts
export default defineConfig({
  reporter: [
    ['html', { open'never' }],
    ['json', { outputFile'results.json' }],
    ['list'],
  ],
  retriesprocess.env.CI2 : 0,
  use: {
    trace'on-first-retry',
    screenshot'only-on-failure',
    video'retain-on-failure',
  },
});

For teams at scale, tools like TestDino provide AI-powered failure categorization that labels each failure as Bug, Flaky, or UI Change. Your team spends time fixing real issues instead of triaging noise.

17. Lint, type-check, and stay updated

These 3 practices are boring but they prevent an entire category of bugs.

Lint your tests to catch missing await

The most common Playwright bug is a missing await. Without it, assertions execute synchronously and pass without actually checking anything.

terminal
npm install -D @typescript-eslint/eslint-plugin

.eslintrc.json
// .eslintrc.json
{
  "rules": {
    "@typescript-eslint/no-floating-promises""error"
  }
}

This catches:

example.spec.ts
// ❌ ESLint error: Missing await — this assertion doesn't actually wait
expect(page.getByText('Success')).toBeVisible();

// ✅ Fixed
await expect(page.getByText('Success')).toBeVisible();

Type-check your tests on CI

Add a type-check step to your CI pipeline:

.github/workflows/playwright.yml
nameType check
  runnpx tsc --noEmit

TypeScript catches wrong argument types, missing properties, and invalid method calls before the tests even run.

Keep Playwright updated

New Playwright versions ship with updated browser binaries that match the latest browser releases. This means you catch browser-specific bugs before your users do.

terminal
# Update Playwright
npm install -D @playwright/test@latest
# Install updated browsers

npx playwright install
# Check your current version

npx playwright --version

Check the release notes with every update. New versions often add features that simplify your tests — like new locator methods, better trace viewer capabilities, or improved agent tools.

Quick reference cheat sheet

Practice

Command / Pattern

Generate locators

npx playwright codegen https://your-app.com

Debug a specific test

npx playwright test login.spec.ts:15 --debug

Run in UI Mode

npx playwright test --ui

Capture traces

npx playwright test --trace on

View HTML report

npx playwright show-report

Shard tests

npx playwright test --shard=1/4

Install only Chromium on CI

npx playwright install chromium --with-deps

Initialize test agents

npx playwright init-agents --loop=vscode

Seed test data via API

await request.post('/api/test/seed', { data })

Install Playwright Skills

npx skills add testdino-hq/playwright-skill

Update Playwright

npm install -D @playwright/test@latest

Conclusion

Playwright in 2026 is a full platform: built-in AI agents, MCP servers, a token-efficient CLI, and an ecosystem of skills that make AI-generated tests production-ready.

The core practices haven't changed: use resilient locators, isolate your tests, write web-first assertions, and run on CI. What's changed is the tooling around them. Start with the fundamentals (practices 1-9), adopt the AI-native practices (10-15), and build the infrastructure to sustain it all (16-17).

Jashn Jain

Product & Growth Engineer

Jashn Jain is a Product and Growth Engineer at TestDino, focusing on automation strategy, developer tooling, and applied AI in testing. Her work involves shaping Playwright based workflows and creating practical resources that help engineering teams adopt modern automation practices.

She contributes through product education and research, including presentations at CNR NANOTEC and publications in ACL Anthology, where her work examines explainability and multimodal model evaluation.

Get started fast

Step-by-step guides, real-world examples, and proven strategies to maximize your test reporting success