Playwright patterns
Skill marzun9620/agent_skills/testing/skills/playwright-patterns
My personal Claude Code skills — 59 of them. Use any, fork the repo, or contribute yours. Install with /plugin marketplace add marzun9620/agent_skills
npx -y skills add marzun9620/agent_skills --skill playwright-patternsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Patterns for writing Playwright E2E tests — POM structure, selector hierarchy, flow specs, waits, role contexts, dialog handling. Use when authoring or reviewing anything under /e2e.
SKILL.md
11.5 KB, as published. Nobody here has run it
Playwright Patterns
Codified playbook for a Playwright E2E suite (typically in /e2e). Distilled from production e2e work so the suite stays consistent as it grows. This skill covers how to write tests; pair it with an in-repo AUTHORING.md that covers where and what runs when.
Quick Reference: Critical Rules
| Category | DO | DON'T |
|---|---|---|
| Selectors | getByRole → getByLabel → getByTestId('data-pw=…') → getByText | page.locator('.css-class') or positional nth(n) |
| Heading / landmark | getByRole('main') for authed shell | getByRole('heading', { level }) for shadcn CardTitle (it's a <div>) |
| Page load signal | toHaveTitle (canonical routes) or form-field / <main> visible (alias routes) | Asserting only toHaveURL after page.goto(url) — tautological |
POM goto() | End with expectLoaded() assertion | Return before the page is observable |
| Waits | page.waitForResponse, expect.poll, auto-waiting locators | page.waitForTimeout(ms) |
| Flow specs | test.describe.serial + phased sub-describes with beforeAll/afterAll contexts | Parallel flow specs against shared staging DB |
| Retries on flow | retries: 0 | Retries on serial multi-step tests — step-1 replay leaks data |
| Multi-role tests | Separate BrowserContext per role (adminPage + publicPage) | One context that signs in/out between steps |
| Test data | Timestamp + TEST_PARALLEL_INDEX suffix | Hardcoded names — collides across parallel / retried runs |
| Secrets | process.env.E2E_ADMIN_PASSWORD read by lib/config.ts | Hardcoding in specs or committing .env |
| File uploads | setInputFiles(path) with fixtures from e2e/fixtures/* | Inline base64 or manual clicks on hidden inputs |
| Dialog handling | Handle both "dialog appeared" and "step advanced" branches | Blind click() then waitForTimeout |
Page Object Model
POMs live in e2e/pages/<name>.page.ts. One file per page or major form.
Contract
import { type Page, expect } from "@playwright/test";
export class <Name>Page {
constructor(private readonly page: Page) {}
// 1. Stable accessors as getters — let callers assert without extra POM methods
get submitBtn() {
return this.page.getByRole("button", { name: /submit|保存/i });
}
// 2. goto() always ends with an expectLoaded assertion
async goto() {
await this.page.goto("/your-route");
await this.expectLoaded();
}
// 3. Copy-stable landing signal. For authed routes prefer getByRole('main').
// For public routes with real meta() titles, toHaveTitle works.
async expectLoaded() {
await expect(this.page.getByRole("main")).toBeVisible({ timeout: 30_000 });
}
// 4. Action methods named by USER INTENT, not selector.
// GOOD: async submitContactForm(...)
// BAD: async clickSubmit()
async submitForm(payload: { email: string; body: string }) {
await this.page.getByLabel("メールアドレス").fill(payload.email);
await this.page.getByLabel("メッセージ").fill(payload.body);
await this.submitBtn.click();
}
}
When to split a POM into multiple files
- One page, multiple complex surfaces — separate files:
job-form.page.ts,job-view.page.ts,job-list.page.ts - Multi-step wizard — one POM per wizard with private per-step helpers (
fillStep1,fillStep2…) and a publicfillAllSteps(data)entry point. Seepages/*-form.page.tspatterns in the reference.
Fixture registration
Every POM gets a fixture in e2e/fixtures/index.ts so specs inject via destructuring:
export const test = base.extend<Fixtures>({
signinPage: async ({ page }, use) => {
await use(new SigninPage(page));
},
});
Specs then:
test("does the thing", async ({ signinPage, page }) => {
await signinPage.goto();
await signinPage.signIn(email, password);
});
Flow Specs (Multi-Step Journeys)
Flow specs go under e2e/specs/<area>/<journey>.spec.ts (or any non-smoke folder — picked up by the staging-catchall project). They must be serial, write carefully, and never retry.
Multi-phase template
import { type BrowserContext, type Page, expect, test } from "@playwright/test";
import { adminStorageStatePath } from "../../lib/storage-state";
import { generateTestEmail } from "../../lib/email";
test.describe.serial("<journey name>", () => {
let adminContext: BrowserContext;
let adminPage: Page;
const testEmail = generateTestEmail();
let entityId: string; // shared state across phases
test.beforeAll(async ({ browser }) => {
adminContext = await browser.newContext({ storageState: adminStorageStatePath });
adminPage = await adminContext.newPage();
});
test.afterAll(async () => {
await adminContext?.close();
});
test.describe.serial("Phase 1: setup", () => {
test("Step 1: create entity", async () => {
test.setTimeout(60_000);
// ... use adminPage ...
entityId = await ...;
});
});
test.describe.serial("Phase 2: public interaction", () => {
test("Step 2: public submits something", async ({ browser }) => {
test.setTimeout(60_000);
const publicContext = await browser.newContext();
const publicPage = await publicContext.newPage();
try {
// ... use publicPage ...
} finally {
await publicContext.close();
}
});
});
test.describe.serial("Phase 3: verify & cleanup", () => {
test("Step 3: admin verifies result", async () => {
await adminPage.goto(`/admin/entity/${entityId}`);
// ... assertions ...
});
});
});
Why phased sub-describes
test.describe.serialon outer + inner means every step runs in order- Inner describes group related steps for reporting — you can scan the run output and see "Phase 2 failed" without reading 15 step titles
beforeAll/afterAllsurvive across phase boundaries, so shared state (adminPage,entityId) stays intact
Why separate publicContext
- The storage state makes
adminPageauthed. To exercise the unauthed public flow you need a fresh context — a single page can't switch identities cleanly - Always
try { ... } finally { publicContext.close() }— unclosed contexts leak Chromium processes
Retries on flow specs: always 0
// playwright.config.ts
{
name: "staging-flow",
retries: 0, // Serial suites retry from Step 1, creating orphan data
fullyParallel: false,
...
}
If step 3 fails and Playwright retries, it re-runs steps 1–3. Steps 1–2 already wrote data. You now have orphan companies / jobs / applicants in shared staging.
Selectors
See references/selector-patterns.md for full hierarchy. TL;DR:
getByRole('button', { name })— accessibility-firstgetByLabel('メールアドレス')— best for form fieldsgetByTestId('page-admin-dashboard')— oncedata-pwlands in the appgetByText(...)— last resortpage.locator('.css-class')— forbidden
For alias routes (/signin, /dashboard) where meta() isn't re-exported, title is empty — use getByRole('main') or a form field instead.
Waits
Never page.waitForTimeout(ms). Three real tools:
1. Auto-waiting locators
expect(locator).toBeVisible({ timeout }) auto-retries. Use liberally.
2. Response-wait for backend mutations
When an action triggers an API call you need to settle before asserting:
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes("/api/documents/applicant-info"),
{ timeout: 30_000 },
);
await submitBtn.click();
await responsePromise;
Set the promise before the triggering action — otherwise you race the request.
3. expect.poll for async backend processes
When a scanner / cron / pipeline moves the record into a new state asynchronously:
await expect
.poll(
async () => {
const count = await page.getByText("クリーン").count();
if (count >= 2) return count;
await page.reload();
return count;
},
{
intervals: [15_000], // check every 15s
timeout: 540_000, // give up at 9 minutes
message: "Expected both documents to reach クリーン status",
},
)
.toBeGreaterThanOrEqual(2);
Dialog handling
Validation dialogs can appear on "Next" / "Save" clicks. Handle both outcomes:
async clickNextStep() {
const dialog = this.page.getByRole("dialog");
// Pre-click: close any stale dialog
if ((await dialog.count()) > 0) {
await dialog.locator("button").first().click();
await expect(dialog).toBeHidden({ timeout: 3_000 });
}
await this.nextButton.click();
// Post-click: dialog might appear OR step advances
const dialogAppeared = await dialog
.waitFor({ state: "visible", timeout: 1_500 })
.then(() => true)
.catch(() => false);
if (dialogAppeared) {
await dialog.locator("button").first().click();
await expect(dialog).toBeHidden({ timeout: 3_000 });
}
}
File uploads
Binary fixtures go under e2e/fixtures/* (e.g. test-photo.png, test.pdf). Reference them via fileURLToPath in the spec:
import path from "node:path";
import { fileURLToPath } from "node:url";
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const testPdf = path.resolve(currentDir, "../../fixtures/test.pdf");
await page.locator("#document").setInputFiles(testPdf);
For multi-file uploads:
const inputs = page.locator("input[type='file']");
const count = await inputs.count();
for (let i = 0; i < count; i++) {
await inputs.nth(i).setInputFiles(testPdf);
}
Test data generation
Always collision-free:
const workerIndex = process.env.TEST_PARALLEL_INDEX ?? "0";
const ts = `${Date.now()}-${workerIndex}`;
export const testEntity = {
name: `E2E-Entity-${ts}`,
email: `e2e-${ts}@mailsac.com`,
} as const;
For emails that need Mailsac polling, use generateTestEmail() from lib/email.ts — it already bakes in the worker index.
Anti-patterns (reject in review)
- CSS class selectors — break on any style refactor
- Positional selectors without semantic context —
nth(3)is meaningless waitForTimeout— fixed sleeps hide real timing problems- Shared state between tests in the same project (non-serial) — leaks, order-dependence
- Retries > 0 on flow specs — orphan data in shared DB
- Mutation-heavy smoke specs — smoke must be idempotent and parallel-safe
page.click()beforeexpect(locator).toBeVisible()— skips auto-wait- Per-step sign-in/out — use separate contexts instead
Deep dives (references/)
pom-patterns.md— POM contract, multi-step wizard patterns, getter conventionsflow-patterns.md— multi-phase flow specs, role contexts, shared state lifetimeselector-patterns.md— full selector hierarchy,data-pwmigration planwait-patterns.md— auto-wait, response-wait, expect.poll, dialog handling
Gives 0 of the 12 instructions most e2e browser skills give
Counted across 407 of the 410 authors here whose files we hold, read 2026-08-06
- use page object model patternin 35 of 407, across 25 files
- Snapshot to get element refsin 24 of 407, across 14 files
- keep tests independentin 23 of 407, across 18 files
- Interact using refs from the latest snapshotin 23 of 407, across 11 files
- clean up test data after each testin 21 of 407, across 15 files
- test user behavior not implementationin 20 of 407, across 14 files
- quarantine flaky tests explicitlyin 19 of 407, across 10 files
- wait for specific network conditionsin 18 of 407, across 8 files
- re-snapshot after navigation or dom changesin 17 of 407, across 10 files
- Detect running dev servers before writing test codein 17 of 407, across 7 files
- use web-first assertionsin 17 of 407, across 14 files
- capture screenshots or videos on test failurein 17 of 407, across 14 files
Said here and by no other author read
- end page object goto methods with loaded assertion
- use separate browser contexts for different roles
- set retries to zero for serial flow specs
- use timestamp and worker index for test data
- read secrets from environment variables
- use set input files for binary uploads
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once.