Claude
Skills
Sign in
Back

writing-playwright-tests

Included with Lifetime
$97 forever

Provides patterns for writing maintainable E2E test scripts with Playwright, focusing on selector strategies, page objects, and wait handling for legacy application retrofitting.

Writing & Docs

What this skill does


# Playwright E2E Testing Skill

## 1. Selector Strategy Hierarchy

Use selectors in this priority order for maximum resilience:

```typescript
// BEST: Explicit test identifiers
page.getByTestId('submit-button')

// GOOD: Semantic role-based (accessible)
page.getByRole('button', { name: 'Submit' })
page.getByRole('heading', { level: 1 })
page.getByRole('textbox', { name: 'Email' })

// GOOD: User-visible text
page.getByText('Welcome back')
page.getByLabel('Email address')
page.getByPlaceholder('Enter your email')

// ACCEPTABLE: When above options unavailable
page.locator('[data-cy="element"]')  // Cypress migration
page.locator('#unique-id')            // Stable IDs only

// AVOID: Brittle structural selectors
page.locator('.btn-primary')          // Classes change
page.locator('div > span:nth-child(2)') // Structure changes
page.locator('//div[@class="foo"]')   // XPath fragile
```

### Adding Test IDs to Legacy Apps

When retrofitting, add data-testid attributes incrementally:

```html
<!-- Before: Relies on brittle class selector -->
<button class="btn btn-primary submit-form">Submit</button>

<!-- After: Resilient test identifier -->
<button class="btn btn-primary submit-form" data-testid="contact-form-submit">Submit</button>
```

Naming convention for test IDs:
```
{component}-{element}-{qualifier}
contact-form-submit
user-list-row-{id}
modal-confirm-button
nav-menu-toggle
```

## 2. Page Object Model

### Basic Page Object

```typescript
// pages/login.page.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = 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');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}
```

### Page Object with Component Composition

```typescript
// components/data-table.component.ts
import { Page, Locator } from '@playwright/test';

export class DataTableComponent {
  readonly container: Locator;
  readonly rows: Locator;
  readonly searchInput: Locator;
  readonly pagination: Locator;

  constructor(page: Page, containerSelector: string) {
    this.container = page.locator(containerSelector);
    this.rows = this.container.getByRole('row');
    this.searchInput = this.container.getByPlaceholder('Search');
    this.pagination = this.container.locator('[data-testid="pagination"]');
  }

  async search(term: string) {
    await this.searchInput.fill(term);
    await this.searchInput.press('Enter');
  }

  async getRowCount(): Promise<number> {
    return await this.rows.count() - 1; // Exclude header
  }

  async clickRow(index: number) {
    await this.rows.nth(index + 1).click(); // Skip header
  }
}

// pages/contacts.page.ts
export class ContactsPage {
  readonly page: Page;
  readonly table: DataTableComponent;
  readonly addButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.table = new DataTableComponent(page, '[data-testid="contacts-table"]');
    this.addButton = page.getByRole('button', { name: 'Add Contact' });
  }
}
```

## 3. Wait Strategies

### Explicit Waits

```typescript
// Wait for navigation
await page.waitForURL('**/dashboard');
await page.waitForURL(/\/users\/\d+/);

// Wait for network idle
await page.waitForLoadState('networkidle');

// Wait for element state
await expect(element).toBeVisible();
await expect(element).toBeEnabled();
await expect(element).toHaveText('Ready');

// Wait for element to appear
await page.waitForSelector('[data-testid="results"]');

// Wait for element to disappear
await expect(page.getByTestId('loading')).toBeHidden();
```

### Waiting for Dynamic Content

```typescript
// Wait for API response before asserting
await page.waitForResponse(resp =>
  resp.url().includes('/api/contacts') && resp.status() === 200
);

// Wait for specific number of elements
await expect(page.getByRole('listitem')).toHaveCount(10);

// Custom wait with polling
await expect(async () => {
  const count = await page.getByRole('row').count();
  expect(count).toBeGreaterThan(5);
}).toPass({ timeout: 10000 });
```

### Handling Loading States

```typescript
async function waitForTableLoad(page: Page, tableLocator: Locator) {
  // Wait for loading indicator to disappear
  await expect(page.getByTestId('table-loading')).toBeHidden();

  // Wait for at least one row
  await expect(tableLocator.getByRole('row')).not.toHaveCount(0);
}

async function waitForModalClose(page: Page) {
  await expect(page.getByRole('dialog')).toBeHidden();
}
```

## 4. Test Structure

### Basic Test File

```typescript
// tests/contacts.spec.ts
import { test, expect } from '@playwright/test';
import { ContactsPage } from '../pages/contacts.page';

test.describe('Contacts Management', () => {
  let contactsPage: ContactsPage;

  test.beforeEach(async ({ page }) => {
    contactsPage = new ContactsPage(page);
    await page.goto('/contacts');
  });

  test('displays contact list', async ({ page }) => {
    await expect(contactsPage.table.rows).not.toHaveCount(0);
  });

  test('filters contacts by search', async ({ page }) => {
    await contactsPage.table.search('John');

    const rows = contactsPage.table.rows;
    await expect(rows).toHaveCount(2);
  });

  test('opens contact details on row click', async ({ page }) => {
    await contactsPage.table.clickRow(0);

    await expect(page).toHaveURL(/\/contacts\/\d+/);
    await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
  });
});
```

### Test with Authentication

```typescript
// tests/authenticated.spec.ts
import { test, expect } from '@playwright/test';

// Use authenticated state from fixture
test.use({ storageState: 'playwright/.auth/user.json' });

test.describe('Dashboard (authenticated)', () => {
  test('shows user dashboard', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  });
});
```

## 5. Common Interactions

### Form Interactions

```typescript
// Text input
await page.getByLabel('Name').fill('John Doe');
await page.getByLabel('Name').clear();

// Select dropdown
await page.getByLabel('Country').selectOption('US');
await page.getByLabel('Country').selectOption({ label: 'United States' });

// Checkbox
await page.getByLabel('Accept terms').check();
await page.getByLabel('Accept terms').uncheck();

// Radio button
await page.getByLabel('Express shipping').check();

// Date picker (fill underlying input)
await page.getByLabel('Start date').fill('2024-01-15');

// File upload
await page.getByLabel('Upload file').setInputFiles('path/to/file.pdf');
await page.getByLabel('Upload file').setInputFiles(['file1.pdf', 'file2.pdf']);
```

### Click Interactions

```typescript
// Standard click
await page.getByRole('button', { name: 'Submit' }).click();

// Double click
await page.getByTestId('row-1').dblclick();

// Right click
await page.getByTestId('item').click({ button: 'right' });

// Click with modifier
await page.getByRole('link').click({ modifiers: ['Control'] });

// Force click (bypasses actionability checks)
await page.getByTestId('hidden-button').click({ force: true });
```

### Keyboard Interactions

```typescript
// Type with delay (for autocomplete)
await page.getByLabel('Search').pressSequentially('playwright', { delay: 100 });

// Special keys
await page.keyboard.press('Enter');
await page.keyboard.press('Escape');
await page.keyboard.

Related in Writing & Docs