Agent Skills: Write Playwright Tests

Write Playwright E2E tests using fixtures and best practices. Use when creating E2E tests, writing browser automation tests, or testing user flows.

UncategorizedID: gruckion/marathon-ralph/write-playwright-test

Skill Files

Browse the full folder contents for write-playwright-test.

Download Skill

Loading file tree…

skills/write-playwright-test/SKILL.md

Skill Metadata

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.

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)

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

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

3. Text Queries (Content)

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

4. Placeholder Queries (Fallback)

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

5. Test ID (Last Resort)

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

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

// 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('user@example.com')
    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

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

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

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

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

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

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

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

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:

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

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

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

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

File Upload

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

Handling Dialogs

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

Screenshot on Specific Step

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