skills/playwright-e2e-design/SKILL.md
Use when designing or fixing a Playwright end-to-end test suite, debugging flaky tests, choosing locator strategies (getByRole vs CSS vs test-id), structuring fixtures and auth-state reuse, configuring parallelism and sharding, mocking third-party APIs via route(), or wiring trace-on-first-retry into CI. Triggers: "tests are flaky", page.waitForTimeout, page.locator with brittle CSS, login runs in every test, third-party API takes test offline, "should I use sleep here", parallel mode, sharding across CI machines, soft vs hard assertions, trace.zip not available on CI failure. NOT for unit testing (Vitest/Jest), Cypress migration playbooks, mobile native testing (Detox/XCUITest), or visual regression testing as a primary concern.
npx skillsauth add curiositech/windags-skills playwright-e2e-designInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Most "Playwright is flaky" stories come from three things: brittle selectors, manual sleeps instead of web-first assertions, and tests that share state. The official Playwright best-practices doc, browserstack/testdino field guides, and the auto-waiting docs all converge on the same playbook. (Playwright — Best Practices, Playwright — Auto-waiting)
The compressed rule:
getByRole / getByTestId + web-first expect() + one fresh storageState per test = not flaky
Jump to your fire:
waitForTimeout(2000) in 40 places → Replace sleeps with auto-waitingThe Playwright docs are explicit: prefer user-facing attributes to XPath or CSS selectors. (playwright-best-practices)
| Tier | Locator | Use for |
|---|---|---|
| 1 | page.getByRole('button', { name: 'Submit' }) | Default. Survives CSS refactors, encodes accessibility intent. |
| 2 | page.getByLabel('Email') / page.getByPlaceholder | Form fields. |
| 3 | page.getByText('Welcome back') | Static copy that's part of the test. |
| 4 | page.getByTestId('user-menu') (with data-testid) | Where role/label/text aren't unique enough. |
| 5 | CSS / XPath | Last resort. Comment why a higher tier didn't fit. |
// ✓ Resilient
await page.getByRole('button', { name: 'Save' }).click();
await page.getByLabel('Display name').fill('Alice');
// ✗ Fragile — designer changes a class name and everything breaks
await page.locator('button.btn.btn-primary.save-action').click();
The Playwright doc's exact framing: "Designer changes to CSS classes break brittle selectors. User-facing attributes remain stable across refactoring." (playwright-best-practices)
expect() on a Playwright locator auto-retries until the condition is met or the timeout expires. This is the single most important flake-elimination tool. (Playwright — Assertions)
// ✗ Read-once snapshot. Race condition guaranteed.
expect(await page.getByText('Welcome').isVisible()).toBe(true);
// ✓ Retries until visible or timeout. No race.
await expect(page.getByText('Welcome')).toBeVisible();
// ✓ Same for text content, URL, count, value, …
await expect(page.getByRole('row')).toHaveCount(10);
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByLabel('Email')).toHaveValue('[email protected]');
Anything that synchronously reads from the DOM (isVisible(), textContent(), count()) is a snapshot. It does not retry. The await expect(locator).toX() form is the retrying form.
Playwright's actionability checks (visible, stable, enabled, receives events) run automatically before every action. (Playwright — Actionability) page.waitForTimeout(N) is almost always wrong:
// ✗ Hopes the API resolved within 2s. Sometimes it didn't.
await page.click('text=Save');
await page.waitForTimeout(2000);
expect(await page.locator('.toast').textContent()).toBe('Saved');
// ✓ No timer. Wait for the thing that actually marks success.
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('Saved');
If you genuinely need to wait for a network call, use page.waitForResponse(/api\/save/) — but usually a UI assertion is what you want.
Logging in inside every test is slow and flaky. Playwright's pattern: a one-time setup project that logs in and saves storageState to disk, then every test starts from that state. (playwright-best-practices)
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /global.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: { storageState: 'playwright/.auth/user.json' },
},
],
});
// global.setup.ts
import { test as setup } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, 'playwright/.auth/user.json');
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: authFile });
});
For multi-role suites, run a setup per role and use test.use({ storageState: ... }) per file.
Each test must be independent. Playwright's docs are blunt: "Each test should be completely isolated from another test and should run independently with its own local storage, session storage, data, cookies etc." (playwright-best-practices)
In practice that means:
test.describe.parallel run in parallel; tests anywhere can run on different workers.test.beforeEach(async ({ request }) => {
// Clean slate via API, not via UI.
await request.delete(`/api/test-utilities/clean`);
});
The docs say: "Don't try to test links to external sites or third party servers that you do not control." (playwright-best-practices)
test('checkout shows total from quote API', async ({ page }) => {
// Pin the third-party response.
await page.route('**/api/quote*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ subtotal: 9999, tax: 870, total: 10869 }),
})
);
await page.goto('/checkout');
await expect(page.getByTestId('total')).toHaveText('$108.69');
});
For Stripe/Auth0/etc.: don't drive their UIs in your E2E suite. Mock the redirect-back step or use their test-mode endpoints.
Traces give you a timeline + DOM snapshots + network log of a failure — gold for debugging CI flake. The docs recommend running them only on retry to keep happy-path runs cheap: (playwright-best-practices)
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0,
use: {
trace: 'on-first-retry',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
},
});
In CI, upload test-results/ as an artifact so traces are downloadable. Open with npx playwright show-trace trace.zip.
// File-level parallelism (within a file).
test.describe.configure({ mode: 'parallel' });
# .github/workflows/e2e.yml — shard across N CI machines.
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}
Sharding distributes tests across runners (playwright-best-practices). Pair with github-actions-matrix-patterns for the CI side. Keep individual tests under ~30s; long tests bottleneck a shard.
Heavy POM hierarchies become their own maintenance burden. The lightweight version: helpers that wrap multi-step user journeys. Don't wrap every locator.
// pages/checkout.ts
export class CheckoutPage {
constructor(private page: Page) {}
async fillShippingAddress(addr: Address) {
await this.page.getByLabel('Street').fill(addr.street);
await this.page.getByLabel('City').fill(addr.city);
await this.page.getByLabel('Zip').fill(addr.zip);
}
async submitOrder() {
await this.page.getByRole('button', { name: 'Place order' }).click();
await expect(this.page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
}
}
A helper layer is fine when it represents a real user workflow. A "ButtonPage" wrapping a single button is bureaucracy.
Use to collect multiple failures before failing the test:
await expect.soft(page.getByTestId('total')).toHaveText('$108.69');
await expect.soft(page.getByTestId('shipping')).toHaveText('$0.00');
await page.getByRole('button', { name: 'Place order' }).click();
// Test fails at the end if any soft assertion failed,
// but you see all the violations in one run.
Useful for end-of-test verification rows. Don't use them where a failure should stop further interaction.
await expect(page).toHaveScreenshot('dashboard.png', { maxDiffPixelRatio: 0.01 });
Snapshots live in __screenshots__/ per platform. Maintenance cost is real — typically reserve for high-value canvases (login page, checkout). Better to spend the budget on functional E2Es first.
Symptom: A designer changes a class, 20 tests break.
Diagnosis: Tests use .btn.btn-primary.save-action-style selectors.
Fix: getByRole, getByLabel, getByTestId (with intentional data-testid). CSS only as last resort, with a comment.
waitForTimeout everywhereSymptom: Suite is slow AND flaky.
Diagnosis: Sleeps mask race conditions sometimes; let them through other times.
Fix: await expect(locator).toX() web-first assertions. Replace each waitForTimeout with the actual condition you were waiting for.
Symptom: Suite is dominated by login latency; auth provider rate-limits in CI.
Diagnosis: Every test runs the full login flow.
Fix: Setup project + storageState reuse. (playwright-best-practices)
Symptom: Test passes 9 of 10 runs.
Diagnosis: expect(await locator.textContent()).toBe('X') reads once, doesn't retry.
Fix: await expect(locator).toHaveText('X'). Same for visibility, count, value, attribute.
Symptom: Test A passes alone, fails when B runs first.
Diagnosis: Both write to the same row; no isolation.
Fix: Per-test unique data (suffix with test.info().testId). Reset via API in beforeEach.
Symptom: Stripe / Auth0 outage takes the suite red. Quota errors on busy CI days.
Diagnosis: E2E driving real third parties.
Fix: page.route() mocks. Test-mode endpoints where mocks aren't realistic. (playwright-best-practices)
Symptom: Suite takes 45 minutes; engineers stop running it pre-PR. Diagnosis: No parallelism, no sharding. Fix: Enable parallel mode within files; shard across CI workers; cap individual test latency.
Symptom: A flake on CI; no trace, no screenshot, no video.
Diagnosis: trace: 'off' or no artifact upload.
Fix: trace: 'on-first-retry', upload test-results/ as a CI artifact. (playwright-best-practices)
mode: 'parallel' in test.describe.configure or globally.npx playwright test --grep @smoke runs in < 2 min for the smoke subset.page.waitForTimeout calls in production tests. CI grep fails on hits.await locator.isVisible() / textContent() followed by a sync expect. Lint or grep enforces.getByRole / getByLabel / getByTestId first; CSS as last resort with a comment.storageState; login flow itself tested once in a setup project.page.route() or test-mode endpoints. CI denies network egress to unrelated hosts.trace: 'on-first-retry' configured; CI uploads test-results/ as an artifact on failure.retries: 2 on CI, retries: 0 locally (so flake is visible to authors).beforeEach cleans state via API.--shard=N/M); see github-actions-matrix-patterns for the matrix.github-actions-matrix-patterns.waitForTimeout is unnecessary). playwright.dev/docs/actionabilitydata-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.