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".
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
// By default, matches if specified children exist (subset match)awaitexpect(page.locator("ul")).toMatchAriaSnapshot(`
- list:
- listitem: text "Feature A"
- listitem: text "Feature C"
`);
// ✅ Passes even if Feature B exists between them// ✅ Passes even if more items exist after Feature C
Control child matching with /children property:
The /children property controls how strictly child elements are matched:
1. Subset matching (default):
awaitexpect(page.locator("ul")).toMatchAriaSnapshot(`
- list:
/children:
- listitem: text "Feature A"
- listitem: text "Feature B"
`);
// ✅ Matches if A and B exist in order// ✅ Other items can exist before, between, or after
2. Exact matching with deep-equal:
awaitexpect(page.locator("ul")).toMatchAriaSnapshot(`
- list:
/children: deep-equal
- listitem: text "Feature A"
- listitem: text "Feature B"
- listitem: text "Feature C"
`);
// ✅ Only passes if exactly these 3 items exist in this exact order// ❌ Fails if Feature D exists// ❌ Fails if order changes
3. No children validation:
awaitexpect(page.locator("nav")).toMatchAriaSnapshot(`
- navigation
`);
// ✅ Validates role exists// ✅ Doesn't check any children at all
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
// Check shopping cart has key items, ignore quantity
- region "Cart":
- listitem: text "Laptop"
- listitem: text "Mouse"// Other items OK, we just care these exist
deep-equal - Most strict:
Fixed navigation structures
Known static content
When exact structure matters
// Main navigation must be exactly this
- navigation:
/children: deep-equal
- link "Home"
- link "Products"
- link "About"
- link "Contact"// Fails if "Careers" link is added
No children - Structure only:
When children are completely dynamic
Just validating container exists
// User feed could have any number of posts
- main:
- region "Feed"// Don't validate children at all
Partial matching by omitting attributes:
// Match structure regardless of checkbox stateawaitexpect(page.locator("form")).toMatchAriaSnapshot(`
- checkbox "Remember me"
- button "Sign in"
`);
// ✅ Passes whether checkbox is checked or unchecked
Use regex for dynamic content:
awaitexpect(page.locator("header")).toMatchAriaSnapshot(`
- banner:
- heading /Welcome, .+/ [level=1]
- text /Last login: \\d{4}-\\d{2}-\\d{2}/
`);
// Matches any username and date pattern
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:
// Validate structureawaitexpect(page.locator("dialog")).toMatchAriaSnapshot(`
- dialog "Confirm Action":
- heading "Are you sure?" [level=2]
- text "This action cannot be undone"
- button "Cancel"
- button "Confirm"
`);
// Then interact with specific elementsawait page.getByRole("button", { name: "Confirm" }).click();
Snapshot Generation Workflow
Generate snapshot on first run:
// Write test with empty stringawaitexpect(page.locator("nav")).toMatchAriaSnapshot(``);
// Run with update flag// npx playwright test --update-snapshots// Playwright generates snapshot automatically
Update snapshots after changes:
# Update all snapshots
npx playwright test --update-snapshots
# Short form
npx playwright test -u
<!-- Bad HTML - no accessible name --><button><svg><pathd="..." /></svg></button>
// Snapshot test fails - button has no accessible nameawaitexpect(page.locator("button")).toMatchAriaSnapshot(`
- button "Delete" // ❌ Fails - actual button has no name
`);
Forces fix:
<!-- Good HTML - accessible name provided --><buttonaria-label="Delete"><svg><pathd="..." /></svg></button>
Best Practices
Scope snapshots appropriately:
// ✅ Good - specific regionawaitexpect(page.locator('nav')).toMatchAriaSnapshot(...)
// ❌ Too broad - entire pageawaitexpect(page.locator('body')).toMatchAriaSnapshot(...)
// ✅ Better - specific sectionsawaitexpect(page.locator('header')).toMatchAriaSnapshot(...)
awaitexpect(page.locator('main')).toMatchAriaSnapshot(...)
Use partial matching for dynamic content:
// Shopping cart with variable item countawaitexpect(page.locator("aside")).toMatchAriaSnapshot(`
- region "Cart":
- heading /Cart \\(\\d+ items?\\)/ [level=2]
- list:
- listitem: text /.*/
// Validates structure without checking all items
`);
Test fails even though element appears 100ms later
Creates flaky tests that fail randomly
The web-first solution:
// ✅ RIGHT - polls until timeoutawaitexpect(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:
awaitexpect(locator).toBeVisible(); // Element is visibleawaitexpect(locator).toBeHidden(); // Element is not visible
State Assertions:
awaitexpect(locator).toBeEnabled(); // Interactive element enabledawaitexpect(locator).toBeDisabled(); // Interactive element disabledawaitexpect(locator).toBeChecked(); // Checkbox/radio checkedawaitexpect(locator).toBeFocused(); // Element has keyboard focus
Use case: Check multiple conditions without stopping on first failure.
// Continue test even if assertions failawait expect.soft(page.getByTestId("status")).toHaveText("Success");
await expect.soft(page.getByTestId("count")).toHaveText("42");
await expect.soft(page.getByTestId("user")).toHaveText("John");
// Test continues - all failures reported at endawait 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?