Playwright E2E testing patterns, Page Object Model, configuration, CI/CD integration, artifact management, and flaky test strategies.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Comprehensive Playwright patterns for building stable, fast, and maintainable E2E test suites.
tests/
├── e2e/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ ├── logout.spec.ts
│ │ └── register.spec.ts
│ ├── features/
│ │ ├── browse.spec.ts
│ │ ├── search.spec.ts
│ │ └── create.spec.ts
│ └── api/
│ └── endpoints.spec.ts
├── fixtures/
│ ├── auth.ts
│ └── data.ts
└── playwright.config.ts
import { Page, Locator } from '@playwright/test'
export class ItemsPage {
readonly page: Page
readonly searchInput: Locator
readonly itemCards: Locator
readonly createButton: Locator
constructor(page: Page) {
this.page = page
this.searchInput = page.locator('[data-testid="search-input"]')
this.itemCards = page.locator('[data-testid="item-card"]')
this.createButton = page.locator('[data-testid="create-btn"]')
}
async goto() {
await this.page.goto('/items')
await this.page.waitForLoadState('networkidle')
}
async search(query: string) {
await this.searchInput.fill(query)
await this.page.waitForResponse(resp => resp.url().includes('/api/search'))
await this.page.waitForLoadState('networkidle')
}
async getItemCount() {
return await this.itemCards.count()
}
}
import { test, expect } from '@playwright/test'
import { ItemsPage } from '../../pages/ItemsPage'
test.describe('Item Search', () => {
let itemsPage: ItemsPage
test.beforeEach(async ({ page }) => {
itemsPage = new ItemsPage(page)
await itemsPage.goto()
})
test('should search by keyword', async ({ page }) => {
await itemsPage.search('test')
const count = await itemsPage.getItemCount()
expect(count).toBeGreaterThan(0)
await expect(itemsPage.itemCards.first()).toContainText(/test/i)
await page.screenshot({ path: 'artifacts/search-results.png' })
})
test('should handle no results', async ({ page }) => {
await itemsPage.search('xyznonexistent123')
await expect(page.locator('[data-testid="no-results"]')).toBeVisible()
expect(await itemsPage.getItemCount()).toBe(0)
})
})
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['junit', { outputFile: 'playwright-results.xml' }],
['json', { outputFile: 'playwright-results.json' }]
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10000,
navigationTimeout: 30000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
})
test('flaky: complex search', async ({ page }) => {
test.fixme(true, 'Flaky - Issue #123')
// test code...
})
test('conditional skip', async ({ page }) => {
test.skip(process.env.CI, 'Flaky in CI - Issue #123')
// test code...
})
npx playwright test tests/search.spec.ts --repeat-each=10
npx playwright test tests/search.spec.ts --retries=3
Race conditions:
// Bad: assumes element is ready
await page.click('[data-testid="button"]')
// Good: auto-wait locator
await page.locator('[data-testid="button"]').click()
Network timing:
// Bad: arbitrary timeout
await page.waitForTimeout(5000)
// Good: wait for specific condition
await page.waitForResponse(resp => resp.url().includes('/api/data'))
Animation timing:
// Bad: click during animation
await page.click('[data-testid="menu-item"]')
// Good: wait for stability
await page.locator('[data-testid="menu-item"]').waitFor({ state: 'visible' })
await page.waitForLoadState('networkidle')
await page.locator('[data-testid="menu-item"]').click()
await page.screenshot({ path: 'artifacts/after-login.png' })
await page.screenshot({ path: 'artifacts/full-page.png', fullPage: true })
await page.locator('[data-testid="chart"]').screenshot({ path: 'artifacts/chart.png' })
await browser.startTracing(page, {
path: 'artifacts/trace.json',
screenshots: true,
snapshots: true,
})
// ... test actions ...
await browser.stopTracing()
// In playwright.config.ts
use: {
video: 'retain-on-failure',
videosPath: 'artifacts/videos/'
}
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
env:
BASE_URL: ${{ vars.STAGING_URL }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
# E2E Test Report
**Date:** YYYY-MM-DD HH:MM
**Duration:** Xm Ys
**Status:** PASSING / FAILING
## Summary
- Total: X | Passed: Y (Z%) | Failed: A | Flaky: B | Skipped: C
## Failed Tests
### test-name
**File:** `tests/e2e/feature.spec.ts:45`
**Error:** Expected element to be visible
**Screenshot:** artifacts/failed.png
**Recommended Fix:** [description]
## Artifacts
- HTML Report: playwright-report/index.html
- Screenshots: artifacts/*.png
- Videos: artifacts/videos/*.webm
- Traces: artifacts/*.zip
waitForTimeout (Arbitrary Sleeps) to Handle Async UIWrong:
await page.click('[data-testid="submit"]')
await page.waitForTimeout(3000) // hope the response arrives in time
expect(await page.locator('[data-testid="success"]').isVisible()).toBe(true)
Correct:
await page.locator('[data-testid="submit"]').click()
await page.waitForResponse(resp => resp.url().includes('/api/submit') && resp.status() === 200)
await expect(page.locator('[data-testid="success"]')).toBeVisible()
Why: Fixed sleeps make tests slow on fast machines and still flaky on slow ones; waiting for explicit conditions is deterministic and fast.
Wrong:
await page.click('.btn-primary.submit-action')
await page.click('//div[@class="card"]/button[1]')
Correct:
await page.getByRole('button', { name: /submit order/i }).click()
// or with test ID when no semantic role is available
await page.locator('[data-testid="submit-order"]').click()
Why: CSS classes and XPaths break when designers restyle components; ARIA roles and data-testid attributes express intent and survive visual refactors.
Wrong:
test('admin can delete user', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', 'admin@example.com')
await page.fill('[name="password"]', 'secret')
await page.click('[type="submit"]')
await page.waitForURL('/dashboard')
// ... actual test logic buried 10 lines in
})
Correct:
test('admin can delete user', async ({ page }) => {
const auth = new AuthPage(page)
await auth.loginAs('admin') // encapsulated in Page Object
// ... test logic starts immediately
})
Why: Duplicated interaction sequences make every test update a multi-file change; Page Objects centralise selectors and flows so one change fixes all tests.
Wrong:
// playwright.config.ts
use: { baseURL: 'https://staging.example.com' }
// Tests create and delete real records affecting shared state
Correct:
// Use ephemeral test environment or API mocking
use: { baseURL: process.env.BASE_URL || 'http://localhost:3000' }
// Seed known test data per test run via API before assertions
Why: Shared staging environments cause test interference, expose PII, and make tests non-deterministic when other developers or jobs are running concurrently.
Wrong:
test('dashboard loads', async ({ page }) => {
// intermittently fails — team just re-runs CI until it passes
})
Correct:
test('dashboard loads', async ({ page }) => {
test.fixme(true, 'Flaky race condition — Issue #456, owner: @alice')
// quarantined until root cause fixed
})
// Track in issue tracker; fix within one sprint
Why: Tolerated flakiness erodes trust in the entire test suite — teams stop treating failures as signals; quarantine makes flakiness visible and actionable.
test('checkout flow', async ({ page }) => {
// Skip on production — real money
test.skip(process.env.NODE_ENV === 'production', 'Skip on production')
await page.goto('/checkout')
await page.locator('[data-testid="product-item"]').first().click()
await page.locator('[data-testid="quantity"]').fill('1')
// Verify order summary
const summary = page.locator('[data-testid="order-summary"]')
await expect(summary).toContainText('Total')
// Confirm and wait for order API
await page.locator('[data-testid="confirm-order"]').click()
await page.waitForResponse(
resp => resp.url().includes('/api/orders') && resp.status() === 200,
{ timeout: 30000 }
)
await expect(page.locator('[data-testid="order-success"]')).toBeVisible()
})