Playwright Best Practices
CLI Context: Prevent Context Overflow
When running Playwright tests from Claude Code or any CLI agent, always use minimal reporters to prevent verbose output from consuming the context window.
Use --reporter=line or --reporter=dot for CLI test runs:
REQUIRED: Use minimal reporter to prevent context overflow
npx playwright test --reporter=line npx playwright test --reporter=dot
BAD: Default reporter generates thousands of lines, floods context
npx playwright test
Configure playwright.config.ts to use minimal reporters by default when CI or CLAUDE env vars are set:
reporter: process.env.CI || process.env.CLAUDE ? [['line'], ['html', { open: 'never' }]] : 'list',
Locator Priority (Most to Least Resilient)
Always prefer user-facing attributes:
-
page.getByRole('button', { name: 'Submit' })
-
accessibility roles
-
page.getByLabel('Email')
-
form control labels
-
page.getByPlaceholder('Search...')
-
input placeholders
-
page.getByText('Welcome')
-
visible text (non-interactive)
-
page.getByAltText('Logo')
-
image alt text
-
page.getByTitle('Settings')
-
title attributes
-
page.getByTestId('submit-btn')
-
explicit test contracts
-
CSS/XPath - last resort, avoid
// BAD: Brittle selectors tied to implementation page.locator('button.btn-primary.submit-form') page.locator('//div[@class="container"]/form/button') page.locator('#app > div:nth-child(2) > button')
// GOOD: User-facing, resilient locators page.getByRole('button', { name: 'Submit' }) page.getByLabel('Password')
Chaining and Filtering
// Scope within a region const card = page.getByRole('listitem').filter({ hasText: 'Product A' }); await card.getByRole('button', { name: 'Add to cart' }).click();
// Filter by child locator const row = page.getByRole('row').filter({ has: page.getByRole('cell', { name: 'John' }) });
// Combine conditions const visibleSubmit = page.getByRole('button', { name: 'Submit' }).and(page.locator(':visible')); const primaryOrSecondary = page.getByRole('button', { name: 'Save' }).or(page.getByRole('button', { name: 'Update' }));
Strictness
Locators throw if multiple elements match. Use first() , last() , nth() only when intentional:
// Throws if multiple buttons match await page.getByRole('button', { name: 'Delete' }).click();
// Explicit selection when needed await page.getByRole('listitem').first().click(); await page.getByRole('row').nth(2).getByRole('button').click();
Web-First Assertions
Use async assertions that auto-wait and retry:
// BAD: No auto-wait, flaky expect(await page.getByText('Success').isVisible()).toBe(true);
// GOOD: Auto-waits up to timeout await expect(page.getByText('Success')).toBeVisible(); await expect(page.getByRole('button')).toBeEnabled(); await expect(page.getByTestId('status')).toHaveText('Submitted'); await expect(page).toHaveURL(/dashboard/); await expect(page).toHaveTitle('Dashboard');
// Collections await expect(page.getByRole('listitem')).toHaveCount(5); await expect(page.getByRole('listitem')).toHaveText(['Item 1', 'Item 2', 'Item 3']);
// Soft assertions (continue on failure, report all) await expect.soft(locator).toBeVisible(); await expect.soft(locator).toHaveText('Expected'); // Test continues, failures compiled at end
Page Object Model
Encapsulate page interactions. Define locators as readonly properties in constructor.
// pages/base.page.ts import { type Page, type Locator, expect } from '@playwright/test'; import debug from 'debug';
export abstract class BasePage { protected readonly log: debug.Debugger;
constructor(
protected readonly page: Page,
protected readonly timeout = 30_000
) {
this.log = debug(test:page:${this.constructor.name});
}
protected async safeClick(locator: Locator, description?: string) { this.log('clicking: %s', description ?? locator); await expect(locator).toBeVisible({ timeout: this.timeout }); await expect(locator).toBeEnabled({ timeout: this.timeout }); await locator.click(); }
protected async safeFill(locator: Locator, value: string) { await expect(locator).toBeVisible({ timeout: this.timeout }); await locator.fill(value); }
abstract isLoaded(): Promise<void>; }
// pages/login.page.ts import { type Locator, type Page, expect } from '@playwright/test'; import { BasePage } from './base.page';
export class LoginPage extends BasePage { readonly emailInput: Locator; readonly passwordInput: Locator; readonly submitButton: Locator; readonly errorMessage: Locator;
constructor(page: Page) { super(page); this.emailInput = page.getByLabel('Email'); this.passwordInput = page.getByLabel('Password'); this.submitButton = page.getByRole('button', { name: 'Sign in' }); this.errorMessage = page.getByRole('alert'); }
async goto() { await this.page.goto('/login'); await this.isLoaded(); }
async isLoaded() { await expect(this.emailInput).toBeVisible(); }
async login(email: string, password: string) { await this.safeFill(this.emailInput, email); await this.safeFill(this.passwordInput, password); await this.safeClick(this.submitButton, 'Sign in button'); }
async expectError(message: string) { await expect(this.errorMessage).toHaveText(message); } }
Fixtures
Prefer fixtures over beforeEach/afterEach. Fixtures encapsulate setup + teardown, run on-demand, and compose with dependencies.
// fixtures/index.ts import { test as base, expect } from '@playwright/test'; import { LoginPage } from '../pages/login.page'; import { DashboardPage } from '../pages/dashboard.page';
type TestFixtures = { loginPage: LoginPage; dashboardPage: DashboardPage; };
export const test = base.extend<TestFixtures>({ loginPage: async ({ page }, use) => { const loginPage = new LoginPage(page); await loginPage.goto(); await use(loginPage); },
dashboardPage: async ({ page }, use) => { await use(new DashboardPage(page)); }, });
export { expect };
Worker-Scoped Fixtures
Use for expensive setup shared across tests (database connections, authenticated users):
// fixtures/auth.fixture.ts import { test as base } from '@playwright/test';
type WorkerFixtures = { authenticatedUser: { token: string; userId: string }; };
export const test = base.extend<{}, WorkerFixtures>({ authenticatedUser: [async ({}, use) => { // Expensive setup - runs once per worker const user = await createTestUser(); const token = await authenticateUser(user);
await use({ token, userId: user.id });
// Cleanup after all tests in worker
await deleteTestUser(user.id);
}, { scope: 'worker' }], });
Automatic Fixtures
Run for every test without explicit declaration:
export const test = base.extend<{ autoLog: void }>({
autoLog: [async ({ page }, use) => {
page.on('console', msg => console.log([browser] ${msg.text()}));
await use();
}, { auto: true }],
});
Authentication
Save authenticated state to reuse. Never log in via UI in every test.
// auth.setup.ts import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => { await page.goto('/login'); 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: 'Sign in' }).click(); await page.waitForURL('/dashboard'); await page.context().storageState({ path: authFile }); });
// playwright.config.ts export default defineConfig({ projects: [ { name: 'setup', testMatch: /.*.setup.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json', }, dependencies: ['setup'], }, ], });
API Authentication (Faster)
setup('authenticate via API', async ({ request }) => { const response = await request.post('/api/auth/login', { data: { email: process.env.TEST_USER_EMAIL, password: process.env.TEST_USER_PASSWORD }, }); expect(response.ok()).toBeTruthy(); await request.storageState({ path: authFile }); });
Network Mocking
Set up routes before navigation.
test('displays mocked data', async ({ page }) => { await page.route('**/api/users', route => route.fulfill({ json: [{ id: 1, name: 'Test User' }], }));
await page.goto('/users'); await expect(page.getByText('Test User')).toBeVisible(); });
// Modify real response test('injects item into response', async ({ page }) => { await page.route('**/api/items', async route => { const response = await route.fetch(); const json = await response.json(); json.push({ id: 999, name: 'Injected' }); await route.fulfill({ response, json }); }); await page.goto('/items'); });
// HAR recording test('uses recorded responses', async ({ page }) => { await page.routeFromHAR('./fixtures/api.har', { url: '/api/', update: false, // true to record }); await page.goto('/'); });
Test Isolation
Each test gets fresh browser context. Never share state between tests.
// BAD: Tests depend on each other
let userId: string;
test('create user', async ({ request }) => {
userId = (await (await request.post('/api/users', { data: { name: 'Test' } })).json()).id;
});
test('delete user', async ({ request }) => {
await request.delete(/api/users/${userId}); // Depends on previous!
});
// GOOD: Each test creates its own data
test('can delete created user', async ({ request }) => {
const { id } = await (await request.post('/api/users', { data: { name: 'Test' } })).json();
const deleteResponse = await request.delete(/api/users/${id});
expect(deleteResponse.ok()).toBeTruthy();
});
Configuration
// playwright.config.ts import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, // Use minimal reporter in CI/agent contexts to prevent context overflow reporter: process.env.CI || process.env.CLAUDE ? [['line'], ['html', { open: 'never' }]] : 'list',
use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'on-first-retry', },
projects: [ { name: 'setup', testMatch: /.*.setup.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'] }, dependencies: ['setup'], }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, dependencies: ['setup'], }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, dependencies: ['setup'], }, ],
webServer: { command: 'npm run start', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, });
Project Structure
tests/ fixtures/ # Custom fixtures (extend base test) pages/ # Page Object Models helpers/ # Utility functions (API clients, data factories) auth.setup.ts # Authentication setup project *.spec.ts # Test files playwright/ .auth/ # Auth state storage (gitignored) playwright.config.ts
Organize tests by feature or user journey. Colocate page objects with tests when possible.
Helpers (Separate from Pages)
// helpers/user.helper.ts import type { Page } from '@playwright/test'; import debug from 'debug';
const log = debug('test:helper:user');
export class UserHelper { constructor(private page: Page) {}
async createUser(data: { name: string; email: string }) { log('creating user: %s', data.email); const response = await this.page.request.post('/api/users', { data }); return response.json(); }
async deleteUser(id: string) {
log('deleting user: %s', id);
await this.page.request.delete(/api/users/${id});
}
}
// helpers/data.factory.ts
export function createTestUser(overrides: Partial<User> = {}): User {
return {
id: crypto.randomUUID(),
email: test-${Date.now()}@example.com,
name: 'Test User',
...overrides,
};
}
Debugging
npx playwright test --debug # Step through with inspector npx playwright test --trace on # Record trace for all tests npx playwright test --ui # Interactive UI mode npx playwright codegen localhost:3000 # Generate locators interactively npx playwright show-report # View HTML report
Enable debug logs: DEBUG=test:* npx playwright test
Anti-Patterns
-
page.waitForTimeout(ms)
-
use auto-waiting locators instead
-
page.locator('.class')
-
use role/label/testid
-
XPath selectors - fragile, use user-facing attributes
-
Shared state between tests - each test creates own data
-
UI login in every test - use setup project + storageState
-
Manual assertions without await - use web-first assertions
-
Hardcoded waits - rely on Playwright's auto-waiting
-
Default reporter in CI/agent - use --reporter=line or --reporter=dot to prevent context overflow
Checklist
-
Locators use role/label/testid, not CSS classes or XPath
-
All assertions use await expect() web-first matchers
-
Page objects define locators in constructor
-
No page.waitForTimeout()
-
use auto-waiting
-
Tests isolated - no shared state
-
Auth state reused via setup project
-
Network mocks set up before navigation
-
Test data created per-test or via fixtures
-
Debug logging added for complex flows
-
Minimal reporter (line /dot ) used in CI/agent contexts