| name | write-playwright-e2e |
| description | Designs and stabilizes Playwright end-to-end tests — Page Object Model, role/data-testid selectors, cross-browser, network mocking, visual regression; used when adding or de-flaking browser tests. |
| when_to_use | When the user wants end-to-end/browser tests, mentions Playwright, Page Object Model, flaky E2E tests, cross-browser testing, or testing real user flows in a browser. |
File layout
Save the skill at skills/write-playwright-e2e/SKILL.md with this frontmatter, then the body below verbatim:
---
name: write-playwright-e2e
description: Designs and stabilizes Playwright end-to-end tests — Page Object Model, role/data-testid selectors, cross-browser, network mocking, visual regression; used when adding or de-flaking browser tests.
when_to_use: When the user wants end-to-end/browser tests, mentions Playwright, Page Object Model, flaky E2E tests, cross-browser testing, or testing real user flows in a browser.
---
When to Use
- Adding a new browser test for a real user flow (login, checkout, search→result, form submit).
- A flow spans pages/redirects/auth and can't be covered by a unit or integration test.
- An existing E2E test is flaky (passes locally, fails in CI, or fails ~1 in N runs) and needs de-flaking.
- The user explicitly names Playwright, Page Object Model, cross-browser, or visual regression.
Skip — and reach for write-tests (unit/integration) instead — when the logic under test is a pure function, a parser, an API handler, or anything you can drive without a real DOM. E2E is the slowest, most brittle tier; only put a flow here when the value is the browser+network+rendering integration. One or two E2E tests per critical flow, not one per assertion.
Steps
-
Bootstrap once if Playwright is absent. Check package.json devDeps for @playwright/test. If missing: npm init playwright@latest (or npm i -D @playwright/test && npx playwright install --with-deps). Confirm a playwright.config.ts exists; tests live in e2e/ or tests/ (match the repo's existing convention — never invent a parallel folder). Add an npm script "test:e2e": "playwright test" if none exists.
-
Set config invariants before writing any test. In playwright.config.ts:
use.baseURL → so tests call page.goto('/path'), never hardcoded hosts.
webServer: { command, url, reuseExistingServer: !process.env.CI } → Playwright boots/awaits the app itself; no manual "start the server first".
use.trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure' → failure forensics without bloating green runs.
expect.timeout left at default (5s); raise per-assertion only where justified, never globally to mask slowness.
forbidOnly: !!process.env.CI → a stray .only fails CI instead of silently shrinking the suite.
-
Write one user scenario per test, arrange→act→assert. Name it after the behavior ('user can reset password from the login page'), not the implementation. Use test.describe to group a feature, test.beforeEach for shared navigation/setup. One scenario = one reason to fail; don't chain five unrelated flows into a mega-test where failure 1 hides 2–5.
-
Select by role/label first, data-testid only as fallback — never brittle CSS/text. Priority order:
page.getByRole('button', { name: 'Submit' }), getByLabel, getByPlaceholder, getByText for stable user-visible content → these mirror what a user/assistive-tech sees and survive refactors.
page.getByTestId('cart-total') when there's no accessible handle. If the element lacks one, add a to the app source rather than reaching for .
Common Errors
waitForTimeout / arbitrary sleeps. The single biggest cause of flake and slowness. There is always a condition to await instead (expect().toBeVisible, waitForResponse, waitForURL). Treat any sleep in an E2E test as a bug.
- Race on click→assert without awaiting the trigger's effect. Clicking submit then immediately asserting the next page fails intermittently because navigation/fetch is in flight. Either use a web-first assertion (which retries) or
Promise.all([page.waitForURL('**/success'), submit.click()]).
- Brittle selectors.
nth-child, framework class names, and copy-text break on the next refactor/translation and produce false failures that erode trust in the suite. Role/label/testid only.
- Forgetting
await. Every Playwright call is async. A missing await makes assertions pass vacuously (the promise is truthy) — a test that can never fail. Enable @typescript-eslint/no-floating-promises to catch these.
reuseExistingServer left on in CI, or no webServer block at all → tests race a not-yet-ready app and fail on the first goto. Let Playwright own server lifecycle and await url.
- Tests depending on order / shared state. Each test must set up and tear down its own data; Playwright runs files in parallel and order isn't guaranteed. Cross-test coupling produces "passes alone, fails in suite."
- Cross-origin
page.route misses. A glob like /api/* won't match an absolute https://api.example.com/.... Use **/api/** and verify the route actually fired (route handlers that never match silently fall through to the real network).
- Uncommitted or machine-specific snapshot baselines. Visual diffs fail in CI when baselines aren't committed, or were generated on a different OS/font stack. Generate in (or matching) the CI environment and commit them.
- Global timeout inflation to "fix" flake. Bumping
expect.timeout to 30s hides a race and makes every failure take 30s. Fix the wait condition; keep timeouts tight.
Verify
The work is done when:
- Each test covers exactly one user scenario, named for behavior, structured arrange→act→assert.
- Zero
waitForTimeout/sleep; all waits are web-first assertions or explicit waitFor* conditions.
- Selectors are role/label-based, with
data-testid only as a documented fallback — no positional CSS/XPath or copy-text matching.
- Reused flows live in Page Objects (
e2e/pages/); duplicated selector blocks have been extracted.
- The suite is deterministic: network mocked via
page.route + committed fixtures, or pointed at a seeded backend — chosen explicitly, applied consistently.
playwright.config.ts sets baseURL, webServer (with reuseExistingServer: !CI), trace/screenshot/video on failure, and forbidOnly in CI.
- Cross-browser coverage exists via
projects (chromium/firefox/webkit + at least one mobile device) on the same test files.
- New/changed tests pass
--repeat-each=10 with no flake, and the full npm run test:e2e is green headless. Any retries are documented as an infra safety net, not a race patch.
- (If used) visual snapshots are masked/animation-disabled, viewport-pinned, and the baselines are committed.