Test Playwright Patterns
Write maintainable Playwright tests using page objects, fixtures, and parallel execution
When to Use
- Writing E2E tests that remain maintainable as the application grows
- Abstracting page interactions into reusable page objects
- Testing complex user flows (multi-step forms, navigation, authentication)
- Debugging flaky tests and improving test reliability
Instructions
- Page Object Model — encapsulate page interactions:
// e2e/pages/login-page.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Log 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();
}
}
- Use locators — prefer accessible selectors:
// Best — accessible
page.getByRole('button', { name: 'Submit' });
page.getByLabel('Email address');
page.getByText('Welcome back');
page.getByPlaceholder('Search...');
// Acceptable — test IDs
page.getByTestId('user-avatar');
// Avoid — brittle
page.locator('.btn-primary');
page.locator('#submit-form');
page.locator('div > span:nth-child(2)');
- Test user flows, not individual elements:
import { test, expect } from '@playwright/test';
test('user can create and publish a post', async ({ page }) => {
await page.goto('/posts/new');
await page.getByLabel('Title').fill('My First Post');
await page.getByLabel('Content').fill('Hello, world!');
await page.getByRole('button', { name: 'Save draft' }).click();
await expect(page.getByText('Draft saved')).toBeVisible();
await page.getByRole('button', { name: 'Publish' }).click();
await expect(page).toHaveURL(/\/posts\/[\w-]+$/);
await expect(page.getByRole('heading', { name: 'My First Post' })).toBeVisible();
});
- Wait for network and state — use auto-waiting:
// Playwright auto-waits for elements to be actionable
await page.getByRole('button', { name: 'Submit' }).click(); // Waits for button to be enabled
// Wait for navigation
await Promise.all([
page.waitForURL('/dashboard'),
page.getByRole('button', { name: 'Log in' }).click(),
]);
// Wait for API response
const responsePromise = page.waitForResponse('/api/users');
await page.getByRole('button', { name: 'Load users' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
- Handle dialogs and popups:
page.on('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Item deleted')).toBeVisible();
- Visual regression with screenshots:
test('homepage matches snapshot', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100,
});
});
- Parallel test isolation:
// Each test gets its own browser context
test('user A sees their data', async ({ page }) => {
/* ... */
});
test('user B sees their data', async ({ page }) => {
/* ... */
});
// These run in parallel without interference
- Tag and filter tests:
test('critical: checkout flow', { tag: '@critical' }, async ({ page }) => {
// ...
});
// Run only critical tests
// npx playwright test --grep @critical
Details
Playwright tests run against real browsers with full DOM, network, and JavaScript execution. They are the closest thing to manual testing that can be automated.
Auto-waiting: Playwright automatically waits for elements to be visible, enabled, and stable before interacting. This eliminates most explicit waits and reduces flakiness compared to Selenium or Cypress.
Locator best practices:
getByRole— always first choice. Forces accessible markupgetByLabel— for form inputs, anchored to their labelgetByText— for visible text contentgetByTestId— when no accessible name exists. Adddata-testidattributes- CSS/XPath — last resort, only for complex DOM structures
Debugging tools:
npx playwright test --ui— interactive test runner with time-travel debuggingnpx playwright test --debug— step through with browser DevToolsnpx playwright show-trace trace.zip— replay recorded traces
Handling flakiness:
- Use locators (auto-waiting) instead of explicit waits
- Wait for specific conditions, not arbitrary timeouts
- Ensure test data isolation (each test creates its own data)
- Use
retries: 2in CI as a safety net, but investigate repeated flakes
Trade-offs:
- Page objects improve maintainability — but add abstraction layers
- Parallel execution speeds up suites — but requires data isolation between tests
- Visual regression catches CSS bugs — but screenshot diffs are sensitive to rendering differences across environments
- Multi-browser testing improves confidence — but triples CI time
Source
https://playwright.dev/docs/test-patterns
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.