playwright-testing
When to use
Design verification. When exercising a UI artifact, run the design-artifact verification checklist (open → console/load → viewport → text-fit → assets → interaction) and capture evidence; a design task with browser capability present is not "done" without it.
Use this skill when:
- Writing end-to-end tests with Playwright
- Automating browser interactions for testing
- Setting up visual regression testing
- Using Playwright MCP for design reviews
- Debugging flaky E2E tests
- Configuring Playwright for CI/CD
Guideline: ../../../docs/guidelines/e2e/playwright.md — full conventions, config templates, CI setup.
Mobile: for native iOS/Android or React Native E2E, do NOT reuse Playwright — see the mobile-e2e-strategy skill for framework selection.
Procedure: Write Playwright tests
- Read the guideline —
../../../docs/guidelines/e2e/playwright.mdfor detailed conventions. - Check Playwright config —
playwright.config.tsfor browsers, base URL, timeouts. - Check existing tests — match patterns in
tests/e2e/ore2e/. - Check test utilities — look for page objects, fixtures, helpers.
- Check CI setup — how are E2E tests run in the pipeline?
- Enumerate the cases — run the
test-case-discoveryfunnel per user flow before writing specs; cover the happy flow AND at least one boundary (empty state, max input) and one error path (failed request, validation rejection) per flow — never the happy flow alone.
Test structure
import { test, expect } from '@playwright/test'
test.describe('User Authentication', () => {
test('should login with valid credentials', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page).toHaveURL('/dashboard')
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
})
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('wrong@example.com')
await page.getByLabel('Password').fill('wrong')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page.getByText('Invalid credentials')).toBeVisible()
})
})
Locator strategies (priority order)
| Strategy | Example | When to use |
|---|---|---|
| Role | getByRole('button', { name: 'Submit' }) |
Default — most accessible |
| Label | getByLabel('Email') |
Form inputs |
| Text | getByText('Welcome') |
Visible text content |
| Placeholder | getByPlaceholder('Search...') |
Input placeholders |
| Test ID | getByTestId('submit-btn') |
Last resort — when no semantic locator works |
| CSS | page.locator('.my-class') |
Avoid — brittle |
Prefer semantic locators (getByRole, getByLabel) over CSS selectors.
Reliable test patterns
Wait for network idle
// Wait for page to fully load
await page.goto('/dashboard', { waitUntil: 'networkidle' })
// Wait for specific API response
await page.waitForResponse(resp =>
resp.url().includes('/api/users') && resp.status() === 200
)
Assertions with auto-retry
// ✅ Auto-retrying assertions (Playwright retries until timeout)
await expect(page.getByText('Success')).toBeVisible()
await expect(page.getByRole('list')).toHaveCount(5)
// ❌ Non-retrying — can be flaky
const text = await page.textContent('.message')
expect(text).toBe('Success')
Page Object Model
// pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.page.getByLabel('Email').fill(email)
await this.page.getByLabel('Password').fill(password)
await this.page.getByRole('button', { name: 'Sign in' }).click()
}
}
Visual regression testing
test('homepage visual regression', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixelRatio: 0.01,
})
})
- Screenshots are stored in
tests/*.png(or configured path). - First run creates baseline screenshots.
- Subsequent runs compare against baselines.
- Update baselines:
npx playwright test --update-snapshots.
Viewport testing
test.describe('Responsive design', () => {
for (const viewport of [
{ width: 1440, height: 900, name: 'desktop' },
{ width: 768, height: 1024, name: 'tablet' },
{ width: 375, height: 812, name: 'mobile' },
]) {
test(`renders correctly on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize(viewport)
await page.goto('/')
await expect(page).toHaveScreenshot(`home-${viewport.name}.png`)
})
}
})
Debugging
# Run with headed browser (see what's happening)
npx playwright test --headed
# Run with Playwright Inspector (step through)
npx playwright test --debug
# View test report
npx playwright show-report
# Run specific test
npx playwright test -g "should login"
Filter noisy Playwright output
Use --grep, --reporter=json, plus jq/rg to keep diagnosis scoped:
# Targeted run — only matching specs
npx playwright test --grep '@smoke'
# JSON report, narrowed to failures via jq
npx playwright test --reporter=json > pw.json
jq '.suites[].specs[] | select(.tests[].results[].status=="failed")' pw.json
# Scan trace logs for one selector
rg --color=never 'getByRole.*Submit' test-results/
Run verification. The test run's exit code is the pass/fail signal — 0
means every spec passed, non-zero means at least one failed. Read the command
output (or the --reporter=json above) to diagnose the failing spec's
root cause; do not blindly re-run hoping it turns green — a retry-until-pass
is not a fix, and a flaky green hides the real defect.
Avoiding flaky tests
| Problem | Solution |
|---|---|
| Element not ready | Use auto-retrying assertions (toBeVisible, toHaveText) |
| Animation interference | Use page.evaluate(() => document.body.style.setProperty('--transition-duration', '0s')) |
| Network timing | Wait for specific responses, not arbitrary timeouts |
| Test isolation | Use fresh browser context per test (Playwright default) |
| Shared state | Reset database/state before each test |
Authentication pattern
// Use storageState to avoid logging in via UI in every test
// auth.setup.ts
import { test as setup } from '@playwright/test'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!)
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL('/dashboard')
await page.context().storageState({ path: '.auth/user.json' })
})
// playwright.config.ts — use storage state in projects
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
dependencies: ['setup'],
},
]
Network mocking
// Mock API responses for isolated testing
await page.route('**/api/users', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Test User' }]),
})
)
Output format
- Playwright test file with Page Object pattern
- Reliable locators using role/label selectors over CSS
Auto-trigger keywords
- Playwright
- E2E test
- browser automation
- visual regression
- end-to-end
Gotcha
- Don't use
page.waitForTimeout()as a fix — it masks the real problem and makes tests flaky. - The model tends to use CSS selectors instead of semantic locators — always prefer
getByRole,getByLabel. test.fixme()is for app bugs,test.skip()is for environment constraints — don't confuse them.- After 3 failed fix attempts on one test, mark it
test.fixme()and move on.
Do NOT
- Do NOT skip assertions — every test must verify something meaningful.
- Do NOT share state between tests — each test should be independent.
- Do NOT hardcode URLs — use
baseURLfrom config. - Do NOT test implementation details — test user-visible behavior.
- Do NOT put assertions in Page Objects — assertions belong in test files.
- Do NOT commit
.only— enforce viaforbidOnly: !!process.env.CI.
Anti-bruteforce — diagnose before retry
When a spec fails, do not retry blindly by re-running, swapping locators at random, or bumping timeouts until green. Diagnose the root cause first: open the trace viewer, inspect the failing locator's aria tree, identify the real reason (timing, locator, app state), then apply a targeted fix. Trial-and-error locator swaps mask flaky test design.