| name | playwright-patterns |
| description | Use when writing Playwright automation code, building web scrapers, or creating E2E tests - provides best practices for selector strategies, waiting patterns, and robust automation that minimizes flakiness |
Playwright Automation Patterns
Overview
Reliable browser automation requires strategic selector choice, proper waiting, and defensive coding. This skill provides patterns that minimize test flakiness and maximize maintainability.
When to Use
- Writing new Playwright scripts or tests
- Debugging flaky automation
- Refactoring unreliable selectors
- Building web scrapers that need to handle dynamic content
- Creating E2E tests that must be maintainable
When NOT to use:
- Simple one-time browser tasks
- When you need Playwright API documentation (use context7 MCP)
Selector Strategy
Priority Order
Use user-facing locators first (most resilient), then test IDs, then CSS/XPath as last resort:
-
Role-based locators (best - user-centric)
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com');
-
Other user-facing locators
await page.getByLabel('Password').fill('secret');
await page.getByPlaceholder('Search...').fill('query');
await page.getByText('Submit Order').click();
-
Test ID attributes (explicit contract)
await page.getByTestId('submit-button').click();
-
CSS/ID selectors (fragile, avoid if possible)
await page.locator('#submit-btn').click();
await page.locator('.btn.btn-primary.submit').click();
Strictness and Specificity
Locators are strict by default - operations throw if multiple elements match:
await page.getByRole('button').click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('button')
.filter({ hasText: 'Submit' })
.click();
await page.locator('.product-card')
.getByRole('button', { name: 'Add to cart' })
.click();
await page.getByRole('button').first().click();
Locator Filtering and Chaining
await page.getByRole('listitem')
.filter({ hasText: 'Product 2' })
.getByRole('button')
.click();
await page.getByRole('listitem')
.filter({ has: page.getByRole('heading', { name: 'Product 2' }) })
.getByRole('button', { name: 'Buy' })
.click();
await expect(
page.getByRole('listitem')
.filter({ hasNot: page.getByText('Out of stock') })
).toHaveCount(5);
const loginOrWelcome = await page.getByRole('button', { name: 'Login' })
.or(page.getByText('Welcome back'))
.first();
await expect(loginOrWelcome).toBeVisible();
Anti-Patterns to Avoid
❌ Fragile CSS paths
await page.click('div.container > div:nth-child(2) > button.submit');
✅ Stable semantic selectors
await page.getByRole('button', { name: 'Submit' }).click();
❌ XPath with positions
await page.locator('xpath=//div[3]/button[1]').click();
✅ XPath with content
await page.locator('xpath=//button[contains(text(), "Submit")]').click();
Waiting Patterns
Built-in Auto-Waiting
Playwright auto-waits before most actions. Trust it.
await page.click('button');
await page.fill('input[name="email"]', 'test@example.com');
What auto-waiting checks:
- Element is attached to DOM
- Element is visible
- Element is stable (not animating)
- Element is enabled
- Element receives events (not obscured)
await page.click('button', { force: true });
await page.click('button', { trial: true });
Web-First Assertions
Use web-first assertions - they retry until condition is met:
expect(await page.getByText('welcome').isVisible()).toBe(true);
await expect(page.getByText('welcome')).toBeVisible();
await expect(page.getByText('Status')).toHaveText('Complete');
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect.soft(page.getByTestId('status')).toHaveText('Success');
await page.getByRole('link', { name: 'next' }).click();
Explicit Waits for Dynamic Content
await expect(page.locator('.results-loaded')).toBeVisible();
await page.waitForLoadState('networkidle');
await page.waitForFunction(() =>
document.querySelectorAll('.item').length > 10
);
Handling Asynchronous Updates
await expect(page.locator('.item')).toHaveCount(5);
await expect(page.locator('.search-results')).toBeVisible();
const items = await page.locator('.item').all();
await expect(page.locator('.loading-spinner')).not.toBeVisible();
await expect(page.locator('.results')).toBeVisible();
await expect(page.locator('.status')).toHaveText('Complete');
await expect(page.locator('.item').first()).toBeVisible();
Data Extraction Patterns
Single Element
const title = await page.locator('h1').textContent();
const price = await page.locator('.price').innerText();
const href = await page.locator('a.product').getAttribute('href');
await expect(page.locator('.price')).toHaveText('$99');
Multiple Elements
await expect(page.locator('.item')).toHaveCount(5);
const items = await page.locator('.item').all();
const data = await Promise.all(
items.map(async item => ({
title: await item.locator('.title').textContent(),
price: await item.locator('.price').textContent(),
}))
);
await expect(page.locator('.results-container')).toBeVisible();
const data = await page.locator('.item').evaluateAll(items =>
items.map(el => ({
title: el.querySelector('.title')?.textContent?.trim(),
price: el.querySelector('.price')?.textContent?.trim(),
}))
);
const data = await page.locator('.item').evaluateAll(items =>
items.map(el => ({
title: el.querySelector('.title')?.textContent?.trim(),
price: el.querySelector('.price')?.textContent?.trim(),
}))
);
Complex Extraction with evaluate()
const data = await page.evaluate(() => {
return {
items: Array.from(document.querySelectorAll('.item')).map(el => ({
title: el.querySelector('.title')?.textContent?.trim(),
price: el.querySelector('.price')?.textContent?.trim(),
url: el.querySelector('a')?.href,
available: !el.classList.contains('out-of-stock')
})),
totalCount: window.productCount,
filters: window.appliedFilters
};
});
const items = await page.locator('.item').evaluateAll(els =>
els.map(el => ({ }))
);
Error Handling
Graceful Fallbacks
const cookieBanner = page.locator('.cookie-banner');
if (await cookieBanner.isVisible()) {
await cookieBanner.getByRole('button', { name: 'Accept' }).click();
}
Retry Logic
await expect(async () => {
const status = await page.locator('.status').textContent();
expect(status).toBe('Complete');
}).toPass({ timeout: 10000, intervals: [1000] });
Timeout Configuration
await page.click('button', { timeout: 5000 });
test.setTimeout(60000);
page.setDefaultTimeout(10000);
Navigation Patterns
Wait for Navigation
await page.click('a.next-page');
await page.waitForLoadState('networkidle');
await page.getByRole('link', { name: 'Next Page' }).click();
Multi-Page Workflows
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.click('a[target="_blank"]')
]);
await newPage.waitForLoadState();
await newPage.close();
Form Interaction Patterns
Basic Form Filling
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'secret123');
await page.locator('input.search').type('Product', { delay: 100 });
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('combobox', { name: 'Country' }).selectOption('US');
await page.getByRole('checkbox', { name: 'I agree' }).check();
await page.getByRole('button', { name: 'Submit' }).click();
File Uploads
await page.setInputFiles('input[type="file"]', '/path/to/file.pdf');
await page.setInputFiles('input[type="file"]', [
'/path/to/file1.pdf',
'/path/to/file2.pdf'
]);
Autocomplete/Search Inputs
await page.getByPlaceholder('Search products').fill('Product Name');
await expect(page.locator('.suggestions')).toBeVisible();
await page.getByRole('option', { name: 'Product Name - Premium' }).click();
await page.locator('.suggestions')
.getByText('Product Name', { exact: false })
.first()
.click();
Screenshot and Debugging
Strategic Screenshots
await page.screenshot({ path: 'screenshot.png', fullPage: true });
await page.locator('.chart').screenshot({ path: 'chart.png' });
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
await page.screenshot({
path: `failure-${testInfo.title}.png`,
fullPage: true
});
}
});
Debug Mode
await page.pause();
const browser = await chromium.launch({ slowMo: 1000 });
Common Patterns Reference
| Task | Pattern |
|---|
| Click button | await page.getByRole('button', { name: 'Text' }).click() |
| Fill input | await page.getByLabel('Field').fill('value') |
| Select option | await page.getByRole('combobox').selectOption('value') |
| Check checkbox | await page.getByRole('checkbox', { name: 'Label' }).check() |
| Wait for element | await expect(page.locator('.el')).toBeVisible() |
| Assert text | await expect(page.locator('.el')).toHaveText('text') |
| Extract text | const text = await page.locator('.el').textContent() |
| Extract multiple | await expect(locator).toHaveCount(5); const els = await locator.all() |
| Batch extract | const data = await page.locator('.el').evaluateAll(els => ...) |
| Run JS in page | await page.evaluate(() => /* JS code */) |
| Take screenshot | await page.screenshot({ path: 'shot.png' }) |
| Handle new tab | const newPage = await context.waitForEvent('page', () => page.click('a')) |
Anti-Pattern Checklist
Avoid these common mistakes:
- ❌ Using
page.waitForTimeout(5000) instead of web-first assertions
- ❌ Using CSS class names or nth-child selectors instead of role-based locators
- ❌ Using
expect(await locator.isVisible()).toBe(true) instead of await expect(locator).toBeVisible()
- ❌ Using deprecated
waitForNavigation() - clicks auto-wait now
- ❌ Using
locator.all() without asserting count first
- ❌ Using
first() when locator should be more specific
- ❌ Not handling popups or cookie banners
- ❌ Hardcoding delays instead of waiting for conditions
- ❌ Taking screenshots for data extraction (use evaluate instead)
Remember
Robust automation priorities:
- User-facing locators first - Role, label, placeholder, text (not CSS)
- Web-first assertions -
await expect(locator).toBeVisible() not expect(await ...)
- Trust auto-waiting - Don't add manual delays or deprecated patterns
- Strictness is your friend - Fix ambiguous locators, don't use
first()
- Batch extraction wisely - Assert count before
all(), use evaluateAll() for efficiency
Browser automation is inherently asynchronous and timing-dependent. Build in resilience from the start.