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
Provides Playwright best practices for robust selectors, waiting patterns, and reliable browser automation.
npx claudepluginhub ed3dai/ed3d-plugins-testingThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Reliable browser automation requires strategic selector choice, proper waiting, and defensive coding. This skill provides patterns that minimize test flakiness and maximize maintainability.
When NOT to use:
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)
// Default uses data-testid
await page.getByTestId('submit-button').click();
// Can customize in playwright.config.ts:
// use: { testIdAttribute: 'data-pw' }
CSS/ID selectors (fragile, avoid if possible)
await page.locator('#submit-btn').click();
await page.locator('.btn.btn-primary.submit').click();
Locators are strict by default - operations throw if multiple elements match:
// ERROR if 2+ buttons exist
await page.getByRole('button').click();
// Solutions:
// 1. Make locator more specific
await page.getByRole('button', { name: 'Submit' }).click();
// 2. Filter to narrow down
await page.getByRole('button')
.filter({ hasText: 'Submit' })
.click();
// 3. Chain locators to scope
await page.locator('.product-card')
.getByRole('button', { name: 'Add to cart' })
.click();
// Avoid: Using first() makes tests fragile
await page.getByRole('button').first().click(); // Don't do this
// Filter by text content
await page.getByRole('listitem')
.filter({ hasText: 'Product 2' })
.getByRole('button')
.click();
// Filter by child element
await page.getByRole('listitem')
.filter({ has: page.getByRole('heading', { name: 'Product 2' }) })
.getByRole('button', { name: 'Buy' })
.click();
// Filter by NOT having text
await expect(
page.getByRole('listitem')
.filter({ hasNot: page.getByText('Out of stock') })
).toHaveCount(5);
// Handle "either/or" scenarios
const loginOrWelcome = await page.getByRole('button', { name: 'Login' })
.or(page.getByText('Welcome back'))
.first();
await expect(loginOrWelcome).toBeVisible();
❌ Fragile CSS paths
// BAD: Breaks when HTML structure changes
await page.click('div.container > div:nth-child(2) > button.submit');
✅ Stable semantic selectors
// GOOD: Survives structural changes
await page.getByRole('button', { name: 'Submit' }).click();
❌ XPath with positions
// BAD: Brittle
await page.locator('xpath=//div[3]/button[1]').click();
✅ XPath with content
// BETTER: More stable
await page.locator('xpath=//button[contains(text(), "Submit")]').click();
Playwright auto-waits before most actions. Trust it.
// Auto-waits for element to be visible, enabled, and stable
await page.click('button');
await page.fill('input[name="email"]', 'test@example.com');
What auto-waiting checks:
// Bypass checks (use with caution)
await page.click('button', { force: true });
// Test without acting (trial run)
await page.click('button', { trial: true });
Use web-first assertions - they retry until condition is met:
// WRONG - no retry, immediate check
expect(await page.getByText('welcome').isVisible()).toBe(true);
// CORRECT - auto-retries until timeout
await expect(page.getByText('welcome')).toBeVisible();
await expect(page.getByText('Status')).toHaveText('Complete');
await expect(page.getByRole('listitem')).toHaveCount(5);
// Soft assertions - continue test even on failure
await expect.soft(page.getByTestId('status')).toHaveText('Success');
await page.getByRole('link', { name: 'next' }).click();
// Test continues, failures reported at end
// Wait for specific element (modern - use web-first assertions)
await expect(page.locator('.results-loaded')).toBeVisible();
// Wait for network to be idle
await page.waitForLoadState('networkidle');
// Wait for custom condition
await page.waitForFunction(() =>
document.querySelectorAll('.item').length > 10
);
// Known count - assert exact number
await expect(page.locator('.item')).toHaveCount(5);
// Unknown count - wait for container, then extract
await expect(page.locator('.search-results')).toBeVisible();
const items = await page.locator('.item').all();
// Loading spinner - wait for absence then presence
await expect(page.locator('.loading-spinner')).not.toBeVisible();
await expect(page.locator('.results')).toBeVisible();
// Wait for text content to appear
await expect(page.locator('.status')).toHaveText('Complete');
// At least one result (reject zero results)
await expect(page.locator('.item').first()).toBeVisible();
// textContent() - Gets all text including hidden elements
const title = await page.locator('h1').textContent();
// innerText() - Gets only visible text (respects CSS display)
const price = await page.locator('.price').innerText();
// getAttribute() - Get attribute value
const href = await page.locator('a.product').getAttribute('href');
// For assertions, prefer web-first assertions
await expect(page.locator('.price')).toHaveText('$99');
// IMPORTANT: locator.all() doesn't wait for elements
// This can be flaky if list is still loading
// Known count - assert first, then extract
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(),
}))
);
// Unknown count - wait for container, then extract
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(),
}))
);
// BEST: Use evaluateAll for batch extraction (single round-trip)
// Use when: extracting from locator-scoped elements (most common)
const data = await page.locator('.item').evaluateAll(items =>
items.map(el => ({
title: el.querySelector('.title')?.textContent?.trim(),
price: el.querySelector('.price')?.textContent?.trim(),
}))
);
// Use evaluate() when you need global page context
// (e.g., checking window variables, document state)
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, // Access global variables
filters: window.appliedFilters // Page-level state
};
});
// Prefer evaluateAll() for locator-scoped extraction (more focused)
const items = await page.locator('.item').evaluateAll(els =>
els.map(el => ({ /* ... */ }))
);
// Check if element exists before interacting
const cookieBanner = page.locator('.cookie-banner');
if (await cookieBanner.isVisible()) {
await cookieBanner.getByRole('button', { name: 'Accept' }).click();
}
// Playwright retries automatically, but you can customize
await expect(async () => {
const status = await page.locator('.status').textContent();
expect(status).toBe('Complete');
}).toPass({ timeout: 10000, intervals: [1000] });
// Set timeout for specific action
await page.click('button', { timeout: 5000 });
// Set timeout for entire test
test.setTimeout(60000);
// Set default timeout for page
page.setDefaultTimeout(10000);
// Modern pattern - click auto-waits for navigation
await page.click('a.next-page');
await page.waitForLoadState('networkidle'); // Only if needed
// Using modern locator
await page.getByRole('link', { name: 'Next Page' }).click();
// Open new tab
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.click('a[target="_blank"]')
]);
await newPage.waitForLoadState();
// Work with newPage
await newPage.close();
// fill() - Recommended for most inputs (fast, atomic operation)
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'secret123');
// type() - For keystroke-sensitive inputs (slower, fires each key event)
await page.locator('input.search').type('Product', { delay: 100 });
// Modern approach with role-based locators
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();
await page.setInputFiles('input[type="file"]', '/path/to/file.pdf');
// Multiple files
await page.setInputFiles('input[type="file"]', [
'/path/to/file1.pdf',
'/path/to/file2.pdf'
]);
// Type and wait for suggestions (modern approach)
await page.getByPlaceholder('Search products').fill('Product Name');
await expect(page.locator('.suggestions')).toBeVisible();
// Click specific suggestion using role-based locator
await page.getByRole('option', { name: 'Product Name - Premium' }).click();
// Or filter suggestions
await page.locator('.suggestions')
.getByText('Product Name', { exact: false })
.first()
.click();
// Full page screenshot
await page.screenshot({ path: 'screenshot.png', fullPage: true });
// Element screenshot
await page.locator('.chart').screenshot({ path: 'chart.png' });
// Screenshot on failure (in test)
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
await page.screenshot({
path: `failure-${testInfo.title}.png`,
fullPage: true
});
}
});
// Pause execution for debugging
await page.pause();
// Slow down actions for observation
const browser = await chromium.launch({ slowMo: 1000 });
| 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')) |
Avoid these common mistakes:
page.waitForTimeout(5000) instead of web-first assertionsexpect(await locator.isVisible()).toBe(true) instead of await expect(locator).toBeVisible()waitForNavigation() - clicks auto-wait nowlocator.all() without asserting count firstfirst() when locator should be more specificRobust automation priorities:
await expect(locator).toBeVisible() not expect(await ...)first()all(), use evaluateAll() for efficiencyBrowser automation is inherently asynchronous and timing-dependent. Build in resilience from the start.
You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.