Skip to main content
Ganesh Joshi
Back to Blogs

Playwright testing best practices: reliable E2E tests

February 21, 20265 min read
Tips
Playwright or E2E test code on screen

Playwright is a powerful framework for end-to-end testing, but power comes with responsibility. Flaky tests, slow runs, and maintenance burden can undermine confidence in your test suite. This guide covers practices that keep Playwright tests reliable, fast, and maintainable.

Selector strategy

Good selectors are resilient to UI changes while accurately targeting elements.

Priority order

Follow this selector hierarchy:

Priority Selector type Example
1 Role getByRole('button', { name: 'Submit' })
2 Label getByLabel('Email address')
3 Text getByText('Welcome back')
4 Placeholder getByPlaceholder('Enter your email')
5 Test ID getByTestId('submit-button')
6 CSS locator('.submit-btn') (avoid)

Role-based selectors

Role selectors match how users and assistive technologies interact:

// Good: semantic and resilient
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('link', { name: 'View details' }).click();

// Bad: tied to implementation
await page.locator('.btn-primary.add-cart').click();
await page.locator('input[type="email"]').fill('user@example.com');

Test IDs for ambiguous cases

When semantic selectors don't work, use data-testid:

// Component
<div data-testid="product-card-123">
  <h3>Product Name</h3>
  <button data-testid="add-to-cart-123">Add to Cart</button>
</div>

// Test
await page.getByTestId('add-to-cart-123').click();

Don't overuse test IDs. They add noise to markup and don't verify accessibility.

Auto-waiting

Playwright waits automatically for elements to be actionable. Trust it.

Let auto-wait work

// Good: auto-wait handles timing
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Success')).toBeVisible();

// Bad: arbitrary waits
await page.waitForTimeout(2000); // Never do this
await page.getByRole('button', { name: 'Submit' }).click();

Custom wait conditions

For complex conditions, use waitFor:

// Wait for specific condition
await page.getByTestId('data-table').waitFor({ state: 'visible' });

// Wait for network idle
await page.waitForLoadState('networkidle');

// Wait for custom condition
await expect(async () => {
  const count = await page.getByTestId('item').count();
  expect(count).toBeGreaterThan(0);
}).toPass();

Avoid explicit timeouts

If you need waitForTimeout, something is wrong:

Symptom Real fix
Element not ready Use proper locator with auto-wait
Animation Wait for animation to complete with custom condition
API response Mock the API or wait for specific UI state
Flaky test Investigate root cause, don't mask with timeout

Test isolation

Each test should be independent. No shared state, no order dependencies.

Use fixtures for setup

// fixtures.ts
import { test as base } from '@playwright/test';

type Fixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<Fixtures>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('test@example.com');
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page.getByText('Dashboard')).toBeVisible();
    await use(page);
  },
});

// test file
test('can view profile', async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/profile');
  await expect(authenticatedPage.getByRole('heading', { name: 'Profile' })).toBeVisible();
});

Reuse authentication state

Avoid logging in for every test:

// global-setup.ts
import { chromium } from '@playwright/test';

async function globalSetup() {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto('http://localhost:3000/login');
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/dashboard');

  await page.context().storageState({ path: 'auth.json' });
  await browser.close();
}

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

Tests start already authenticated.

Page Object Model

Encapsulate page interactions for maintainability:

// page-objects/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

// test
test('can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password');
  await expect(page.getByText('Dashboard')).toBeVisible();
});

Assertions

Use web-first assertions that auto-retry:

// Good: auto-retrying assertions
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('heading')).toHaveText('Welcome');
await expect(page.getByTestId('items')).toHaveCount(5);

// Bad: snapshot assertions without retry
const text = await page.getByRole('heading').textContent();
expect(text).toBe('Welcome'); // No retry, can be flaky

Soft assertions

Continue test after failure to gather more info:

await expect.soft(page.getByTestId('name')).toHaveText('John');
await expect.soft(page.getByTestId('email')).toHaveText('john@example.com');
await expect.soft(page.getByTestId('role')).toHaveText('Admin');
// All assertions run, failures collected

Parallel execution

Playwright runs tests in parallel by default. Ensure isolation:

// playwright.config.ts
export default defineConfig({
  workers: process.env.CI ? 4 : undefined, // 4 workers in CI
  fullyParallel: true, // Run all tests in parallel
});

Avoiding conflicts

// Bad: tests conflict on shared resource
test('creates user', async ({ page }) => {
  await createUser('john@example.com');
});
test('deletes user', async ({ page }) => {
  await deleteUser('john@example.com'); // Might run first!
});

// Good: each test uses unique data
test('creates user', async ({ page }) => {
  const email = `user-${Date.now()}@example.com`;
  await createUser(email);
});

Debugging

Trace viewer

Enable traces for debugging failures:

// playwright.config.ts
export default defineConfig({
  use: {
    trace: 'on-first-retry', // Capture trace on retry
  },
});

View with:

npx playwright show-trace trace.zip

UI mode

Run tests interactively:

npx playwright test --ui

Watch mode, time travel debugging, and step-through execution.

Console and network

Capture browser console and network:

test('debug network', async ({ page }) => {
  page.on('console', (msg) => console.log('CONSOLE:', msg.text()));
  page.on('response', (response) => console.log('RESPONSE:', response.url()));

  await page.goto('/');
});

CI configuration

# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  test:
    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: npm run build
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Sharding for speed

Split tests across machines:

jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - run: npx playwright test --shard=${{ matrix.shard }}/4

Common patterns

Pattern Implementation
Wait for API page.waitForResponse('/api/users')
Mock API page.route('/api/*', handler)
Test mobile Use projects with different viewports
Screenshot comparison expect(page).toHaveScreenshot()
File upload page.setInputFiles('input[type=file]', 'path/to/file')

Summary

Write reliable Playwright tests by using semantic selectors, trusting auto-wait, isolating tests with fixtures, and reusing authentication state. Use Page Object Model for maintainability in large suites. Run tests in parallel with proper isolation. Debug with traces and UI mode. In CI, shard tests for speed and upload reports on failure.

Frequently Asked Questions

Playwright is a browser automation framework by Microsoft for end-to-end testing. It supports Chromium, Firefox, and WebKit, has built-in auto-waiting, and runs tests in parallel across browsers.

Use getByRole, getByLabel, getByText for accessibility-based selectors. Use getByTestId for ambiguous cases. Avoid CSS selectors tied to implementation details like class names.

Common causes: not using auto-wait properly, adding manual timeouts, brittle selectors, shared state between tests, and race conditions. Use Playwright's built-in waiting and isolate each test.

Run tests in parallel with multiple workers, use test isolation to avoid sequential dependencies, reuse authentication state with storageState, and consider sharding across CI machines.

POM helps for large test suites by encapsulating page interactions. For smaller suites, inline locators are fine. Use fixtures for shared setup like authentication.

Related Posts