home / skills / gruckion / marathon-ralph / write-playwright-test

write-playwright-test skill

/skills/write-playwright-test

This skill helps you write robust Playwright end-to-end tests using fixtures, Page Object Model, and accessibility-first queries to mirror user behavior.

npx playbooks add skill gruckion/marathon-ralph --skill write-playwright-test

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
12.5 KB
---
name: write-playwright-test
description: Write Playwright E2E tests using fixtures and best practices. Use when creating E2E tests, writing browser automation tests, or testing user flows.
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
---

# Write Playwright Tests

Write end-to-end tests using Playwright fixtures, Page Object Model, and accessibility-first queries.

## Core Principles

Follow Kent C. Dodds' testing philosophy:

> "The more your tests resemble the way your software is used, the more confidence they can give you."

### Test User Behavior, Not Implementation

- Test what users see and do
- Avoid testing internal state or methods
- Focus on outcomes, not mechanisms

## Query Priority (Accessibility-First)

Use queries in this order of preference:

### 1. Role Queries (Best)

```typescript
// Buttons, links, headings
await page.getByRole('button', { name: /submit/i })
await page.getByRole('link', { name: /home/i })
await page.getByRole('heading', { level: 1 })

// Form elements
await page.getByRole('textbox', { name: /email/i })
await page.getByRole('checkbox', { name: /remember me/i })
await page.getByRole('combobox', { name: /country/i })

// With state
await page.getByRole('checkbox', { checked: true })
await page.getByRole('tab', { selected: true })
```

### 2. Label Queries (Forms)

```typescript
await page.getByLabel(/email address/i)
await page.getByLabel(/password/i)
await page.getByLabel(/phone number/i)
```

### 3. Text Queries (Content)

```typescript
await page.getByText(/welcome back/i)
await page.getByText('Exact Match')
```

### 4. Placeholder Queries (Fallback)

```typescript
await page.getByPlaceholder('Search...')
```

### 5. Test ID (Last Resort)

```typescript
// Only when no accessible alternative exists
await page.getByTestId('complex-widget')
```

## Fixtures

### Why Use Fixtures?

- **Encapsulation**: Setup and teardown in one place
- **Reusability**: Share across all tests
- **Isolation**: Fresh state per test
- **Auto-cleanup**: Teardown runs automatically

### Basic Custom Fixture

```typescript
// tests/e2e/fixtures/test-fixtures.ts
import { test as base } from '@playwright/test'

type MyFixtures = {
  testUser: { email: string; password: string }
}

export const test = base.extend<MyFixtures>({
  testUser: async ({}, use) => {
    // Setup: Create test data
    const user = {
      email: `test-${Date.now()}@example.com`,
      password: 'TestPassword123!',
    }

    // Provide to test
    await use(user)

    // Teardown: Cleanup (runs after test)
    // await deleteUser(user.email)
  },
})

export { expect } from '@playwright/test'
```

### Authentication Fixture

```typescript
// tests/e2e/fixtures/auth-fixtures.ts
import { test as base, type Page } from '@playwright/test'

type AuthFixtures = {
  authenticatedPage: Page
}

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    // Login before test
    await page.goto('/login')
    await page.getByLabel(/email/i).fill('[email protected]')
    await page.getByLabel(/password/i).fill('password123')
    await page.getByRole('button', { name: /sign in/i }).click()
    await page.waitForURL('/dashboard')

    // Provide authenticated page
    await use(page)

    // Logout after test (optional)
    // await page.goto('/logout')
  },
})
```

### Database Fixture with Cleanup

```typescript
// tests/e2e/fixtures/db-fixtures.ts
import { test as base } from '@playwright/test'

type DbFixtures = {
  insertTestData: (data: TestData) => Promise<string>
}

export const test = base.extend<DbFixtures>({
  insertTestData: async ({ request }, use) => {
    const createdIds: string[] = []

    // Provide factory function
    await use(async (data) => {
      const response = await request.post('/api/test-data', { data })
      const { id } = await response.json()
      createdIds.push(id)
      return id
    })

    // Cleanup all created data
    for (const id of createdIds) {
      await request.delete(`/api/test-data/${id}`)
    }
  },
})
```

### Worker-Scoped Fixture (Shared Across Tests)

```typescript
// tests/e2e/fixtures/worker-fixtures.ts
import { test as base } from '@playwright/test'

type WorkerFixtures = {
  sharedAccount: { username: string; token: string }
}

export const test = base.extend<{}, WorkerFixtures>({
  sharedAccount: [
    async ({ browser }, use, workerInfo) => {
      // Create unique account per worker
      const username = `worker-${workerInfo.workerIndex}`

      // Setup runs once per worker
      const page = await browser.newPage()
      await page.goto('/signup')
      // ... create account ...
      await page.close()

      await use({ username, token: 'token' })

      // Teardown when worker shuts down
      // await deleteAccount(username)
    },
    { scope: 'worker' },
  ],
})
```

### Combining Multiple Fixtures

```typescript
// tests/e2e/fixtures/index.ts
import { mergeTests } from '@playwright/test'
import { test as authTest } from './auth-fixtures'
import { test as dbTest } from './db-fixtures'
import { test as pageTest } from './page-fixtures'

export const test = mergeTests(authTest, dbTest, pageTest)
export { expect } from '@playwright/test'
```

## Page Object Model

### Page Object Structure

```typescript
// tests/e2e/pages/checkout.page.ts
import { type Page, type Locator, expect } from '@playwright/test'

export class CheckoutPage {
  readonly page: Page

  // Locators
  readonly cartItems: Locator
  readonly subtotal: Locator
  readonly checkoutButton: Locator
  readonly promoCodeInput: Locator
  readonly applyPromoButton: Locator
  readonly errorMessage: Locator

  constructor(page: Page) {
    this.page = page
    this.cartItems = page.getByRole('list', { name: /cart items/i })
    this.subtotal = page.getByTestId('subtotal')
    this.checkoutButton = page.getByRole('button', { name: /checkout/i })
    this.promoCodeInput = page.getByLabel(/promo code/i)
    this.applyPromoButton = page.getByRole('button', { name: /apply/i })
    this.errorMessage = page.getByRole('alert')
  }

  async goto() {
    await this.page.goto('/checkout')
  }

  async applyPromoCode(code: string) {
    await this.promoCodeInput.fill(code)
    await this.applyPromoButton.click()
  }

  async proceedToCheckout() {
    await this.checkoutButton.click()
  }

  async expectItemCount(count: number) {
    await expect(this.cartItems.getByRole('listitem')).toHaveCount(count)
  }

  async expectSubtotal(amount: string) {
    await expect(this.subtotal).toHaveText(amount)
  }
}
```

### Using Page Objects with Fixtures

```typescript
// tests/e2e/fixtures/page-fixtures.ts
import { test as base } from '@playwright/test'
import { CheckoutPage } from '../pages/checkout.page'
import { LoginPage } from '../pages/login.page'

type PageFixtures = {
  checkoutPage: CheckoutPage
  loginPage: LoginPage
}

export const test = base.extend<PageFixtures>({
  checkoutPage: async ({ page }, use) => {
    await use(new CheckoutPage(page))
  },

  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page))
  },
})
```

## Writing Tests

### Basic Test Structure

```typescript
// tests/e2e/checkout.spec.ts
import { test, expect } from './fixtures'

test.describe('Checkout Flow', () => {
  test('user can complete purchase', async ({ page, checkoutPage }) => {
    // Given: User has items in cart
    await checkoutPage.goto()
    await checkoutPage.expectItemCount(2)

    // When: User proceeds to checkout
    await checkoutPage.proceedToCheckout()

    // Then: User sees payment form
    await expect(page).toHaveURL('/payment')
    await expect(
      page.getByRole('heading', { name: /payment/i })
    ).toBeVisible()
  })

  test('user can apply valid promo code', async ({ checkoutPage }) => {
    await checkoutPage.goto()

    await checkoutPage.applyPromoCode('SAVE20')

    await expect(
      checkoutPage.page.getByText(/20% discount applied/i)
    ).toBeVisible()
  })

  test('user sees error for invalid promo code', async ({ checkoutPage }) => {
    await checkoutPage.goto()

    await checkoutPage.applyPromoCode('INVALID')

    await expect(checkoutPage.errorMessage).toHaveText(/invalid promo code/i)
  })
})
```

### Test with Authentication Fixture

```typescript
import { test, expect } from './fixtures'

test.describe('Dashboard', () => {
  test('authenticated user sees dashboard', async ({ authenticatedPage }) => {
    await authenticatedPage.goto('/dashboard')

    await expect(
      authenticatedPage.getByRole('heading', { name: /dashboard/i })
    ).toBeVisible()
    await expect(
      authenticatedPage.getByText(/welcome back/i)
    ).toBeVisible()
  })
})
```

### Test with Data Fixture

```typescript
import { test, expect } from './fixtures'

test.describe('Products', () => {
  test('user can view product details', async ({ page, insertTestData }) => {
    // Create test product (auto-cleaned up after test)
    const productId = await insertTestData({
      name: 'Test Product',
      price: 99.99,
    })

    await page.goto(`/products/${productId}`)

    await expect(page.getByRole('heading')).toHaveText('Test Product')
    await expect(page.getByText('$99.99')).toBeVisible()
  })
})
```

## Web-First Assertions

Always use web-first assertions that auto-wait:

```typescript
// GOOD - Auto-waits and retries
await expect(page.getByText('Success')).toBeVisible()
await expect(page.getByRole('button')).toBeEnabled()
await expect(page).toHaveURL('/dashboard')
await expect(page).toHaveTitle(/Dashboard/)

// BAD - Manual check, no retry
const isVisible = await page.getByText('Success').isVisible()
expect(isVisible).toBe(true)
```

### Common Assertions

```typescript
// Visibility
await expect(locator).toBeVisible()
await expect(locator).toBeHidden()

// Text content
await expect(locator).toHaveText('exact text')
await expect(locator).toContainText('partial')

// Attributes
await expect(locator).toHaveAttribute('href', '/path')
await expect(locator).toHaveClass(/active/)

// Form state
await expect(locator).toBeEnabled()
await expect(locator).toBeDisabled()
await expect(locator).toBeChecked()
await expect(locator).toHaveValue('input value')

// Count
await expect(locator).toHaveCount(5)

// Page
await expect(page).toHaveURL('/path')
await expect(page).toHaveTitle('Page Title')
```

## Soft Assertions

Continue test even if assertion fails:

```typescript
test('multiple checks', async ({ page }) => {
  await page.goto('/dashboard')

  // Soft assertions don't stop the test
  await expect.soft(page.getByTestId('status')).toHaveText('Active')
  await expect.soft(page.getByTestId('count')).toHaveText('10')

  // Test continues even if above fail
  await page.getByRole('link', { name: /settings/i }).click()
})
```

## Common Patterns

### Wait for Network

```typescript
// Wait for specific API response
await page.goto('/dashboard')
await page.waitForResponse((response) =>
  response.url().includes('/api/data') && response.status() === 200
)
```

### File Upload

```typescript
const fileInput = page.getByLabel(/upload file/i)
await fileInput.setInputFiles('./test-data/file.pdf')
```

### Handling Dialogs

```typescript
page.on('dialog', (dialog) => dialog.accept())
await page.getByRole('button', { name: /delete/i }).click()
```

### Screenshot on Specific Step

```typescript
await page.goto('/dashboard')
await page.screenshot({ path: 'dashboard.png' })
```

## What NOT to Do

- **DON'T** use `page.locator('.class-name')` - prefer role/label queries
- **DON'T** use `page.waitForTimeout(1000)` - use web-first assertions
- **DON'T** test implementation details - test user behavior
- **DON'T** repeat login flow in every test - use fixtures
- **DON'T** hardcode test data - generate unique data
- **DON'T** skip error scenarios - test unhappy paths

## Test Organization

```
tests/e2e/
├── fixtures/
│   ├── index.ts              # Combined fixtures export
│   ├── auth-fixtures.ts      # Authentication
│   ├── db-fixtures.ts        # Database operations
│   └── page-fixtures.ts      # Page objects
├── pages/
│   ├── login.page.ts
│   ├── dashboard.page.ts
│   └── checkout.page.ts
├── auth.setup.ts             # Auth setup project
├── auth.spec.ts              # Auth tests (one login flow)
├── dashboard.spec.ts
├── checkout.spec.ts
└── search.spec.ts
```

## Tips for Reliable Tests

1. **Use unique test data** - Avoid conflicts between parallel tests
2. **Clean up after tests** - Use fixture teardown
3. **Wait for stability** - Use web-first assertions
4. **Isolate tests** - Each test should work independently
5. **Test one thing** - Keep tests focused
6. **Use descriptive names** - `user can complete checkout` not `test1`

Overview

This skill helps you write Playwright end-to-end tests using fixtures, Page Object Model, and accessibility-first queries. It focuses on testing user behavior, reliable web-first assertions, and reusable fixtures to keep tests isolated and maintainable. Use built-in patterns for authentication, database setup/cleanup, and worker-scoped resources to scale parallel test runs.

How this skill works

It prescribes an accessibility-first query order (role, label, text, placeholder, test id) and encourages Page Objects for encapsulating page interactions. Custom fixtures provide setup, teardown, and shared resources (authenticated pages, test data factories, worker-scoped accounts). Tests use web-first assertions that auto-wait and soft assertions when continuing after failures is desired.

When to use it

  • Creating E2E tests that mimic real user flows
  • Automating browser interactions for critical paths (checkout, auth, dashboard)
  • Setting up reusable test data and authenticated sessions
  • Running tests in parallel with worker-scoped shared resources
  • Writing resilient tests that avoid flakiness and implementation detail checks

Best practices

  • Prefer getByRole and other accessibility-first queries over CSS selectors
  • Use fixtures to encapsulate setup/teardown, avoid repeating login flows
  • Keep tests focused: test one behavior per test and use descriptive names
  • Rely on web-first assertions (expect(...).toBeVisible(), toHaveURL()) not manual waits
  • Generate unique test data and clean up during fixture teardown
  • Use Page Objects to centralize locators and common flows

Example use cases

  • Write a checkout flow test using a CheckoutPage object and cart fixtures
  • Create an authenticatedPage fixture to test dashboard behavior without repeating login
  • Use a database fixture that inserts test products and cleans them up after each test
  • Share a worker-scoped account for expensive setup that can be reused across tests
  • Apply soft assertions to collect multiple expectation failures without stopping the test

FAQ

When should I use test IDs?

Use test IDs only as a last resort when no accessible role, label, placeholder, or text can identify the element reliably.

How do fixtures help with parallel test runs?

Worker-scoped fixtures create resources once per worker to avoid redundant setup, while per-test fixtures provide isolated state for each test so parallel runs don't conflict.