| name | playwright-best-practices |
| description | Comprehensive playwright testing best practices including ARIA snapshot testing for structural validation. Use when asked to "write playwright tests", "validate page structure", "test accessibility", "improve playwright test quality", "review playwright test code", or "advise on playwright test architecture". |
| license | MIT |
Playwright Testing Best Practices
Description
This skill teaches WHAT makes good tests and WHY certain patterns prevent failures. Provides the decision-making framework behind test structure, locator selection, assertion strategies, and debugging approaches. Works in conjunction with playwright-cli skill (which teaches HOW to execute commands). Apply when planning test strategy, choosing between ARIA snapshots vs individual locators, structuring assertions, debugging test failures, reviewing test code, or advising on test architecture. Emphasizes ARIA snapshot testing for structural validation, user-visible behavior testing, locator resilience hierarchy, web-first assertion patterns, test isolation principles, and production-proven debugging strategies.
Core Testing Philosophy
Test User-Visible Behavior
Principle: Tests should verify what end users experience, not implementation details.
Rules:
- Focus on rendered output that users can see and interact with
- Avoid testing internal function names, data structures, or CSS class names
- Test the same interface that users experience
- Don't assert on implementation details that could change without affecting user experience
Anti-patterns:
expect(component.internalState.items).toBeArray();
expect(element.className).toBe("btn-primary");
await expect(page.getByRole("list")).toBeVisible();
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled();
Test Isolation
Principle: Each test must run independently with its own state.
Critical Requirements:
- Every test gets fresh local storage, session storage, data, and cookies
- Tests cannot depend on execution order or state from other tests
- Each test should be able to run alone without setup from other tests
Benefits:
- Improves reproducibility
- Makes debugging easier
- Prevents cascading failures
- Allows parallel execution
Implementation Patterns:
test.beforeEach(async ({ page }) => {
await page.goto("https://example.com/login");
await page.getByLabel("Username").fill("user");
await page.getByLabel("Password").fill("pass");
await page.getByRole("button", { name: "Sign in" }).click();
});
test("first test", async ({ page }) => {
});
test("second test", async ({ page }) => {
});
Advanced Pattern - Setup Projects:
- For expensive operations like authentication, use setup projects
- Log in once, save state, reuse across tests
- Dramatically improves test suite performance
- See Playwright auth documentation for implementation
When Duplication is OK:
- Simple tests where beforeEach adds complexity
- When it improves test clarity and readability
- When it makes tests more self-contained
Avoid Third-Party Dependencies
Principle: Only test code you control.
Rules:
- Never navigate to external sites you don't control
- Don't test third-party APIs directly
- Mock external dependencies instead
Why This Matters:
- External sites can have cookie banners, overlays, downtime
- Content can change unexpectedly
- Slows down test execution
- Creates flaky tests
Solution - Use Network Mocking:
await page.route("**/api/external-service", (route) =>
route.fulfill({
status: 200,
body: JSON.stringify(expectedData),
}),
);
await page.goto("https://example.com");
ARIA Snapshot Testing
What Are ARIA Snapshots?
Definition: ARIA snapshots provide a YAML representation of the accessibility tree - the semantic structure assistive technologies see.
Why This Is Powerful:
- Tests what actually matters: semantic structure and accessibility
- Survives CSS refactoring and styling changes
- Catches accessibility regressions automatically
- More resilient than pixel-based visual testing
- Documents expected page structure in human-readable format
- Single assertion can validate entire page regions
The Core Pattern
await expect(page.locator("body")).toMatchAriaSnapshot(`
- banner:
- heading "Welcome to Our Site" [level=1]
- navigation:
- link "Home"
- link "About"
- link "Contact"
- main:
- region "Featured Content":
- heading "Latest News" [level=2]
- list:
- listitem: link "Article 1"
- listitem: link "Article 2"
`);
Why ARIA Snapshots Over Individual Assertions
Traditional approach (brittle):
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Welcome");
await expect(page.getByRole("link", { name: "Home" })).toBeVisible();
await expect(page.getByRole("link", { name: "About" })).toBeVisible();
await expect(page.getByRole("link", { name: "Contact" })).toBeVisible();
ARIA snapshot approach (resilient):
await expect(page.locator("nav")).toMatchAriaSnapshot(`
- navigation:
- link "Home"
- link "About"
- link "Contact"
`);
YAML Format Guide
Basic structure:
- role "accessible name" [attributes]:
- child role "name"
- child role "name"
Role: ARIA role (heading, button, link, navigation, list, etc.)
Accessible name: Text users hear from screen readers (in quotes)
Attributes: ARIA attributes in square brackets
[level=1] - heading level
[checked] or [checked=true] - checkbox state
[disabled] - disabled state
[expanded=true] - expanded state
[pressed=true] - toggle button state
Examples:
- heading "Dashboard" [level=1]
- button "Menu" [expanded=true]
- checkbox "Accept terms" [checked]
- textbox "Email" [disabled]
- list "Todo Items":
- listitem: checkbox "Buy groceries"
- listitem: checkbox "Call mom" [checked]
- listitem: checkbox "Write tests"
Partial Matching Strategy
Default behavior - subset matching:
await expect(page.locator("ul")).toMatchAriaSnapshot(`
- list:
- listitem: text "Feature A"
- listitem: text "Feature C"
`);
Control child matching with /children property:
The /children property controls how strictly child elements are matched:
1. Subset matching (default):
await expect(page.locator("ul")).toMatchAriaSnapshot(`
- list:
/children:
- listitem: text "Feature A"
- listitem: text "Feature B"
`);
2. Exact matching with deep-equal:
await expect(page.locator("ul")).toMatchAriaSnapshot(`
- list:
/children: deep-equal
- listitem: text "Feature A"
- listitem: text "Feature B"
- listitem: text "Feature C"
`);
3. No children validation:
await expect(page.locator("nav")).toMatchAriaSnapshot(`
- navigation
`);
When to use each mode:
Subset (default) - Most flexible:
- Dynamic lists where count varies
- Checking specific items exist
- When order matters but count doesn't
- region "Cart":
- listitem: text "Laptop"
- listitem: text "Mouse"
deep-equal - Most strict:
- Fixed navigation structures
- Known static content
- When exact structure matters
- navigation:
/children: deep-equal
- link "Home"
- link "Products"
- link "About"
- link "Contact"
No children - Structure only:
- When children are completely dynamic
- Just validating container exists
- main:
- region "Feed"
Partial matching by omitting attributes:
await expect(page.locator("form")).toMatchAriaSnapshot(`
- checkbox "Remember me"
- button "Sign in"
`);
Use regex for dynamic content:
await expect(page.locator("header")).toMatchAriaSnapshot(`
- banner:
- heading /Welcome, .+/ [level=1]
- text /Last login: \\d{4}-\\d{2}-\\d{2}/
`);
When to Use ARIA Snapshots
Use ARIA snapshots for:
- Page layout validation (header, nav, main, footer structure)
- Form structure testing (labels, inputs, buttons in correct hierarchy)
- List and table structures
- Navigation menus
- Modal dialogs and their contents
- Component structure validation
- Accessibility compliance checks
- Regression detection for structural changes
Use individual locators for:
- Specific interactions (clicking, typing)
- Dynamic state changes
- Conditional logic in tests
- Precise timing requirements
Combine both:
await expect(page.locator("dialog")).toMatchAriaSnapshot(`
- dialog "Confirm Action":
- heading "Are you sure?" [level=2]
- text "This action cannot be undone"
- button "Cancel"
- button "Confirm"
`);
await page.getByRole("button", { name: "Confirm" }).click();
Snapshot Generation Workflow
Generate snapshot on first run:
await expect(page.locator("nav")).toMatchAriaSnapshot(``);
Update snapshots after changes:
npx playwright test --update-snapshots
npx playwright test -u
Review generated snapshots:
- Playwright creates patch files showing differences
- Review patches before committing
- Ensures structural changes are intentional
- Documents evolution of page structure
Common Patterns
Page layout validation:
test("homepage structure", async ({ page }) => {
await page.goto("/");
await expect(page.locator("body")).toMatchAriaSnapshot(`
- banner:
- link "Company Logo"
- navigation:
- link "Products"
- link "About"
- link "Contact"
- main:
- region "Hero":
- heading "Transform Your Business" [level=1]
- button "Get Started"
- contentinfo:
- text "© 2024 Company Name"
`);
});
Form structure testing:
test("registration form", async ({ page }) => {
await page.goto("/register");
await expect(page.locator("form")).toMatchAriaSnapshot(`
- textbox "Email"
- textbox "Password" [type="password"]
- textbox "Confirm Password" [type="password"]
- checkbox "I agree to the terms"
- button "Create Account"
`);
});
Table structure validation:
test("data table structure", async ({ page }) => {
await page.goto("/dashboard");
await expect(page.locator("table")).toMatchAriaSnapshot(`
- table:
- rowgroup:
- row:
- columnheader "Name"
- columnheader "Email"
- columnheader "Status"
- rowgroup:
- row:
- cell "John Doe"
- cell "john@example.com"
- cell "Active"
`);
});
Modal dialog validation:
test("confirmation dialog", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Delete" }).click();
await expect(page.locator("role=dialog")).toMatchAriaSnapshot(`
- dialog "Confirm Deletion":
- heading "Delete Item?" [level=2]
- text "This action cannot be undone"
- group "Actions":
- button "Cancel"
- button "Delete" [pressed=true]
`);
});
Integration with Accessibility Testing
ARIA snapshots enforce accessibility:
- Elements must have proper ARIA roles
- Interactive elements need accessible names
- Heading hierarchy must be correct
- Form inputs must be labeled
- Lists must use proper structure
Example - catches accessibility issues:
<button>
<svg><path d="..." /></svg>
</button>
await expect(page.locator("button")).toMatchAriaSnapshot(`
- button "Delete" // ❌ Fails - actual button has no name
`);
Forces fix:
<button aria-label="Delete">
<svg><path d="..." /></svg>
</button>
Best Practices
Scope snapshots appropriately:
await expect(page.locator('nav')).toMatchAriaSnapshot(...)
await expect(page.locator('body')).toMatchAriaSnapshot(...)
await expect(page.locator('header')).toMatchAriaSnapshot(...)
await expect(page.locator('main')).toMatchAriaSnapshot(...)
Use partial matching for dynamic content:
await expect(page.locator("aside")).toMatchAriaSnapshot(`
- region "Cart":
- heading /Cart \\(\\d+ items?\\)/ [level=2]
- list:
- listitem: text /.*/
// Validates structure without checking all items
`);
Combine with web-first assertions:
await expect(page.locator("form")).toMatchAriaSnapshot(`
- textbox "Email"
- button "Submit"
`);
await expect(page.getByRole("button", { name: "Submit" })).toBeDisabled();
Version control snapshots:
- Commit snapshot files with code
- Review snapshot changes in PRs
- Treat snapshot updates as structural changes
- Document why snapshots changed
Locator Strategy Framework
The Locator Resilience Hierarchy
Always choose the highest priority locator that works.
This hierarchy is based on production experience with test maintenance:
-
Role-based locators (HIGHEST PRIORITY)
- Most resilient to UI changes
- Aligned with accessibility best practices
- Self-documenting
page.getByRole("button", { name: "Submit" });
page.getByRole("textbox", { name: "Email" });
page.getByRole("heading", { name: "Dashboard" });
-
Label associations
- Resilient and semantically meaningful
- Follows form best practices
page.getByLabel("Password");
page.getByLabel("Remember me");
-
Placeholder text
- Good for inputs when labels aren't available
- Less stable than labels
page.getByPlaceholder("Enter email address");
-
Text content
- Good for unique text
- Can break if copy changes
page.getByText("Welcome back");
-
Test IDs (when semantic options aren't available)
- Stable but requires code changes
- Use data-testid attribute
page.getByTestId("submit-button");
-
XPath/CSS selectors (LAST RESORT)
- Most fragile
- Only when nothing else works
- Document why you need them
Why This Order Matters
Role-based locators win because:
- They survive CSS refactoring
- They survive DOM restructuring
- They survive text changes (use name option)
- They align with how users actually interact
- They encourage accessible markup
CSS classes lose because:
page.locator(".btn-primary.submit-button.large");
page.getByRole("button", { name: "Submit Order" });
Deep DOM paths lose because:
page.locator("div.container > section > form > button:nth-child(3)");
page.getByRole("form", { name: "Checkout" }).getByRole("button", { name: "Submit" });
Chaining and Filtering Patterns
Narrow scope to reduce brittleness:
const product = page.getByRole("listitem").filter({ hasText: "Product 2" });
await product.getByRole("button", { name: "Add to cart" }).click();
Filtering Strategies:
.filter({ hasText: 'Premium' })
.filter({ has: page.getByRole('button', { name: 'Delete' }) })
.filter({ hasNotText: 'Sold out' })
.filter({ hasText: 'Premium' })
.filter({ has: page.getByRole('img') })
Locator Generation Strategy
Never hand-write complex locators - use codegen to discover optimal selectors:
npx playwright codegen https://example.com
Why generation beats hand-writing:
- Playwright knows the full context of the page
- It tests uniqueness automatically
- It prioritizes according to best practices
- It handles edge cases (multiple matches, hidden elements)
When to regenerate locators:
- After UI refactoring
- When tests become flaky
- When selectors feel brittle
- During test review
Web-First Assertions
The Core Pattern: Always Await Expect
Critical Rule: Use web-first assertions that wait and retry.
await expect(page.getByText("welcome")).toBeVisible();
expect(await page.getByText("welcome").isVisible()).toBe(true);
Why Manual Assertions Fail
The timing problem:
const visible = await page.getByText("welcome").isVisible();
expect(visible).toBe(true);
What happens:
- Queries element at that exact millisecond
- If not ready, returns false immediately
- Test fails even though element appears 100ms later
- Creates flaky tests that fail randomly
The web-first solution:
await expect(page.getByText("welcome")).toBeVisible();
What happens:
- Queries element repeatedly
- Waits up to 5 seconds (default timeout)
- Retries if element isn't ready
- Only fails if truly never appears
- Much more stable
Assertion Strategy Guide
Visibility Assertions:
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
State Assertions:
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();
Content Assertions:
await expect(locator).toHaveText("exact text");
await expect(locator).toContainText("partial");
await expect(locator).toHaveValue("input value");
await expect(locator).toHaveAttribute("href", "/about");
Collection Assertions:
await expect(locator).toHaveCount(5);
Advanced Patterns:
await expect(locator).toBeVisible();
await expect(locator).toHaveClass(/active/);
await expect(locator).not.toBeVisible();
await expect(locator).toBeVisible({ timeout: 10000 });
Soft Assertions Pattern
Use case: Check multiple conditions without stopping on first failure.
await expect.soft(page.getByTestId("status")).toHaveText("Success");
await expect.soft(page.getByTestId("count")).toHaveText("42");
await expect.soft(page.getByTestId("user")).toHaveText("John");
await page.getByRole("link", { name: "next" }).click();
When to use:
- Checking multiple independent conditions
- Form validation with many fields
- Visual regression checks across page
- Gathering comprehensive failure information
Important: All failures compile and display once test completes.
Debugging Strategy Framework
Debugging Decision Tree
When test fails, follow this sequence:
- Check the failure message - What assertion failed?
- Verify locator still matches - Did UI change?
- Check timing - Did content appear after timeout?
- Inspect network - Did API call fail?
- Review auto-wait logs - What actionability check failed?
Parallelism Strategy
Default behavior:
- Tests in different files run in parallel
- Tests in same file run sequentially
- Each test gets isolated browser context
Enable file-level parallelism:
test.describe.configure({ mode: "parallel" });
test("runs concurrently 1", async ({ page }) => {});
test("runs concurrently 2", async ({ page }) => {});
When to use:
- Many independent tests in one file
- No shared state between tests
- Tests are truly isolated
Common Patterns and Edge Cases
Dynamic Content Patterns
await expect(page.getByText("Loaded")).toBeVisible();
await page.waitForTimeout(2000);
Multiple Elements Pattern
const items = page.getByRole("listitem");
const count = await items.count();
for (let i = 0; i < (await items.count()); i++) {
await items.nth(i).click();
}
await page.getByRole("listitem").filter({ hasText: "Premium" }).first().click();
Form Interaction Patterns
await page.getByLabel("Email").fill("user@example.com");
await page.getByLabel("Email").clear();
await page.getByLabel("Email").fill("new@example.com");
await page.getByLabel("Subscribe").check();
await page.getByLabel("Unsubscribe").uncheck();
await page.getByLabel("Option A").check();
await page.getByLabel("Country").selectOption("USA");
await page.getByLabel("Country").selectOption({ label: "United States" });
await page.getByLabel("Upload").setInputFiles("file.pdf");
await page.getByLabel("Upload").setInputFiles(["file1.pdf", "file2.pdf"]);
Network Request Patterns
const request = page.waitForRequest("**/api/data");
await page.getByRole("button", { name: "Load" }).click();
await request;
const response = page.waitForResponse("**/api/data");
await page.getByRole("button", { name: "Load" }).click();
await response;
await page.route("**/api/data", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true }),
}),
);
Navigation Patterns
await page.goto("https://example.com", {
waitUntil: "networkidle",
});
await Promise.all([page.waitForNavigation(), page.getByRole("link", { name: "Next" }).click()]);
await page.waitForURL("**/dashboard");
Anti-Patterns to Avoid
Never Use Arbitrary Waits
await page.waitForTimeout(5000);
await expect(page.getByText("Loaded")).toBeVisible();
Why:
- Arbitrary waits make tests slower than necessary
- Still flaky if content takes longer than wait
- No indication of what you're waiting for
Don't Chain Promises Manually
page
.getByRole("button")
.click()
.then(() => {
return page.getByText("Success").isVisible();
});
await page.getByRole("button").click();
await expect(page.getByText("Success")).toBeVisible();
Don't Re-Query the Same Element
await page.getByRole("button", { name: "Submit" }).click();
await expect(page.getByRole("button", { name: "Submit" })).toBeDisabled();
const submitBtn = page.getByRole("button", { name: "Submit" });
await submitBtn.click();
await expect(submitBtn).toBeDisabled();
Don't Test Implementation Details
expect(component._internalState).toBe("loading");
expect(element.className).toContain("loading");
await expect(page.getByRole("status")).toHaveText("Loading...");
await expect(page.getByRole("progressbar")).toBeVisible();
Don't Use Non-Resilient Selectors
page.locator(".btn-primary");
page.locator("div > section > form > button:nth-child(3)");
page.locator("button");
page.getByRole("button", { name: "Submit Order" });
Integration with playwright-cli
This best practices skill provides the decision-making framework for using playwright-cli effectively. When using playwright-cli:
Apply ARIA snapshot strategy first:
- Start with ARIA snapshots for broad structural validation
- Use
snapshot command to visualize page structure
- Validates semantic structure and accessibility in single assertion
- More resilient than individual locator checks
Apply locator hierarchy:
- Use
snapshot to explore page structure
- Choose highest-priority locator from the hierarchy
- Prefer role-based refs over CSS-based refs
Apply assertion strategy:
- After actions, verify state with appropriate assertions
- Use web-first assertion patterns in generated code
- Don't rely on manual timing
Apply debugging framework:
- Use
console and network commands to diagnose
- Follow debugging decision tree
- Capture traces for complex failures
Apply test isolation:
- Use sessions to separate test contexts
- Don't let state leak between test scenarios
- Mock external dependencies with route commands
When to Apply This Skill
Trigger Scenarios:
- Planning test strategy for new features
- Validating page structure and accessibility
- Choosing between ARIA snapshots vs individual locators
- Choosing locators during test creation
- Reviewing test code for resilience
- Debugging flaky or failing tests
- Optimizing test suite performance
- Advising on test architecture
- Setting up CI/CD test pipelines
- Evaluating test quality and maintainability
Key Indicators:
- Questions about ARIA snapshot usage
- Discussion of structural validation
- Questions about locator selection
- Discussion of test reliability
- Debugging test failures
- Test review and refactoring
- CI/CD configuration for tests
- Test suite optimization
- Cross-browser testing strategy
- Accessibility compliance questions
Output Quality Markers:
- Uses ARIA snapshots for structural validation
- Uses role-based locators over CSS selectors
- Employs web-first assertions consistently
- Implements proper test isolation
- Mocks external dependencies
- Follows locator resilience hierarchy
- Applies debugging decision tree
- Tests across multiple browsers
- Validates accessibility automatically