Claude
Skills
Sign in
Back

playwright-patterns

Included with Lifetime
$97 forever

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

Writing & Docs

What this skill does


# 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:

1. **Role-based locators** (best - user-centric)
   ```javascript
   await page.getByRole('button', { name: 'Submit' }).click();
   await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
   ```

2. **Other user-facing locators**
   ```javascript
   await page.getByLabel('Password').fill('secret');
   await page.getByPlaceholder('Search...').fill('query');
   await page.getByText('Submit Order').click();
   ```

3. **Test ID attributes** (explicit contract)
   ```javascript
   // Default uses data-testid
   await page.getByTestId('submit-button').click();

   // Can customize in playwright.config.ts:
   // use: { testIdAttribute: 'data-pw' }
   ```

4. **CSS/ID selectors** (fragile, avoid if possible)
   ```javascript
   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:

```javascript
// 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
```

### Locator Filtering and Chaining

```javascript
// 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();
```

### Anti-Patterns to Avoid

❌ **Fragile CSS paths**
```javascript
// BAD: Breaks when HTML structure changes
await page.click('div.container > div:nth-child(2) > button.submit');
```

✅ **Stable semantic selectors**
```javascript
// GOOD: Survives structural changes
await page.getByRole('button', { name: 'Submit' }).click();
```

❌ **XPath with positions**
```javascript
// BAD: Brittle
await page.locator('xpath=//div[3]/button[1]').click();
```

✅ **XPath with content**
```javascript
// BETTER: More stable
await page.locator('xpath=//button[contains(text(), "Submit")]').click();
```

## Waiting Patterns

### Built-in Auto-Waiting

Playwright auto-waits before most actions. Trust it.

```javascript
// Auto-waits for element to be visible, enabled, and stable
await page.click('button');
await page.fill('input[name="email"]', '[email protected]');
```

**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)

```javascript
// Bypass checks (use with caution)
await page.click('button', { force: true });

// Test without acting (trial run)
await page.click('button', { trial: true });
```

### Web-First Assertions

Use web-first assertions - they retry until condition is met:

```javascript
// 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
```

### Explicit Waits for Dynamic Content

```javascript
// 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
);
```

### Handling Asynchronous Updates

```javascript
// 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();
```

## Data Extraction Patterns

### Single Element

```javascript
// 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');
```

### Multiple Elements

```javascript
// 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(),
  }))
);
```

### Complex Extraction with evaluate()

```javascript
// 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.cla

Related in Writing & Docs