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.
