
Playwright Core
- 445 installs
- 343 repo stars
- Updated July 2, 2026
- testdino-hq/playwright-skill
playwright-core is a TestDino agent skill pack with 46 Playwright reference guides for writing and debugging reliable E2E, API, component, visual, accessibility, and security tests in TypeScript and JavaScript.
About
playwright-core is the core pack of testdino-hq/playwright-skill, bundling 46 production-tested Playwright guides agents load on demand. It codifies ten Golden Rules—including getByRole locators, web-first assertions, isolated fixtures, baseURL config, CI retries, and on-first-retry traces—and indexes guides for locators, assertions, authentication, API testing, network mocking, visual regression, accessibility, framework recipes for React, Next.js, Vue, and Angular, plus debugging and flaky-test remediation. The MIT-licensed pack targets authorized apps you own or may test in staging. Developers reach for playwright-core when agents need opinionated Playwright patterns instead of generic test snippets that encourage waitForTimeout anti-patterns.
- playwright-core
Playwright Core by the numbers
- 445 all-time installs (skills.sh)
- +15 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #956 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/testdino-hq/playwright-skill --skill playwright-coreAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 445 |
|---|---|
| repo stars | ★ 343 |
| Last updated | July 2, 2026 |
| Repository | testdino-hq/playwright-skill ↗ |
How do you write reliable Playwright end-to-end tests?
Use playwright-core for development tasks
Who is it for?
QA and frontend engineers authoring Playwright E2E, API, component, or accessibility tests who want TestDino's 46-guide core patterns.
Skip if: Teams standardized on Cypress or Selenium without Playwright adoption, or unauthorized testing against third-party production sites.
When should I use this skill?
A developer asks to write Playwright tests, fix flaky E2E specs, choose locators, mock network calls, or debug trace failures.
What you get
Playwright test files, locator strategies, fixture setups, trace-enabled configs, and debugging workflows aligned to Golden Rules.
- Playwright test specs with recommended locators and fixtures
- playwright.config patterns with baseURL, retries, and traces
- Debugging and flaky-test remediation checklists
By the numbers
- Core pack contains 46 Playwright reference guides
- Documents 10 Golden Rules for reliable Playwright test design
- Parent playwright-skill repository ships 5 installable skill packs total
Files
Accessibility Testing
When to use: Every project. Accessibility is not a feature — it is a quality baseline. Integrate automated checks (axe-core) into every test suite and supplement with manual keyboard/screen-reader verification for critical flows.
Prerequisites: core/configuration.md, core/locators.md
Quick Reference
// Install: npm install -D @axe-core/playwright
import AxeBuilder from '@axe-core/playwright';
// Full page scan
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
// Scoped scan — only the main content area
const results = await new AxeBuilder({ page }).include('#main-content').analyze();
// WCAG AA only
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
// Exclude known issues during migration
const results = await new AxeBuilder({ page }).disableRules(['color-contrast']).analyze();
// Playwright 1.59+: capture the accessibility tree for the whole page
const pageTree = await page.ariaSnapshot();
// Or scope it to one region
const dialogTree = await page.getByRole('dialog', { name: 'Checkout' }).ariaSnapshot();
// Playwright 1.60+: assert the whole page's aria tree, and capture bounding boxes
await expect(page).toMatchAriaSnapshot();
const treeWithBoxes = await page.ariaSnapshot({ boxes: true });Patterns
ARIA Snapshots For Structure Checks
Use when: You want to verify the accessibility tree shape of a page, region, dialog, or widget in addition to running axe. Avoid when: You only need rule-based WCAG checks. Start with axe for broad coverage, then use ARIA snapshots for high-value structure assertions.
Playwright 1.59 adds page.ariaSnapshot() as a shortcut for capturing the page-level accessibility tree, and expands locator.ariaSnapshot() with more control over depth and snapshot mode. This is useful for menus, dialogs, composite widgets, and other components where semantic structure matters as much as raw DOM shape.
TypeScript
import { test, expect } from '@playwright/test';
test('checkout dialog exposes the expected accessibility structure', async ({ page }) => {
await page.goto('/checkout');
await page.getByRole('button', { name: 'Open checkout' }).click();
const dialogTree = await page
.getByRole('dialog', { name: 'Checkout' })
.ariaSnapshot();
expect(dialogTree).toContain('heading "Checkout"');
expect(dialogTree).toContain('button "Apply coupon"');
});Snapshot options
When the full accessibility tree is too noisy, use the newer options to limit the result to the level of detail you actually care about.
const menuTree = await page.getByRole('menu', { name: 'Account' }).ariaSnapshot({
depth: 2,
});
const summaryTree = await page.getByRole('dialog', { name: 'Checkout' }).ariaSnapshot({
mode: 'summary',
});Use smaller snapshots for stable assertions. Deep full-tree snapshots are powerful, but they can become brittle if the component structure changes often.
JavaScript
const { test, expect } = require('@playwright/test');
test('checkout dialog exposes the expected accessibility structure', async ({ page }) => {
await page.goto('/checkout');
await page.getByRole('button', { name: 'Open checkout' }).click();
const dialogTree = await page
.getByRole('dialog', { name: 'Checkout' })
.ariaSnapshot();
expect(dialogTree).toContain('heading "Checkout"');
expect(dialogTree).toContain('button "Apply coupon"');
});Page-Level Aria Snapshot Assertions (Playwright 1.60+)
Use when: You want to assert the whole page's accessibility tree against a stored snapshot, or you need element bounding boxes alongside the tree (useful for AI/agent consumption and layout-aware checks). Avoid when: A scoped locator assertion is enough — whole-page snapshots are broad and change often. Prefer asserting a stable region.
Playwright 1.60 lets expect(page).toMatchAriaSnapshot() run at the page level (equivalent to asserting against page.locator('body')), and adds a boxes option to ariaSnapshot() that appends each element's bounding box.
TypeScript
import { test, expect } from '@playwright/test';
test('home page matches its aria snapshot', async ({ page }) => {
await page.goto('/');
// Page-level assertion — no need to target body explicitly.
// First run writes the snapshot; later runs compare against it.
await expect(page).toMatchAriaSnapshot();
});
test('capture aria tree with bounding boxes', async ({ page }) => {
await page.goto('/dashboard');
// `boxes: true` appends each node's [x, y, width, height] to the snapshot
const treeWithBoxes = await page.ariaSnapshot({ boxes: true });
expect(treeWithBoxes).toContain('heading "Dashboard"');
});JavaScript
const { test, expect } = require('@playwright/test');
test('home page matches its aria snapshot', async ({ page }) => {
await page.goto('/');
await expect(page).toMatchAriaSnapshot();
});Page-leveltoMatchAriaSnapshot()is broad — scope to a region (expect(page.getByRole('main')).toMatchAriaSnapshot()) when you want assertions that survive unrelated layout changes.
axe-core/playwright Integration
Use when: You want automated WCAG violation detection on any page or component. This is your first line of defense and should run in every test suite. Avoid when: You need to verify subjective UX quality (reading order, cognitive load, plain language). axe-core catches structural violations, not usability problems.
axe-core detects roughly 30-40% of WCAG issues automatically. That 30-40% includes the most common and egregious violations: missing alt text, broken label associations, invalid ARIA, and contrast failures. Catching these automatically frees you to spend manual effort on the harder problems.
TypeScript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('accessibility', () => {
test('home page has no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('dashboard has no accessibility violations after login', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Scan after the page is fully interactive
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('report violations with helpful details on failure', async ({ page }) => {
await page.goto('/products');
const results = await new AxeBuilder({ page }).analyze();
// Format violations for readable test output
const violationSummary = results.violations.map((v) => ({
rule: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.length,
help: v.helpUrl,
}));
expect(results.violations, JSON.stringify(violationSummary, null, 2)).toEqual([]);
});
});JavaScript
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test.describe('accessibility', () => {
test('home page has no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('report violations with helpful details on failure', async ({ page }) => {
await page.goto('/products');
const results = await new AxeBuilder({ page }).analyze();
const violationSummary = results.violations.map((v) => ({
rule: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.length,
help: v.helpUrl,
}));
expect(results.violations, JSON.stringify(violationSummary, null, 2)).toEqual([]);
});
});Scanning Specific Regions
Use when: You want to focus axe-core on a specific component (new feature, redesigned section) or exclude areas you do not control (third-party widgets, ads, embedded iframes). Avoid when: You want a full-page baseline. Scan everything first, then narrow down.
TypeScript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('scoped accessibility scans', () => {
test('scan only the checkout form', async ({ page }) => {
await page.goto('/checkout');
const results = await new AxeBuilder({ page })
.include('#checkout-form')
.analyze();
expect(results.violations).toEqual([]);
});
test('scan page excluding third-party chat widget', async ({ page }) => {
await page.goto('/support');
const results = await new AxeBuilder({ page })
.exclude('#intercom-widget')
.exclude('.third-party-ads')
.analyze();
expect(results.violations).toEqual([]);
});
test('scan multiple specific regions', async ({ page }) => {
await page.goto('/dashboard');
// Include multiple areas — each is scanned independently
const results = await new AxeBuilder({ page })
.include('#navigation')
.include('#main-content')
.include('#footer')
.exclude('.ad-banner')
.analyze();
expect(results.violations).toEqual([]);
});
test('scan a modal after it opens', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Delete account' }).click();
// Wait for the modal to be fully rendered
await expect(page.getByRole('dialog', { name: 'Confirm deletion' })).toBeVisible();
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
expect(results.violations).toEqual([]);
});
});JavaScript
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test.describe('scoped accessibility scans', () => {
test('scan only the checkout form', async ({ page }) => {
await page.goto('/checkout');
const results = await new AxeBuilder({ page })
.include('#checkout-form')
.analyze();
expect(results.violations).toEqual([]);
});
test('scan page excluding third-party chat widget', async ({ page }) => {
await page.goto('/support');
const results = await new AxeBuilder({ page })
.exclude('#intercom-widget')
.exclude('.third-party-ads')
.analyze();
expect(results.violations).toEqual([]);
});
test('scan a modal after it opens', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Delete account' }).click();
await expect(page.getByRole('dialog', { name: 'Confirm deletion' })).toBeVisible();
const results = await new AxeBuilder({ page })
.include('[role="dialog"]')
.analyze();
expect(results.violations).toEqual([]);
});
});WCAG Compliance Levels
Use when: Your project targets a specific WCAG compliance level (most target AA). Use tags to limit axe-core to the rules that matter for your compliance requirement. Avoid when: You want the broadest possible scan. Omitting withTags() runs all rules, including best practices beyond WCAG.
Tag reference:
wcag2a— WCAG 2.0 Level A (minimum)wcag2aa— WCAG 2.0 Level AA (standard target for most organizations)wcag2aaa— WCAG 2.0 Level AAA (strict; rarely required)wcag21a,wcag21aa,wcag21aaa— WCAG 2.1 additionswcag22aa— WCAG 2.2 additionsbest-practice— not WCAG, but recommended patterns
TypeScript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('WCAG compliance levels', () => {
test('meets WCAG 2.1 AA (standard compliance target)', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('meets WCAG 2.2 AA (latest standard)', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('meets WCAG AAA (strict — use for government or healthcare)', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag2aaa', 'wcag21a', 'wcag21aa', 'wcag21aaa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('best practices beyond WCAG', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['best-practice'])
.analyze();
// Use soft assertion — best practices are advisory, not blocking
expect.soft(results.violations).toEqual([]);
});
});JavaScript
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test.describe('WCAG compliance levels', () => {
test('meets WCAG 2.1 AA (standard compliance target)', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('meets WCAG 2.2 AA (latest standard)', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});
});Disabling Specific Rules
Use when: Migrating a legacy app to accessibility compliance incrementally. You have known violations documented in a tracking system and want the test suite to catch new regressions without failing on existing known issues. Avoid when: Hiding violations you do not intend to fix. Every disabled rule should have a tracking ticket.
TypeScript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
// Centralize known exceptions — makes them visible and trackable
const KNOWN_ISSUES = {
// JIRA-1234: Legacy header component, scheduled for redesign Q2
rules: ['color-contrast'],
// JIRA-1235: Third-party date picker has no label association
selectors: ['#legacy-datepicker'],
};
test.describe('accessibility with known exceptions', () => {
test('no new violations (excluding tracked known issues)', async ({ page }) => {
await page.goto('/dashboard');
const results = await new AxeBuilder({ page })
.disableRules(KNOWN_ISSUES.rules)
.exclude(KNOWN_ISSUES.selectors[0])
.analyze();
expect(results.violations).toEqual([]);
});
test('verify known issues still exist (remove when fixed)', async ({ page }) => {
await page.goto('/dashboard');
// Scan ONLY for the known issues to confirm they still exist
// When this test fails (violations disappear), remove the exception
const results = await new AxeBuilder({ page })
.withRules(KNOWN_ISSUES.rules)
.analyze();
if (results.violations.length === 0) {
console.warn(
'Known accessibility issues appear to be fixed. ' +
'Remove exceptions from KNOWN_ISSUES and close tracking tickets.'
);
}
});
});JavaScript
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
const KNOWN_ISSUES = {
rules: ['color-contrast'],
selectors: ['#legacy-datepicker'],
};
test.describe('accessibility with known exceptions', () => {
test('no new violations (excluding tracked known issues)', async ({ page }) => {
await page.goto('/dashboard');
const results = await new AxeBuilder({ page })
.disableRules(KNOWN_ISSUES.rules)
.exclude(KNOWN_ISSUES.selectors[0])
.analyze();
expect(results.violations).toEqual([]);
});
test('verify known issues still exist (remove when fixed)', async ({ page }) => {
await page.goto('/dashboard');
const results = await new AxeBuilder({ page })
.withRules(KNOWN_ISSUES.rules)
.analyze();
if (results.violations.length === 0) {
console.warn(
'Known accessibility issues appear to be fixed. ' +
'Remove exceptions from KNOWN_ISSUES and close tracking tickets.'
);
}
});
});Keyboard Navigation Testing
Use when: Verifying that all interactive elements are reachable and operable via keyboard alone. This is critical for motor-impaired users and power users who navigate without a mouse. Avoid when: Never skip this. Automated tools cannot fully verify keyboard navigation — this requires behavioral tests.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('keyboard navigation', () => {
test('tab order follows logical reading order', async ({ page }) => {
await page.goto('/login');
// Tab through interactive elements and verify focus order
await page.keyboard.press('Tab');
await expect(page.getByLabel('Email')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Password')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Forgot password?' })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Sign in' })).toBeFocused();
// Verify Enter activates the focused button
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).focus();
await page.keyboard.press('Enter');
await page.waitForURL('/dashboard');
});
test('skip navigation link moves focus to main content', async ({ page }) => {
await page.goto('/');
// First Tab should land on the skip link (visually hidden until focused)
await page.keyboard.press('Tab');
const skipLink = page.getByRole('link', { name: 'Skip to main content' });
await expect(skipLink).toBeFocused();
// Activating the skip link moves focus past the nav
await page.keyboard.press('Enter');
await expect(page.locator('#main-content')).toBeFocused();
});
test('dropdown menu operates with keyboard', async ({ page }) => {
await page.goto('/dashboard');
const menuButton = page.getByRole('button', { name: 'User menu' });
await menuButton.focus();
// Open menu with Enter or Space
await page.keyboard.press('Enter');
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
// Arrow keys navigate menu items
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', { name: 'Profile' })).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeFocused();
// Escape closes the menu and returns focus to the trigger
await page.keyboard.press('Escape');
await expect(menu).not.toBeVisible();
await expect(menuButton).toBeFocused();
});
test('keyboard shortcuts work correctly', async ({ page }) => {
await page.goto('/editor');
// Ctrl+S / Cmd+S triggers save
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
const saveResponse = page.waitForResponse('**/api/save');
await page.keyboard.press(`${modifier}+s`);
await saveResponse;
await expect(page.getByText('Saved')).toBeVisible();
});
test('no keyboard traps in form navigation', async ({ page }) => {
await page.goto('/complex-form');
// Tab through every field — focus should never get stuck
const interactiveElements = page.locator(
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const count = await interactiveElements.count();
for (let i = 0; i < count; i++) {
await page.keyboard.press('Tab');
// Verify something is focused (focus did not get trapped or lost)
const focused = page.locator(':focus');
await expect(focused).toBeAttached();
}
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('keyboard navigation', () => {
test('tab order follows logical reading order', async ({ page }) => {
await page.goto('/login');
await page.keyboard.press('Tab');
await expect(page.getByLabel('Email')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Password')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: 'Forgot password?' })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Sign in' })).toBeFocused();
});
test('dropdown menu operates with keyboard', async ({ page }) => {
await page.goto('/dashboard');
const menuButton = page.getByRole('button', { name: 'User menu' });
await menuButton.focus();
await page.keyboard.press('Enter');
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem', { name: 'Profile' })).toBeFocused();
await page.keyboard.press('Escape');
await expect(menu).not.toBeVisible();
await expect(menuButton).toBeFocused();
});
test('no keyboard traps in form navigation', async ({ page }) => {
await page.goto('/complex-form');
const interactiveElements = page.locator(
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const count = await interactiveElements.count();
for (let i = 0; i < count; i++) {
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(focused).toBeAttached();
}
});
});Screen Reader Testing Patterns
Use when: Verifying that ARIA attributes, live regions, and roles produce the correct accessible experience. You cannot run a real screen reader in CI, but you can verify the semantic structure that screen readers depend on. Avoid when: You want to test actual screen reader output (use manual testing with NVDA/VoiceOver for that).
TypeScript
import { test, expect } from '@playwright/test';
test.describe('screen reader semantics', () => {
test('ARIA labels provide meaningful context', async ({ page }) => {
await page.goto('/dashboard');
// Navigation landmarks must have distinct labels
const mainNav = page.getByRole('navigation', { name: 'Main' });
const footerNav = page.getByRole('navigation', { name: 'Footer' });
await expect(mainNav).toBeVisible();
await expect(footerNav).toBeVisible();
// Regions should have accessible names
const mainRegion = page.getByRole('main');
await expect(mainRegion).toBeAttached();
// Buttons with icons must have accessible names
const closeButton = page.getByRole('button', { name: 'Close' });
await expect(closeButton).toBeAttached();
// Images must have alt text (getByRole('img') only matches with accessible name)
const logo = page.getByRole('img', { name: 'Company logo' });
await expect(logo).toBeVisible();
});
test('live regions announce dynamic content changes', async ({ page }) => {
await page.goto('/notifications');
// Verify the live region exists before triggering content
const statusRegion = page.locator('[aria-live="polite"]');
await expect(statusRegion).toBeAttached();
// Trigger an action that updates the live region
await page.getByRole('button', { name: 'Save' }).click();
// Verify the live region received the update
await expect(statusRegion).toHaveText('Changes saved successfully');
});
test('alert live region announces errors immediately', async ({ page }) => {
await page.goto('/checkout');
// aria-live="assertive" or role="alert" interrupts the screen reader
await page.getByRole('button', { name: 'Place order' }).click();
const alert = page.getByRole('alert');
await expect(alert).toBeVisible();
await expect(alert).toHaveText('Payment method is required');
});
test('expandable sections announce their state', async ({ page }) => {
await page.goto('/faq');
const faqButton = page.getByRole('button', { name: 'How do I reset my password?' });
// aria-expanded should reflect the current state
await expect(faqButton).toHaveAttribute('aria-expanded', 'false');
await faqButton.click();
await expect(faqButton).toHaveAttribute('aria-expanded', 'true');
// The controlled panel should be visible
const panel = page.locator(`#${await faqButton.getAttribute('aria-controls')}`);
await expect(panel).toBeVisible();
});
test('page headings form a logical hierarchy', async ({ page }) => {
await page.goto('/about');
// There should be exactly one h1
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
// Heading levels should not skip (h1 -> h3 without h2 is a violation)
const headings = page.getByRole('heading');
const count = await headings.count();
let previousLevel = 0;
for (let i = 0; i < count; i++) {
const heading = headings.nth(i);
const tagName = await heading.evaluate((el) => el.tagName.toLowerCase());
const level = parseInt(tagName.replace('h', ''), 10);
// Level can go up by 1 or drop to any lower level, but never skip forward
if (level > previousLevel + 1 && previousLevel !== 0) {
throw new Error(
`Heading hierarchy skipped from h${previousLevel} to h${level}: "${await heading.textContent()}"`
);
}
previousLevel = level;
}
});
test('table has proper headers and caption', async ({ page }) => {
await page.goto('/reports');
const table = page.getByRole('table', { name: 'Monthly revenue' });
await expect(table).toBeVisible();
// Column headers
const columnHeaders = table.getByRole('columnheader');
await expect(columnHeaders).toHaveCount(4);
await expect(columnHeaders.first()).toHaveText('Month');
// Row headers (if applicable)
const rowHeaders = table.getByRole('rowheader');
await expect(rowHeaders.first()).toHaveText('January');
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('screen reader semantics', () => {
test('ARIA labels provide meaningful context', async ({ page }) => {
await page.goto('/dashboard');
const mainNav = page.getByRole('navigation', { name: 'Main' });
const footerNav = page.getByRole('navigation', { name: 'Footer' });
await expect(mainNav).toBeVisible();
await expect(footerNav).toBeVisible();
const closeButton = page.getByRole('button', { name: 'Close' });
await expect(closeButton).toBeAttached();
const logo = page.getByRole('img', { name: 'Company logo' });
await expect(logo).toBeVisible();
});
test('live regions announce dynamic content changes', async ({ page }) => {
await page.goto('/notifications');
const statusRegion = page.locator('[aria-live="polite"]');
await expect(statusRegion).toBeAttached();
await page.getByRole('button', { name: 'Save' }).click();
await expect(statusRegion).toHaveText('Changes saved successfully');
});
test('expandable sections announce their state', async ({ page }) => {
await page.goto('/faq');
const faqButton = page.getByRole('button', { name: 'How do I reset my password?' });
await expect(faqButton).toHaveAttribute('aria-expanded', 'false');
await faqButton.click();
await expect(faqButton).toHaveAttribute('aria-expanded', 'true');
});
});Color Contrast Verification
Use when: Ensuring text and UI components meet WCAG contrast ratio requirements. axe-core checks contrast automatically, but you may need explicit checks for dynamic themes, dark mode, or brand color changes. Avoid when: axe-core's built-in contrast rule covers your use case. Only add explicit checks for dynamic color changes axe cannot observe in a single scan.
TypeScript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('color contrast', () => {
test('light theme meets contrast requirements', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
test('dark theme meets contrast requirements', async ({ page }) => {
await page.goto('/');
// Activate dark mode
await page.getByRole('button', { name: 'Toggle dark mode' }).click();
// Wait for theme transition to complete
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
test('high contrast mode meets AAA contrast requirements', async ({ page }) => {
await page.goto('/settings/display');
await page.getByRole('checkbox', { name: 'High contrast' }).check();
// AAA requires 7:1 for normal text, 4.5:1 for large text
const results = await new AxeBuilder({ page })
.withTags(['wcag2aaa'])
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
test('focus indicators are visible', async ({ page }) => {
await page.goto('/');
// Tab to an element and verify focus outline has sufficient contrast
await page.keyboard.press('Tab');
const focusedElement = page.locator(':focus');
// Verify the outline is not transparent or zero-width
const outline = await focusedElement.evaluate((el) => {
const styles = window.getComputedStyle(el);
return {
outlineStyle: styles.outlineStyle,
outlineWidth: styles.outlineWidth,
outlineColor: styles.outlineColor,
boxShadow: styles.boxShadow,
};
});
// Focus must be visible — either outline or box-shadow
const hasVisibleFocus =
(outline.outlineStyle !== 'none' && outline.outlineWidth !== '0px') ||
outline.boxShadow !== 'none';
expect(hasVisibleFocus, 'Focused element must have a visible focus indicator').toBe(true);
});
});JavaScript
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test.describe('color contrast', () => {
test('light theme meets contrast requirements', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
test('dark theme meets contrast requirements', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Toggle dark mode' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
test('focus indicators are visible', async ({ page }) => {
await page.goto('/');
await page.keyboard.press('Tab');
const focusedElement = page.locator(':focus');
const outline = await focusedElement.evaluate((el) => {
const styles = window.getComputedStyle(el);
return {
outlineStyle: styles.outlineStyle,
outlineWidth: styles.outlineWidth,
boxShadow: styles.boxShadow,
};
});
const hasVisibleFocus =
(outline.outlineStyle !== 'none' && outline.outlineWidth !== '0px') ||
outline.boxShadow !== 'none';
expect(hasVisibleFocus, 'Focused element must have a visible focus indicator').toBe(true);
});
});Focus Trap Testing
Use when: Testing modals, dialogs, dropdown menus, slide-over panels, and any overlay that must trap focus within itself to prevent users from accidentally interacting with background content. Avoid when: The component does not overlay content (inline expandable sections do not need focus traps).
TypeScript
import { test, expect } from '@playwright/test';
test.describe('focus trap', () => {
test('modal traps focus within itself', async ({ page }) => {
await page.goto('/settings');
// Open the modal
await page.getByRole('button', { name: 'Delete account' }).click();
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
await expect(dialog).toBeVisible();
// Focus should move into the dialog automatically
const firstFocusable = dialog.getByRole('button', { name: 'Cancel' });
await expect(firstFocusable).toBeFocused();
// Tab should cycle within the dialog
await page.keyboard.press('Tab');
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeFocused();
// Tab again wraps back to the first focusable element
await page.keyboard.press('Tab');
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();
// Shift+Tab wraps to the last focusable element
await page.keyboard.press('Shift+Tab');
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeFocused();
// Escape closes the dialog
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
// Focus returns to the trigger element
await expect(page.getByRole('button', { name: 'Delete account' })).toBeFocused();
});
test('dropdown menu traps focus and returns it on close', async ({ page }) => {
await page.goto('/dashboard');
const trigger = page.getByRole('button', { name: 'Actions' });
await trigger.click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
// First menu item receives focus
await expect(page.getByRole('menuitem').first()).toBeFocused();
// ArrowDown moves through items
await page.keyboard.press('ArrowDown');
await expect(page.getByRole('menuitem').nth(1)).toBeFocused();
// Escape closes and returns focus
await page.keyboard.press('Escape');
await expect(menu).not.toBeVisible();
await expect(trigger).toBeFocused();
});
test('background content is inert when modal is open', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Delete account' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Background content should have aria-hidden="true" or be inert
const mainContent = page.locator('main');
const isHidden = await mainContent.evaluate((el) => {
return el.getAttribute('aria-hidden') === 'true' || el.hasAttribute('inert');
});
expect(isHidden, 'Background content must be hidden from assistive technology').toBe(true);
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('focus trap', () => {
test('modal traps focus within itself', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Delete account' }).click();
const dialog = page.getByRole('dialog', { name: 'Confirm deletion' });
await expect(dialog).toBeVisible();
const firstFocusable = dialog.getByRole('button', { name: 'Cancel' });
await expect(firstFocusable).toBeFocused();
await page.keyboard.press('Tab');
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeFocused();
await page.keyboard.press('Tab');
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Delete account' })).toBeFocused();
});
test('background content is inert when modal is open', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Delete account' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
const mainContent = page.locator('main');
const isHidden = await mainContent.evaluate((el) => {
return el.getAttribute('aria-hidden') === 'true' || el.hasAttribute('inert');
});
expect(isHidden, 'Background content must be hidden from assistive technology').toBe(true);
});
});Accessible Forms
Use when: Testing that forms are usable by assistive technology. Every form field must have an associated label, error messages must be programmatically linked, and required fields must be announced. Avoid when: Never skip this for any form.
TypeScript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('accessible forms', () => {
test('all form fields have associated labels', async ({ page }) => {
await page.goto('/register');
// Every input should be reachable via getByLabel (proves label association)
await expect(page.getByLabel('First name')).toBeVisible();
await expect(page.getByLabel('Last name')).toBeVisible();
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
// Run axe to catch any we missed
const results = await new AxeBuilder({ page })
.include('form')
.withRules(['label', 'label-title-only'])
.analyze();
expect(results.violations).toEqual([]);
});
test('required fields are announced to screen readers', async ({ page }) => {
await page.goto('/register');
// Required fields must have aria-required="true" or the required attribute
const emailField = page.getByLabel('Email');
const hasRequired = await emailField.evaluate((el) => {
return el.hasAttribute('required') || el.getAttribute('aria-required') === 'true';
});
expect(hasRequired, 'Email field must be marked as required').toBe(true);
});
test('error messages are linked to their fields via aria-describedby', async ({ page }) => {
await page.goto('/register');
// Submit empty form to trigger validation
await page.getByRole('button', { name: 'Create account' }).click();
// The error message should be visible
const errorMessage = page.getByText('Email is required');
await expect(errorMessage).toBeVisible();
// The error must be linked to the field via aria-describedby
const emailField = page.getByLabel('Email');
const describedBy = await emailField.getAttribute('aria-describedby');
expect(describedBy).toBeTruthy();
// The id of the error message matches the aria-describedby value
const errorId = await errorMessage.getAttribute('id');
expect(describedBy).toContain(errorId);
// The field should also indicate invalid state
await expect(emailField).toHaveAttribute('aria-invalid', 'true');
});
test('form error summary is announced and links to fields', async ({ page }) => {
await page.goto('/register');
await page.getByRole('button', { name: 'Create account' }).click();
// Error summary should appear with role="alert" for immediate announcement
const errorSummary = page.getByRole('alert');
await expect(errorSummary).toBeVisible();
await expect(errorSummary).toContainText('Please fix the following errors');
// Error summary links should move focus to the corresponding field
await errorSummary.getByRole('link', { name: 'Email is required' }).click();
await expect(page.getByLabel('Email')).toBeFocused();
});
test('autocomplete attributes are set for common fields', async ({ page }) => {
await page.goto('/checkout');
// Autocomplete helps password managers and assistive tech fill forms
await expect(page.getByLabel('Full name')).toHaveAttribute('autocomplete', 'name');
await expect(page.getByLabel('Email')).toHaveAttribute('autocomplete', 'email');
await expect(page.getByLabel('Street address')).toHaveAttribute('autocomplete', 'street-address');
await expect(page.getByLabel('Postal code')).toHaveAttribute('autocomplete', 'postal-code');
});
test('fieldsets group related fields with legends', async ({ page }) => {
await page.goto('/checkout');
// Related fields should be grouped in fieldsets with legends
const shippingGroup = page.getByRole('group', { name: 'Shipping address' });
await expect(shippingGroup).toBeVisible();
await expect(shippingGroup.getByLabel('Street address')).toBeVisible();
const billingGroup = page.getByRole('group', { name: 'Billing address' });
await expect(billingGroup).toBeVisible();
});
});JavaScript
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test.describe('accessible forms', () => {
test('all form fields have associated labels', async ({ page }) => {
await page.goto('/register');
await expect(page.getByLabel('First name')).toBeVisible();
await expect(page.getByLabel('Last name')).toBeVisible();
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
const results = await new AxeBuilder({ page })
.include('form')
.withRules(['label', 'label-title-only'])
.analyze();
expect(results.violations).toEqual([]);
});
test('error messages are linked to their fields via aria-describedby', async ({ page }) => {
await page.goto('/register');
await page.getByRole('button', { name: 'Create account' }).click();
const errorMessage = page.getByText('Email is required');
await expect(errorMessage).toBeVisible();
const emailField = page.getByLabel('Email');
const describedBy = await emailField.getAttribute('aria-describedby');
expect(describedBy).toBeTruthy();
const errorId = await errorMessage.getAttribute('id');
expect(describedBy).toContain(errorId);
await expect(emailField).toHaveAttribute('aria-invalid', 'true');
});
test('autocomplete attributes are set for common fields', async ({ page }) => {
await page.goto('/checkout');
await expect(page.getByLabel('Full name')).toHaveAttribute('autocomplete', 'name');
await expect(page.getByLabel('Email')).toHaveAttribute('autocomplete', 'email');
await expect(page.getByLabel('Street address')).toHaveAttribute('autocomplete', 'street-address');
});
});Accessibility in CI
Use when: You want accessibility violations to fail builds, preventing regressions from reaching production. Every team should gate their CI pipeline on accessibility. Avoid when: Never. If you only run accessibility checks locally, they will be skipped.
TypeScript
// playwright.config.ts — dedicated accessibility project
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'accessibility',
testMatch: '**/*.a11y.spec.ts',
use: {
browserName: 'chromium', // axe-core works best with Chromium
},
},
{
name: 'e2e-chromium',
testMatch: '**/*.spec.ts',
testIgnore: '**/*.a11y.spec.ts',
use: { browserName: 'chromium' },
},
],
});// tests/pages.a11y.spec.ts — scan all critical pages
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const PAGES_TO_SCAN = [
{ name: 'Home', path: '/' },
{ name: 'Login', path: '/login' },
{ name: 'Register', path: '/register' },
{ name: 'Dashboard', path: '/dashboard' },
{ name: 'Products', path: '/products' },
{ name: 'Checkout', path: '/checkout' },
{ name: 'Contact', path: '/contact' },
];
for (const { name, path } of PAGES_TO_SCAN) {
test(`${name} page (${path}) has no WCAG AA violations`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
// Attach violation details to the test report
await test.info().attach('accessibility-scan-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});
}// tests/helpers/a11y-fixture.ts — reusable axe-core fixture
import { test as base, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
type A11yFixtures = {
makeAxeBuilder: () => AxeBuilder;
};
export const test = base.extend<A11yFixtures>({
makeAxeBuilder: async ({ page }, use) => {
const makeAxeBuilder = () =>
new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']);
await use(makeAxeBuilder);
},
});
export { expect };// tests/dashboard.a11y.spec.ts — using the fixture
import { test, expect } from './helpers/a11y-fixture';
test('dashboard has no violations after data loads', async ({ page, makeAxeBuilder }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
const results = await makeAxeBuilder().analyze();
await test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});JavaScript
// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
projects: [
{
name: 'accessibility',
testMatch: '**/*.a11y.spec.js',
use: { browserName: 'chromium' },
},
{
name: 'e2e-chromium',
testMatch: '**/*.spec.js',
testIgnore: '**/*.a11y.spec.js',
use: { browserName: 'chromium' },
},
],
});// tests/pages.a11y.spec.js
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
const PAGES_TO_SCAN = [
{ name: 'Home', path: '/' },
{ name: 'Login', path: '/login' },
{ name: 'Dashboard', path: '/dashboard' },
{ name: 'Products', path: '/products' },
];
for (const { name, path } of PAGES_TO_SCAN) {
test(`${name} page (${path}) has no WCAG AA violations`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
await test.info().attach('accessibility-scan-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});
}GitHub Actions integration:
# .github/workflows/accessibility.yml
name: Accessibility Tests
on: [push, pull_request]
jobs:
accessibility:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run accessibility tests
run: npx playwright test --project=accessibility
- name: Upload accessibility report
if: always()
uses: actions/upload-artifact@v4
with:
name: accessibility-report
path: playwright-report/
retention-days: 30Decision Guide
| What to Check | Automated (axe-core) | Manual (Keyboard/Screen Reader) | Why |
|---|---|---|---|
| Missing alt text | Yes | No | axe-core detects this reliably |
| Color contrast ratios | Yes | No | Computed automatically from CSS |
| Missing form labels | Yes | No | Detects missing <label> and aria associations |
| Invalid ARIA attributes | Yes | No | Validates against WAI-ARIA spec |
| Duplicate IDs | Yes | No | DOM analysis |
| Tab order / logical flow | No | Yes | Requires understanding of page layout and user intent |
| Focus management in modals | Partial (axe checks aria-hidden) | Yes | Focus trapping behavior requires behavioral testing |
| Screen reader UX quality | No | Yes | Whether announcements are helpful is subjective |
| Cognitive load / readability | No | Yes | Cannot be automated — requires human judgment |
| Touch target size (mobile) | Yes (WCAG 2.2) | Yes | axe checks minimum size; real-device feel needs manual testing |
| Dynamic content announcements | No | Yes | Live region behavior depends on timing and screen reader |
| Keyboard shortcut conflicts | No | Yes | Requires knowing OS/browser/AT shortcuts |
| Reading order vs visual order | No | Yes | CSS reordering (flexbox order, grid) can break this |
| Error recovery flow | No | Yes | Whether error guidance is understandable requires human judgment |
| Video captions / audio descriptions | Partial (checks <track>) | Yes | Quality of captions must be verified manually |
Rule of thumb: Automate everything axe-core can catch (run it in CI on every page), then spend your manual testing budget on keyboard navigation, focus management, and screen reader announcements for your most critical user flows.
Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
| Only test with axe-core | Catches ~30-40% of WCAG issues. Misses keyboard navigation, focus management, reading order, and UX quality. | Combine axe-core with keyboard navigation tests and periodic manual screen reader audits |
| Ignore keyboard navigation | ~8% of users rely on keyboard-only navigation. Inaccessible keyboard UX blocks real users. | Test Tab order, Enter/Space activation, Escape to close, and arrow key navigation for every interactive component |
| Skip focus management in modals | Users Tab into background content behind the modal, lose context, or cannot close the dialog. | Test focus trap, Escape to close, and focus return to trigger element |
| Test accessibility once | Regressions creep in with every PR. A passing audit today means nothing next sprint. | Run axe-core in CI on every build; schedule quarterly manual audits |
Use tabindex > 0 | Overrides natural tab order, creating unpredictable navigation. Extremely difficult to maintain. | Use tabindex="0" to make elements focusable and tabindex="-1" for programmatic focus; never use positive values |
Rely on title attribute for accessibility | Screen readers handle title inconsistently — many ignore it. | Use aria-label, aria-labelledby, or visible text labels |
Add role="button" to <div> without keyboard support | Creates a "button" that only works with a mouse. Screen reader announces it as a button, but Enter/Space does nothing. | Use <button> elements. If you must use a <div>, add tabindex="0", role="button", and keydown handlers for Enter and Space |
Hide content with display: none and expect screen readers to read it | display: none hides content from everyone, including assistive technology. | Use .sr-only / visually-hidden CSS pattern to hide visually while keeping content accessible |
Use aria-label on non-interactive <div> or <span> | aria-label is ignored on elements without a role. Screen readers will not announce it. | Add an appropriate role (role="region") or use aria-labelledby pointing to a visible heading |
| Test only the happy path | Missing error states, empty states, and loading states that may have different accessibility characteristics. | Test forms with validation errors, empty search results, loading skeletons, and timeout states |
Disable color-contrast rule permanently | Users with low vision cannot read your content. | Fix the contrast. If migrating, track exceptions with tickets and set a deadline |
Troubleshooting
axe-core reports no violations but screen reader experience is poor
Cause: axe-core tests the structural HTML/ARIA correctness, not the quality of the user experience. An element can have a technically valid aria-label that is unhelpful ("button", "click here", "x").
Fix: Audit your most critical flows with a real screen reader (VoiceOver on macOS: Cmd+F5; NVDA on Windows: free download). Listen to the announcements and ask: would a user who cannot see the screen understand what to do?
"color-contrast" violation on elements that look fine
Cause: axe-core computes contrast against the actual background, which may involve overlapping elements, gradients, or background images. The computed background may differ from what you see.
Fix:
- Inspect the element in DevTools: check the computed background color including any overlapping elements.
- For text on images/gradients, add a semi-transparent background behind the text.
- If axe reports a false positive (rare), verify with a manual contrast checker and use
disableRuleswith a documented justification.
Focus is lost after a dynamic content change
Cause: When an element is removed from the DOM (closing a modal, deleting a list item, navigating a SPA), focus falls back to <body>, leaving keyboard users stranded.
Fix:
// After closing a modal, return focus to the trigger
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('button', { name: 'Open modal' })).toBeFocused();
// After deleting an item, move focus to the next item or a logical landmark
await page.getByRole('button', { name: 'Delete item 3' }).click();
await expect(page.getByRole('listitem').nth(2)).toBeFocused(); // next itemaxe-core scan returns incomplete results (not violations)
Cause: results.incomplete contains checks axe could not determine automatically. These are not failures — they are items that need manual review. Common for color contrast on complex backgrounds.
Fix:
const results = await new AxeBuilder({ page }).analyze();
// Log incomplete checks for manual review
if (results.incomplete.length > 0) {
console.log('Needs manual review:', results.incomplete.map((i) => i.id));
}
// Still fail on definite violations
expect(results.violations).toEqual([]);Tab order test fails intermittently
Cause: Focus behavior depends on the page being fully loaded and interactive. Animations, lazy-loaded content, or auto-focus scripts can interfere.
Fix:
// Wait for the page to be fully interactive before testing tab order
await page.goto('/login');
await expect(page.getByLabel('Email')).toBeVisible();
// Click the body first to ensure focus starts from a known position
await page.locator('body').click();
// Now test tab order
await page.keyboard.press('Tab');
await expect(page.getByLabel('Email')).toBeFocused();Related
- core/locators.md — role-based locators align with accessibility best practices
- core/forms-and-validation.md — form interaction patterns including accessible error handling
- ci/ci-github-actions.md — CI setup for running accessibility tests
- core/i18n-and-localization.md — accessibility considerations for multilingual apps
- core/component-testing.md — test individual component accessibility in isolation
Testing Angular Apps with Playwright
When to use: Testing Angular applications -- reactive forms, Angular Material components, Angular Router navigation, lazy-loaded modules, signals, observables, and Zone.js-driven change detection. This guide covers E2E testing patterns specific to Angular behavior.
Prerequisites: core/configuration.md, core/locators.md
Quick Reference
# Install Playwright in an Angular project
npm init playwright@latest
# Run tests with Angular dev server managed by Playwright
npx playwright test
# Run against a production build (recommended for CI)
npx playwright test --project=chromium
# Debug a single test
npx playwright test tests/home.spec.ts --headed --debug
# Generate tests with codegen
npx playwright codegen http://localhost:4200Setup
Playwright Config for Angular
TypeScript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 14'] },
},
],
webServer: {
command: process.env.CI
? 'npx ng build && npx http-server dist/your-app/browser -p 4200 -s'
: 'npx ng serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120_000, // Angular builds can be slow
},
});JavaScript
// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.js',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 14'] },
},
],
webServer: {
command: process.env.CI
? 'npx ng build && npx http-server dist/your-app/browser -p 4200 -s'
: 'npx ng serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});Angular CLI Integration
Angular projects that previously used Protractor can adopt Playwright as a direct replacement. The test directory conventionally lives at e2e/ in Angular projects.
your-angular-app/
src/
e2e/
tests/
home.spec.ts
auth.spec.ts
products.spec.ts
fixtures/
auth.fixture.ts
playwright.config.ts
angular.json
package.jsonAdd scripts to package.json:
{
"scripts": {
"e2e": "playwright test",
"e2e:headed": "playwright test --headed",
"e2e:debug": "playwright test --debug",
"e2e:report": "playwright show-report"
}
}Environment Configuration
Angular uses environment.ts and environment.prod.ts for build-time configuration. For test-specific settings, use environment variables passed through the Playwright config.
TypeScript
// playwright.config.ts (excerpt)
webServer: {
command: process.env.CI
? 'npx ng build --configuration=production && npx http-server dist/your-app/browser -p 4200 -s'
: 'npx ng serve --configuration=development',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NG_APP_API_URL: 'http://localhost:4200/api',
},
},Patterns
Angular-Specific Locator Strategies
Use when: Targeting elements in Angular templates. Angular generates specific attribute patterns (_ngcontent-*, _nghost-*, ng-reflect-*) that you must avoid in locators. Always use semantic locators. Avoid when: You are tempted to use [_ngcontent-abc123] or [ng-reflect-model] attributes -- they are internal and change on every build.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Angular locator strategies', () => {
test('prefer role-based locators over Angular internals', async ({ page }) => {
await page.goto('/dashboard');
// GOOD: Role-based locators work with Angular Material and native HTML
await page.getByRole('button', { name: 'Create project' }).click();
await expect(page.getByRole('heading', { name: 'New Project' })).toBeVisible();
// GOOD: Label-based for form fields
await page.getByLabel('Project name').fill('My Project');
// GOOD: Text-based for non-interactive content
await expect(page.getByText('3 projects total')).toBeVisible();
// BAD (never do this):
// page.locator('[_ngcontent-abc]') -- changes every build
// page.locator('[ng-reflect-model]') -- debug attribute, stripped in prod
// page.locator('app-dashboard .mat-card') -- component selector + internal class
});
test('use test IDs for complex Angular components', async ({ page }) => {
await page.goto('/analytics');
// Angular components with no semantic role need test IDs
const chart = page.getByTestId('revenue-chart');
await expect(chart).toBeVisible();
// Configure the testIdAttribute if your team uses a different attribute
// In playwright.config.ts: use: { testIdAttribute: 'data-cy' }
});
test('scope locators within Angular component boundaries', async ({ page }) => {
await page.goto('/users');
// Scope within a table to find specific rows
const userTable = page.getByRole('table', { name: 'Users' });
const adminRow = userTable.getByRole('row').filter({
has: page.getByRole('cell', { name: 'Admin' }),
});
await adminRow.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByRole('dialog', { name: 'Edit User' })).toBeVisible();
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Angular locator strategies', () => {
test('prefer role-based locators over Angular internals', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Create project' }).click();
await expect(page.getByRole('heading', { name: 'New Project' })).toBeVisible();
await page.getByLabel('Project name').fill('My Project');
await expect(page.getByText('3 projects total')).toBeVisible();
});
test('use test IDs for complex Angular components', async ({ page }) => {
await page.goto('/analytics');
const chart = page.getByTestId('revenue-chart');
await expect(chart).toBeVisible();
});
test('scope locators within Angular component boundaries', async ({ page }) => {
await page.goto('/users');
const userTable = page.getByRole('table', { name: 'Users' });
const adminRow = userTable.getByRole('row').filter({
has: page.getByRole('cell', { name: 'Admin' }),
});
await adminRow.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByRole('dialog', { name: 'Edit User' })).toBeVisible();
});
});Testing Reactive Forms
Use when: Testing Angular reactive forms (FormGroup, FormControl, FormArray). Playwright interacts with the rendered DOM, so reactive forms are transparent -- test the user experience. Avoid when: Testing form validation logic in isolation -- use Angular TestBed unit tests for that.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('reactive forms', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/register');
});
test('shows validation errors for invalid inputs', async ({ page }) => {
// Touch and blur each field to trigger Angular's touched + dirty validators
const emailInput = page.getByLabel('Email');
await emailInput.click();
await emailInput.blur();
await expect(page.getByText('Email is required')).toBeVisible();
await emailInput.fill('not-an-email');
await emailInput.blur();
await expect(page.getByText('Invalid email format')).toBeVisible();
});
test('cross-field validation (password match)', async ({ page }) => {
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
await page.getByLabel('Confirm password').fill('different-password');
await page.getByLabel('Confirm password').blur();
await expect(page.getByText('Passwords do not match')).toBeVisible();
// Fix the mismatch
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
await page.getByLabel('Confirm password').blur();
await expect(page.getByText('Passwords do not match')).toBeHidden();
});
test('dynamic FormArray -- add and remove items', async ({ page }) => {
await page.goto('/profile/edit');
// Add a phone number (FormArray push)
await page.getByRole('button', { name: 'Add phone number' }).click();
const phoneInputs = page.getByLabel(/Phone number/);
await expect(phoneInputs).toHaveCount(2); // default + new one
await phoneInputs.nth(1).fill('+1-555-0199');
// Remove the first phone number
await page.getByRole('button', { name: 'Remove phone 1' }).click();
await expect(phoneInputs).toHaveCount(1);
await expect(phoneInputs.first()).toHaveValue('+1-555-0199');
});
test('submit button disabled when form is invalid', async ({ page }) => {
const submitButton = page.getByRole('button', { name: 'Create account' });
// Form starts invalid -- button should be disabled
await expect(submitButton).toBeDisabled();
// Fill all required fields
await page.getByLabel('Full name').fill('Jane Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
await page.getByLabel('I agree to the terms').check();
// Now the form is valid -- button should be enabled
await expect(submitButton).toBeEnabled();
});
test('async validator shows loading state', async ({ page }) => {
// Slow down the username availability check
await page.route('**/api/check-username*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ available: true }),
});
});
await page.getByLabel('Username').fill('janedoe');
await page.getByLabel('Username').blur();
// Async validator fires -- shows a loading indicator
await expect(page.getByTestId('username-checking')).toBeVisible();
// After the check completes
await expect(page.getByTestId('username-checking')).toBeHidden();
await expect(page.getByText('Username is available')).toBeVisible();
});
test('form submission posts correct data', async ({ page }) => {
let submittedData: Record<string, unknown> = {};
await page.route('**/api/register', async (route) => {
submittedData = route.request().postDataJSON();
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 1 }),
});
});
await page.getByLabel('Full name').fill('Jane Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
await page.getByLabel('I agree to the terms').check();
await page.getByRole('button', { name: 'Create account' }).click();
expect(submittedData).toMatchObject({
name: 'Jane Doe',
email: 'jane@example.com',
});
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('reactive forms', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/register');
});
test('shows validation errors for invalid inputs', async ({ page }) => {
const emailInput = page.getByLabel('Email');
await emailInput.click();
await emailInput.blur();
await expect(page.getByText('Email is required')).toBeVisible();
await emailInput.fill('not-an-email');
await emailInput.blur();
await expect(page.getByText('Invalid email format')).toBeVisible();
});
test('cross-field validation (password match)', async ({ page }) => {
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
await page.getByLabel('Confirm password').fill('different-password');
await page.getByLabel('Confirm password').blur();
await expect(page.getByText('Passwords do not match')).toBeVisible();
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
await page.getByLabel('Confirm password').blur();
await expect(page.getByText('Passwords do not match')).toBeHidden();
});
test('submit button disabled when form is invalid', async ({ page }) => {
const submitButton = page.getByRole('button', { name: 'Create account' });
await expect(submitButton).toBeDisabled();
await page.getByLabel('Full name').fill('Jane Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
await page.getByLabel('I agree to the terms').check();
await expect(submitButton).toBeEnabled();
});
});Testing Angular Material Components
Use when: Testing apps using Angular Material (mat-button, mat-input, mat-select, mat-dialog, mat-table, etc.). Angular Material components use proper ARIA attributes, making them accessible to role-based locators. Avoid when: Using CSS class selectors like .mat-mdc-button or .mat-option -- these change between Material versions.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Angular Material components', () => {
test('mat-select dropdown', async ({ page }) => {
await page.goto('/settings');
// Angular Material select has role="combobox"
await page.getByRole('combobox', { name: 'Theme' }).click();
// Options appear in a CDK overlay (similar to a portal)
await page.getByRole('option', { name: 'Dark' }).click();
// Verify the selection
await expect(page.getByRole('combobox', { name: 'Theme' })).toContainText('Dark');
});
test('mat-autocomplete with type-ahead', async ({ page }) => {
await page.goto('/users/new');
const roleInput = page.getByRole('combobox', { name: 'Role' });
await roleInput.fill('adm');
// Autocomplete suggestions appear in a CDK overlay
await expect(page.getByRole('option', { name: 'Admin' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Administrator' })).toBeVisible();
await page.getByRole('option', { name: 'Admin' }).click();
await expect(roleInput).toHaveValue('Admin');
});
test('mat-dialog opens and closes', async ({ page }) => {
await page.goto('/projects');
await page.getByRole('button', { name: 'Delete project' }).first().click();
// MatDialog renders as a CDK overlay with role="dialog"
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Are you sure?')).toBeVisible();
// Cancel
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toBeHidden();
});
test('mat-table sorting', async ({ page }) => {
await page.goto('/users');
// Click the column header to sort
await page.getByRole('columnheader', { name: 'Name' }).click();
// Verify sort indicator
const header = page.getByRole('columnheader', { name: 'Name' });
await expect(header).toHaveAttribute('aria-sort', 'ascending');
// Verify rows are sorted
const names = await page.getByRole('cell').filter({
has: page.locator('[data-column="name"]'),
}).allTextContents();
const sortedNames = [...names].sort();
expect(names).toEqual(sortedNames);
// Click again for descending
await page.getByRole('columnheader', { name: 'Name' }).click();
await expect(header).toHaveAttribute('aria-sort', 'descending');
});
test('mat-paginator controls table pagination', async ({ page }) => {
await page.goto('/users');
await expect(page.getByText('1 - 10 of 50')).toBeVisible();
// Navigate to next page
await page.getByRole('button', { name: 'Next page' }).click();
await expect(page.getByText('11 - 20 of 50')).toBeVisible();
// Change page size
await page.getByRole('combobox', { name: 'Items per page' }).click();
await page.getByRole('option', { name: '25' }).click();
await expect(page.getByText('1 - 25 of 50')).toBeVisible();
});
test('mat-snack-bar notification appears and dismisses', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Save' }).click();
// Snackbar appears at the bottom of the screen
await expect(page.getByText('Settings saved successfully')).toBeVisible();
// Dismiss via action button
await page.getByRole('button', { name: 'Dismiss' }).click();
await expect(page.getByText('Settings saved successfully')).toBeHidden();
});
test('mat-stepper wizard flow', async ({ page }) => {
await page.goto('/onboarding');
// Step 1: Personal info
await expect(page.getByText('Step 1 of 3')).toBeVisible();
await page.getByLabel('Full name').fill('Jane Doe');
await page.getByRole('button', { name: 'Next' }).click();
// Step 2: Company info
await expect(page.getByText('Step 2 of 3')).toBeVisible();
await page.getByLabel('Company').fill('Acme Corp');
await page.getByRole('button', { name: 'Next' }).click();
// Step 3: Review
await expect(page.getByText('Step 3 of 3')).toBeVisible();
await expect(page.getByText('Jane Doe')).toBeVisible();
await expect(page.getByText('Acme Corp')).toBeVisible();
// Go back to step 1
await page.getByRole('button', { name: 'Back' }).click();
await page.getByRole('button', { name: 'Back' }).click();
await expect(page.getByText('Step 1 of 3')).toBeVisible();
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Angular Material components', () => {
test('mat-select dropdown', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('combobox', { name: 'Theme' }).click();
await page.getByRole('option', { name: 'Dark' }).click();
await expect(page.getByRole('combobox', { name: 'Theme' })).toContainText('Dark');
});
test('mat-dialog opens and closes', async ({ page }) => {
await page.goto('/projects');
await page.getByRole('button', { name: 'Delete project' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Are you sure?')).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toBeHidden();
});
test('mat-table sorting', async ({ page }) => {
await page.goto('/users');
await page.getByRole('columnheader', { name: 'Name' }).click();
const header = page.getByRole('columnheader', { name: 'Name' });
await expect(header).toHaveAttribute('aria-sort', 'ascending');
});
test('mat-snack-bar notification appears and dismisses', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Settings saved successfully')).toBeVisible();
await page.getByRole('button', { name: 'Dismiss' }).click();
await expect(page.getByText('Settings saved successfully')).toBeHidden();
});
});Testing Angular Router Navigation
Use when: Testing Angular Router navigation, lazy-loaded routes, route guards, and URL parameter handling. Avoid when: Testing router configuration in isolation -- use Angular TestBed for that.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Angular Router navigation', () => {
test('lazy-loaded module loads on navigation', async ({ page }) => {
await page.goto('/');
// Navigate to a lazy-loaded route
await page.getByRole('link', { name: 'Admin' }).click();
await page.waitForURL('/admin');
// The lazy module loads and renders its component
await expect(page.getByRole('heading', { name: 'Admin Dashboard' })).toBeVisible();
});
test('route guard redirects unauthorized users', async ({ page }) => {
// Visit a route protected by AuthGuard (canActivate)
await page.goto('/admin/users');
// Guard should redirect to login
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});
test('route resolver prefetches data before navigation', async ({ page }) => {
// Intercept the API call that the resolver makes
const resolverPromise = page.waitForResponse('**/api/products/*');
await page.goto('/products/42');
// The resolver fetches data before the component renders
await resolverPromise;
// Component renders with pre-fetched data (no loading spinner)
await expect(page.getByRole('heading', { level: 1 })).toContainText('Product');
});
test('nested router-outlet renders child components', async ({ page }) => {
await page.goto('/settings/profile');
// Parent layout (SettingsComponent with its own router-outlet)
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Settings' })).toBeVisible();
// Child route (ProfileComponent rendered inside nested router-outlet)
await expect(page.getByRole('heading', { name: 'Profile', level: 2 })).toBeVisible();
// Navigate to sibling child route
await page.getByRole('link', { name: 'Security' }).click();
await page.waitForURL('/settings/security');
// Parent persists, child changes
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
});
test('route parameters update component state', async ({ page }) => {
await page.goto('/users/1');
await expect(page.getByRole('heading')).toContainText('User #1');
// Navigate to a different user via the URL
await page.goto('/users/2');
await expect(page.getByRole('heading')).toContainText('User #2');
});
test('query parameters drive filter behavior', async ({ page }) => {
await page.goto('/products?category=electronics&page=2');
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
await expect(page.getByText('Page 2')).toBeVisible();
});
test('browser back navigates through Angular history', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Products' }).click();
await page.waitForURL('/products');
await page.getByRole('link', { name: 'About' }).click();
await page.waitForURL('/about');
await page.goBack();
await expect(page).toHaveURL(/\/products/);
await page.goBack();
await expect(page).toHaveURL(/\/$/);
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Angular Router navigation', () => {
test('lazy-loaded module loads on navigation', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Admin' }).click();
await page.waitForURL('/admin');
await expect(page.getByRole('heading', { name: 'Admin Dashboard' })).toBeVisible();
});
test('route guard redirects unauthorized users', async ({ page }) => {
await page.goto('/admin/users');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});
test('nested router-outlet renders child components', async ({ page }) => {
await page.goto('/settings/profile');
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Profile', level: 2 })).toBeVisible();
await page.getByRole('link', { name: 'Security' }).click();
await page.waitForURL('/settings/security');
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
});
test('browser back navigates through Angular history', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Products' }).click();
await page.waitForURL('/products');
await page.getByRole('link', { name: 'About' }).click();
await page.waitForURL('/about');
await page.goBack();
await expect(page).toHaveURL(/\/products/);
await page.goBack();
await expect(page).toHaveURL(/\/$/);
});
});Testing Lazy-Loaded Modules
Use when: Verifying that Angular lazy-loaded feature modules load correctly when the user navigates to their routes. Lazy-loaded modules introduce network requests for JavaScript chunks. Avoid when: The module is eagerly loaded -- no separate chunk to load.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('lazy-loaded modules', () => {
test('lazy module loads without errors', async ({ page }) => {
const consoleErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.goto('/');
// Navigate to a lazy-loaded route
const chunkRequest = page.waitForResponse((response) =>
response.url().includes('.js') && response.status() === 200
);
await page.getByRole('link', { name: 'Reports' }).click();
await chunkRequest;
await page.waitForURL('/reports');
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
// No chunk loading errors
const chunkErrors = consoleErrors.filter(
(e) => e.includes('ChunkLoadError') || e.includes('Loading chunk')
);
expect(chunkErrors).toEqual([]);
});
test('preloaded lazy module navigates instantly', async ({ page }) => {
await page.goto('/dashboard');
// If preloadingStrategy is configured, the module may already be cached
// Navigate and verify it renders without visible delay
const startTime = Date.now();
await page.getByRole('link', { name: 'Reports' }).click();
await page.waitForURL('/reports');
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
const loadTime = Date.now() - startTime;
// Preloaded modules should render quickly (not an exact assertion, but a sanity check)
expect(loadTime).toBeLessThan(3000);
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('lazy-loaded modules', () => {
test('lazy module loads without errors', async ({ page }) => {
const consoleErrors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.goto('/');
await page.getByRole('link', { name: 'Reports' }).click();
await page.waitForURL('/reports');
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
const chunkErrors = consoleErrors.filter(
(e) => e.includes('ChunkLoadError') || e.includes('Loading chunk')
);
expect(chunkErrors).toEqual([]);
});
});Testing Signals and Observables Indirectly
Use when: Verifying that Angular signals (signal(), computed(), effect()) and RxJS observables produce correct UI updates. Playwright cannot subscribe to observables or read signals directly -- test through the rendered output. Avoid when: Testing observable transformation logic in isolation -- use Jasmine/Jest with Angular TestBed for that.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('signals (tested through UI)', () => {
test('signal-based counter updates the DOM', async ({ page }) => {
await page.goto('/counter');
// The counter uses signal() internally
await expect(page.getByTestId('count')).toHaveText('0');
await page.getByRole('button', { name: 'Increment' }).click();
await expect(page.getByTestId('count')).toHaveText('1');
await page.getByRole('button', { name: 'Increment' }).click();
await page.getByRole('button', { name: 'Increment' }).click();
await expect(page.getByTestId('count')).toHaveText('3');
await page.getByRole('button', { name: 'Reset' }).click();
await expect(page.getByTestId('count')).toHaveText('0');
});
test('computed signal updates derived values', async ({ page }) => {
await page.goto('/cart');
// Cart total is a computed() signal derived from items
await expect(page.getByTestId('cart-total')).toHaveText('$0.00');
// Add item (updates the items signal, which updates the computed total)
await page.goto('/products');
await page.getByRole('listitem')
.filter({ hasText: '$29.99' })
.getByRole('button', { name: 'Add to cart' })
.click();
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByTestId('cart-total')).toHaveText('$29.99');
});
});
test.describe('observables (tested through UI)', () => {
test('real-time data stream updates the UI', async ({ page }) => {
await page.goto('/dashboard');
// The component subscribes to an observable that emits stock prices
const priceElement = page.getByTestId('stock-price');
await expect(priceElement).toBeVisible();
// Get the initial value
const initialPrice = await priceElement.textContent();
// Wait for the observable to emit a new value
// Use polling assertion instead of waitForTimeout
await expect(priceElement).not.toHaveText(initialPrice!, { timeout: 10_000 });
});
test('search with debounceTime observable', async ({ page }) => {
await page.goto('/search');
const apiCalls: string[] = [];
await page.route('**/api/search*', async (route) => {
apiCalls.push(route.request().url());
await route.continue();
});
// Type quickly -- the observable's debounceTime should batch
await page.getByRole('textbox', { name: 'Search' }).pressSequentially('angular', {
delay: 50,
});
await expect(page.getByRole('listitem')).toHaveCount(5);
// debounceTime should prevent a request per keystroke
expect(apiCalls.length).toBeLessThanOrEqual(2);
});
test('switchMap cancels previous requests on new input', async ({ page }) => {
await page.goto('/search');
// Type one query
await page.getByRole('textbox', { name: 'Search' }).fill('first query');
// Immediately type a different query before results come back
await page.getByRole('textbox', { name: 'Search' }).fill('second query');
// Results should match the second query, not the first
await expect(page.getByRole('listitem').first()).toContainText(/second query/i);
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('signals (tested through UI)', () => {
test('signal-based counter updates the DOM', async ({ page }) => {
await page.goto('/counter');
await expect(page.getByTestId('count')).toHaveText('0');
await page.getByRole('button', { name: 'Increment' }).click();
await expect(page.getByTestId('count')).toHaveText('1');
await page.getByRole('button', { name: 'Increment' }).click();
await page.getByRole('button', { name: 'Increment' }).click();
await expect(page.getByTestId('count')).toHaveText('3');
await page.getByRole('button', { name: 'Reset' }).click();
await expect(page.getByTestId('count')).toHaveText('0');
});
});
test.describe('observables (tested through UI)', () => {
test('search with debounceTime observable', async ({ page }) => {
await page.goto('/search');
const apiCalls = [];
await page.route('**/api/search*', async (route) => {
apiCalls.push(route.request().url());
await route.continue();
});
await page.getByRole('textbox', { name: 'Search' }).pressSequentially('angular', {
delay: 50,
});
await expect(page.getByRole('listitem')).toHaveCount(5);
expect(apiCalls.length).toBeLessThanOrEqual(2);
});
});Framework-Specific Tips
Zone.js Considerations
Angular uses Zone.js to detect async operations and trigger change detection. Playwright does not depend on Zone.js -- it interacts with the DOM directly. However, Zone.js can affect test behavior:
1. Change detection timing: After user interactions (click, fill), Angular schedules change detection via Zone.js. Playwright's auto-waiting handles this -- expect(locator).toHaveText('new value') retries until the DOM updates.
2. Zoneless Angular (experimental): Angular 17+ supports zoneless change detection. Tests work identically with Playwright because Playwright waits for DOM changes, not Zone.js ticks.
3. Long-running async operations: If your app has setInterval or long-running observables, Zone.js keeps Angular in a "not stable" state. This does not affect Playwright (unlike Protractor, which waited for Angular stability). Playwright simply interacts with whatever is on the screen.
Protractor to Playwright Migration Checklist
| Protractor | Playwright Equivalent |
|---|---|
element(by.css('.btn')) | page.locator('.btn') -- but prefer page.getByRole('button', { name: '...' }) |
element(by.id('login')) | page.getByTestId('login') or page.getByRole(...) |
element(by.buttonText('Submit')) | page.getByRole('button', { name: 'Submit' }) |
element(by.model('user.name')) | page.getByLabel('Name') -- Playwright cannot read ng-model |
element(by.binding('user.name')) | page.getByText(expectedValue) -- test the rendered output |
element(by.repeater('item in items')) | page.getByRole('listitem') or page.getByTestId(...) |
browser.waitForAngular() | Not needed -- Playwright auto-waits; remove all instances |
browser.sleep(3000) | await expect(locator).toBeVisible() -- never use arbitrary waits |
browser.get('/path') | await page.goto('/path') |
protractor.ExpectedConditions | await expect(locator).toBeVisible/toBeHidden/toHaveText(...) |
Angular Build Configurations
| Scenario | Build Command | Notes |
|---|---|---|
| Local development | npx ng serve | Fast rebuild, source maps, no optimization |
| CI (production build) | npx ng build && npx http-server dist/your-app/browser -p 4200 -s | Tests the real production bundle |
| CI (SSR/Universal) | npx ng build --ssr && node dist/your-app/server/server.mjs | Tests server-side rendered Angular |
| Staging environment | No webServer needed | Point baseURL to the staging URL |
The -s flag on http-server enables SPA fallback (sends index.html for all routes), which is essential for Angular Router to work correctly.
CDK Overlay Container
Angular Material and Angular CDK render overlays (dialogs, menus, selects, autocompletes) in a special container outside the component tree. Playwright sees these overlays in the document -- no special handling is needed. Use standard role-based locators:
// CDK overlays render into <div class="cdk-overlay-container"> at the body level
// Playwright sees them as regular DOM elements
const dialog = page.getByRole('dialog');
const menu = page.getByRole('menu');
const listbox = page.getByRole('listbox');Testing with Angular SSR (Universal)
If your Angular app uses server-side rendering:
// playwright.config.ts (SSR-specific)
webServer: {
command: process.env.CI
? 'npx ng build --ssr && node dist/your-app/server/server.mjs'
: 'npx ng serve --ssr',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 180_000, // SSR builds are slower
},Test for hydration issues the same way as with other SSR frameworks:
test('no hydration errors after SSR', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error' && msg.text().includes('hydration')) {
errors.push(msg.text());
}
});
await page.goto('/');
await page.getByRole('button', { name: 'Get started' }).click();
expect(errors).toEqual([]);
});Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
page.locator('[_ngcontent-abc123]') | Angular scoped style attributes are random and change every build | Use getByRole, getByLabel, getByText, getByTestId |
page.locator('[ng-reflect-model="value"]') | ng-reflect-* attributes only exist in dev mode; stripped in production | Test the rendered value: expect(input).toHaveValue('value') |
page.locator('app-my-component') | Angular component selectors are implementation details | Target the content the component renders using semantic locators |
page.locator('.mat-mdc-button') | Angular Material class names change between versions (MDC migration) | page.getByRole('button', { name: 'Submit' }) |
page.evaluate(() => (window as any).ng) to access Angular internals | Depends on debug mode; not available in production builds | Test through the DOM; never access the Angular runtime |
await page.waitForTimeout(500) after clicking a button | Zone.js change detection timing varies; arbitrary waits are fragile | await expect(locator).toHaveText('expected value') auto-retries |
browser.waitForAngular() (Protractor pattern) | Does not exist in Playwright; not needed -- Playwright auto-waits | Remove entirely; use web-first assertions |
Test Angular services by injecting them via page.evaluate | Services are not accessible from the browser console in production | Test services indirectly through the UI they power; unit test with TestBed |
Use ng serve in CI | Development server is slower, includes debug code, may hide production-only bugs | Use ng build && http-server in CI |
| Skip testing CDK overlay components (dialogs, selects, menus) | These are the most interactive parts of the app; bugs here are highly visible | Test overlays with role-based locators; they render in the regular DOM |
Related
- core/locators.md -- locator strategies for Angular Material and CDK components
- core/assertions-and-waiting.md -- auto-waiting assertions that replace Protractor's waitForAngular
- core/forms-and-validation.md -- form testing patterns for reactive and template-driven forms
- core/accessibility.md -- accessibility testing for Angular Material components
- core/authentication.md -- authentication with Angular route guards
- migration/from-selenium.md -- migration patterns applicable to Protractor (Protractor is built on Selenium)
- core/test-architecture.md -- when to use E2E vs unit tests with Angular TestBed
- ci/ci-github-actions.md -- CI setup with Angular build caching
Assertions and Waiting
When to use: Every time you write an expect() call, wait for a condition, or wonder why a test is flaky due to timing.Prerequisites: core/locators.md for locator strategies used in examples.
Quick Reference
// Web-first (auto-retry) — ALWAYS prefer these
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.getByRole('listitem')).toHaveCount(5);
// Negative — auto-retries until condition is met
await expect(page.getByRole('dialog')).not.toBeVisible();
// Soft — collect failures, don't stop test
await expect.soft(page.getByRole('heading')).toHaveText('Title');
// Polling — non-DOM async conditions
await expect.poll(() => getUserCount()).toBe(10);
// Retry a block — multiple assertions that must pass together
await expect(async () => { /* assertions */ }).toPass();Patterns
Web-First Assertions (Auto-Retry)
Use when: Asserting anything about a locator — visibility, text, attributes, CSS, count, values. Avoid when: Asserting on an already-resolved JavaScript value (use non-retrying assertions instead).
Web-first assertions automatically retry until the condition is met or the timeout expires. They are the backbone of reliable Playwright tests.
TypeScript
import { test, expect } from '@playwright/test';
test('web-first assertions demo', async ({ page }) => {
await page.goto('/products');
// Visibility
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
// Text — exact match
await expect(page.getByTestId('total')).toHaveText('Total: $99.00');
// Text — partial match (substring or regex)
await expect(page.getByTestId('total')).toContainText('$99');
await expect(page.getByTestId('total')).toHaveText(/Total: \$\d+\.\d{2}/);
// Element count
await expect(page.getByRole('listitem')).toHaveCount(5);
// Attribute
await expect(page.getByRole('link', { name: 'Docs' })).toHaveAttribute('href', '/docs');
// CSS property
await expect(page.getByTestId('alert')).toHaveCSS('background-color', 'rgb(255, 0, 0)');
// Input value
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
// Class (use toHaveClass for full match, regex for partial)
await expect(page.getByTestId('card')).toHaveClass(/active/);
// Enabled / disabled / checked
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page.getByRole('checkbox')).toBeChecked();
// Editable / focused / attached
await expect(page.getByLabel('Name')).toBeEditable();
await expect(page.getByLabel('Name')).toBeFocused();
});JavaScript
const { test, expect } = require('@playwright/test');
test('web-first assertions demo', async ({ page }) => {
await page.goto('/products');
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
await expect(page.getByTestId('total')).toHaveText('Total: $99.00');
await expect(page.getByTestId('total')).toContainText('$99');
await expect(page.getByTestId('total')).toHaveText(/Total: \$\d+\.\d{2}/);
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByRole('link', { name: 'Docs' })).toHaveAttribute('href', '/docs');
await expect(page.getByTestId('alert')).toHaveCSS('background-color', 'rgb(255, 0, 0)');
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
await expect(page.getByTestId('card')).toHaveClass(/active/);
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page.getByRole('checkbox')).toBeChecked();
await expect(page.getByLabel('Name')).toBeEditable();
await expect(page.getByLabel('Name')).toBeFocused();
});Asserting Pseudo-Element Styles (toHaveCSS({ pseudo }), Playwright 1.60+)
Use when: The style you care about lives on a ::before or ::after pseudo-element — icon glyphs (content), decorative bars, required-field asterisks, tooltips. Avoid when: The style is on the element itself; pass no pseudo option.
Playwright 1.60 adds a pseudo option to toHaveCSS(), so you can assert computed styles of pseudo-elements directly instead of reaching into getComputedStyle via evaluate.
TypeScript
import { test, expect } from '@playwright/test';
test('required field shows a red asterisk via ::after', async ({ page }) => {
await page.goto('/register');
const label = page.getByText('Email', { exact: true });
// Verify the ::after content and color of the "required" marker
await expect(label).toHaveCSS('content', '"*"', { pseudo: '::after' });
await expect(label).toHaveCSS('color', 'rgb(220, 38, 38)', { pseudo: '::after' });
});JavaScript
const { test, expect } = require('@playwright/test');
test('required field shows a red asterisk via ::after', async ({ page }) => {
await page.goto('/register');
const label = page.getByText('Email', { exact: true });
await expect(label).toHaveCSS('content', '"*"', { pseudo: '::after' });
});Non-Retrying Assertions
Use when: The value is already resolved — a JavaScript variable, an API response body, a page title from page.title(), or a URL from page.url(). Avoid when: Asserting on anything that might change asynchronously in the DOM. Use web-first assertions instead.
Non-retrying assertions run once. If they fail, they fail immediately.
TypeScript
import { test, expect } from '@playwright/test';
test('non-retrying assertions for resolved values', async ({ page }) => {
await page.goto('/api/health');
// Already-resolved values — no retry needed
const title = await page.title();
expect(title).toBe('Health Check');
const url = page.url();
expect(url).toContain('/api/health');
// API response body
const response = await page.request.get('/api/users');
const body = await response.json();
expect(body.users).toHaveLength(3);
expect(response.status()).toBe(200);
// Snapshot (value comparison, not retrying)
expect(body).toMatchObject({ status: 'healthy', users: expect.any(Array) });
});JavaScript
const { test, expect } = require('@playwright/test');
test('non-retrying assertions for resolved values', async ({ page }) => {
await page.goto('/api/health');
const title = await page.title();
expect(title).toBe('Health Check');
const url = page.url();
expect(url).toContain('/api/health');
const response = await page.request.get('/api/users');
const body = await response.json();
expect(body.users).toHaveLength(3);
expect(response.status()).toBe(200);
expect(body).toMatchObject({ status: 'healthy', users: expect.any(Array) });
});Negative Assertions
Use when: Verifying something has disappeared, been removed, or is not present. Avoid when: Never.
Negative web-first assertions auto-retry until the condition is met. This is critical: expect(locator).not.toBeVisible() correctly waits for the element to disappear. It does not just check once.
TypeScript
import { test, expect } from '@playwright/test';
test('verify element disappears after action', async ({ page }) => {
await page.goto('/notifications');
// Dismiss a notification
await page.getByRole('button', { name: 'Dismiss' }).click();
// Auto-retries until the notification is gone — correct
await expect(page.getByRole('alert')).not.toBeVisible();
// Verify text is not present
await expect(page.getByText('Error occurred')).not.toBeVisible();
// Verify element is detached from DOM entirely
await expect(page.getByTestId('modal')).not.toBeAttached();
// Verify count dropped to zero
await expect(page.getByRole('alert')).toHaveCount(0);
});JavaScript
const { test, expect } = require('@playwright/test');
test('verify element disappears after action', async ({ page }) => {
await page.goto('/notifications');
await page.getByRole('button', { name: 'Dismiss' }).click();
await expect(page.getByRole('alert')).not.toBeVisible();
await expect(page.getByText('Error occurred')).not.toBeVisible();
await expect(page.getByTestId('modal')).not.toBeAttached();
await expect(page.getByRole('alert')).toHaveCount(0);
});Gotcha: not.toBeVisible() passes for elements that exist but are hidden AND for elements not in the DOM. If you specifically need to assert the element is removed from the DOM entirely (not just hidden), use not.toBeAttached().
Soft Assertions
Use when: You want to collect multiple failures in a single test without stopping at the first one. Common for form validation checks, dashboard content audits, or visual checklists. Avoid when: Subsequent assertions depend on the result of earlier ones (if the first fails, later assertions may be meaningless).
TypeScript
import { test, expect } from '@playwright/test';
test('dashboard shows all expected widgets', async ({ page }) => {
await page.goto('/dashboard');
// All checks run even if earlier ones fail
await expect.soft(page.getByTestId('revenue-widget')).toBeVisible();
await expect.soft(page.getByTestId('users-widget')).toBeVisible();
await expect.soft(page.getByTestId('orders-widget')).toBeVisible();
await expect.soft(page.getByTestId('revenue-widget')).toContainText('$');
await expect.soft(page.getByTestId('users-widget')).toContainText('active');
// Test still fails if any soft assertion failed, but you see ALL failures in the report
});JavaScript
const { test, expect } = require('@playwright/test');
test('dashboard shows all expected widgets', async ({ page }) => {
await page.goto('/dashboard');
await expect.soft(page.getByTestId('revenue-widget')).toBeVisible();
await expect.soft(page.getByTestId('users-widget')).toBeVisible();
await expect.soft(page.getByTestId('orders-widget')).toBeVisible();
await expect.soft(page.getByTestId('revenue-widget')).toContainText('$');
await expect.soft(page.getByTestId('users-widget')).toContainText('active');
});Tip: Guard subsequent actions against soft-assertion failures when those actions would throw confusing errors.
await expect.soft(page.getByRole('button', { name: 'Next' })).toBeVisible();
if (test.info().errors.length > 0) return; // bail out — no point continuing
await page.getByRole('button', { name: 'Next' }).click();Polling Assertions
Use when: Waiting for a non-DOM, non-locator async condition: API readiness, database state, file existence, polling a service. Avoid when: The condition is about a DOM element. Use web-first assertions on locators.
expect.poll() repeatedly calls a function until the assertion passes.
TypeScript
import { test, expect } from '@playwright/test';
test('wait for background job to complete', async ({ page }) => {
await page.goto('/jobs');
await page.getByRole('button', { name: 'Start Export' }).click();
// Poll an API endpoint until the job finishes
await expect.poll(async () => {
const response = await page.request.get('/api/jobs/latest');
const job = await response.json();
return job.status;
}, {
message: 'Expected export job to complete',
timeout: 30_000,
intervals: [1_000, 2_000, 5_000], // backoff: 1s, 2s, then every 5s
}).toBe('completed');
});JavaScript
const { test, expect } = require('@playwright/test');
test('wait for background job to complete', async ({ page }) => {
await page.goto('/jobs');
await page.getByRole('button', { name: 'Start Export' }).click();
await expect.poll(async () => {
const response = await page.request.get('/api/jobs/latest');
const job = await response.json();
return job.status;
}, {
message: 'Expected export job to complete',
timeout: 30_000,
intervals: [1_000, 2_000, 5_000],
}).toBe('completed');
});Retrying Assertion Blocks with toPass()
Use when: Multiple assertions or actions must pass together as a group, and the whole block should be retried if any part fails. Common for race conditions where data appears incrementally. Avoid when: A single web-first assertion suffices.
TypeScript
import { test, expect } from '@playwright/test';
test('search results update correctly', async ({ page }) => {
await page.goto('/search');
await expect(async () => {
await page.getByLabel('Search').fill('playwright');
await page.getByRole('button', { name: 'Search' }).click();
// Both must pass together — retries the whole block
await expect(page.getByRole('listitem')).toHaveCount(10);
await expect(page.getByRole('listitem').first()).toContainText('Playwright');
}).toPass({
timeout: 15_000,
intervals: [1_000, 2_000, 5_000],
});
});JavaScript
const { test, expect } = require('@playwright/test');
test('search results update correctly', async ({ page }) => {
await page.goto('/search');
await expect(async () => {
await page.getByLabel('Search').fill('playwright');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByRole('listitem')).toHaveCount(10);
await expect(page.getByRole('listitem').first()).toContainText('Playwright');
}).toPass({
timeout: 15_000,
intervals: [1_000, 2_000, 5_000],
});
});Custom Matchers
Use when: Domain-specific assertions you repeat across many tests — valid price format, date range, accessible form, etc. Avoid when: The assertion is only used in one test. Inline it.
TypeScript
// fixtures/custom-matchers.ts
import { expect, type Locator } from '@playwright/test';
expect.extend({
async toHaveValidPrice(locator: Locator) {
const assertionName = 'toHaveValidPrice';
let pass: boolean;
let matcherResult: any;
try {
await expect(locator).toHaveText(/^\$\d{1,3}(,\d{3})*\.\d{2}$/);
pass = true;
} catch (e: any) {
matcherResult = e.matcherResult;
pass = false;
}
const message = pass
? () => `${this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot })}\n\nLocator: ${locator}\nExpected: not a valid price format\nReceived: ${matcherResult?.actual || 'valid price'}`
: () => `${this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot })}\n\nLocator: ${locator}\nExpected: valid price format ($X,XXX.XX)\nReceived: ${matcherResult?.actual || 'no text'}`;
return { message, pass, name: assertionName, expected: 'valid price format', actual: matcherResult?.actual };
},
});
// Declare types for TypeScript
export {};
declare global {
namespace PlaywrightTest {
interface Matchers<R, T> {
toHaveValidPrice(): R;
}
}
}// tests/products.spec.ts
import { test, expect } from '@playwright/test';
import '../fixtures/custom-matchers';
test('product prices are valid', async ({ page }) => {
await page.goto('/products');
await expect(page.getByTestId('price-tag').first()).toHaveValidPrice();
});JavaScript
// fixtures/custom-matchers.js
const { expect } = require('@playwright/test');
expect.extend({
async toHaveValidPrice(locator) {
const assertionName = 'toHaveValidPrice';
let pass;
let matcherResult;
try {
await expect(locator).toHaveText(/^\$\d{1,3}(,\d{3})*\.\d{2}$/);
pass = true;
} catch (e) {
matcherResult = e.matcherResult;
pass = false;
}
const message = pass
? () => `Expected locator not to have valid price format`
: () => `Expected locator to have valid price format ($X,XXX.XX), received: ${matcherResult?.actual || 'no text'}`;
return { message, pass, name: assertionName };
},
});// tests/products.spec.js
const { test, expect } = require('@playwright/test');
require('../fixtures/custom-matchers');
test('product prices are valid', async ({ page }) => {
await page.goto('/products');
await expect(page.getByTestId('price-tag').first()).toHaveValidPrice();
});Auto-Waiting (Actionability)
Use when: You don't need to "use" this — understand it. Every Playwright action (click, fill, check, selectOption, etc.) auto-waits for the target element to be actionable before proceeding.
Playwright checks before acting:
| Action | Waits for |
|---|---|
click() | Attached, visible, stable (no animation), enabled, not obscured by another element |
fill() | Attached, visible, enabled, editable |
check() | Attached, visible, stable, enabled |
selectOption() | Attached, visible, enabled |
hover() | Attached, visible, stable |
type() | Attached, visible, enabled, editable |
This means you almost never need explicit waits before actions. Do NOT write await expect(button).toBeVisible() before await button.click() — the click already waits for visibility.
Explicit Waits
Use when: Waiting for navigation, network responses, or page load states that are not tied to a specific locator. Avoid when: A web-first assertion on a locator would suffice.
TypeScript
import { test, expect } from '@playwright/test';
test('explicit waits for non-locator conditions', async ({ page }) => {
await page.goto('/login');
// Wait for navigation after form submit
await page.getByLabel('Email').fill('user@test.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
// can also use glob: await page.waitForURL('**/dashboard');
// or regex: await page.waitForURL(/.*dashboard/);
// Wait for a specific API response
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/user') && resp.status() === 200
);
await page.getByRole('button', { name: 'Refresh' }).click();
const response = await responsePromise;
const data = await response.json();
expect(data.name).toBe('Test User');
// Wait for a network request to be sent
const requestPromise = page.waitForRequest('**/api/analytics');
await page.getByRole('button', { name: 'Track' }).click();
const request = await requestPromise;
expect(request.method()).toBe('POST');
// Wait for load state
await page.waitForLoadState('networkidle'); // use sparingly — only for legacy apps
});JavaScript
const { test, expect } = require('@playwright/test');
test('explicit waits for non-locator conditions', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@test.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/user') && resp.status() === 200
);
await page.getByRole('button', { name: 'Refresh' }).click();
const response = await responsePromise;
const data = await response.json();
expect(data.name).toBe('Test User');
const requestPromise = page.waitForRequest('**/api/analytics');
await page.getByRole('button', { name: 'Track' }).click();
const request = await requestPromise;
expect(request.method()).toBe('POST');
await page.waitForLoadState('networkidle');
});Critical pattern: Always set up waitForResponse / waitForRequest BEFORE the action that triggers it. Otherwise you have a race condition.
// CORRECT — promise registered before the click
const responsePromise = page.waitForResponse('**/api/data');
await page.getByRole('button', { name: 'Load' }).click();
const response = await responsePromise;
// WRONG — response may already have arrived before waitForResponse is registered
await page.getByRole('button', { name: 'Load' }).click();
const response = await page.waitForResponse('**/api/data'); // race condition!Assertion Timeouts
Use when: A specific assertion needs more or less time than the global default.
TypeScript
import { test, expect } from '@playwright/test';
// Per-assertion timeout
test('slow element appears eventually', async ({ page }) => {
await page.goto('/slow-dashboard');
await expect(page.getByTestId('heavy-chart')).toBeVisible({
timeout: 30_000, // override for this one assertion
});
});
// Per-test timeout
test('long-running flow', async ({ page }) => {
test.setTimeout(120_000);
await page.goto('/import');
await page.getByRole('button', { name: 'Import CSV' }).click();
await expect(page.getByText('Import complete')).toBeVisible({ timeout: 60_000 });
});JavaScript
const { test, expect } = require('@playwright/test');
test('slow element appears eventually', async ({ page }) => {
await page.goto('/slow-dashboard');
await expect(page.getByTestId('heavy-chart')).toBeVisible({
timeout: 30_000,
});
});
test('long-running flow', async ({ page }) => {
test.setTimeout(120_000);
await page.goto('/import');
await page.getByRole('button', { name: 'Import CSV' }).click();
await expect(page.getByText('Import complete')).toBeVisible({ timeout: 60_000 });
});Global timeout in playwright.config.ts:
export default defineConfig({
expect: {
timeout: 10_000, // default is 5_000 — increase for slow apps
},
timeout: 30_000, // per-test timeout (default 30_000)
});Decision Guide
| Scenario | Recommended Approach | Why |
|---|---|---|
| Element visible / hidden | expect(locator).toBeVisible() / .not.toBeVisible() | Auto-retries, handles timing |
| Text content check | expect(locator).toHaveText() or .toContainText() | Auto-retries; use toContainText for substring |
| Element count | expect(locator).toHaveCount(n) | Retries until count matches |
| Input value | expect(locator).toHaveValue('x') | Auto-retries on the locator |
| Element attribute | expect(locator).toHaveAttribute('href', '/x') | Auto-retries |
| CSS property | expect(locator).toHaveCSS('color', 'rgb(0,0,0)') | Auto-retries; use computed RGB values. Add { pseudo: '::after' } (1.60+) for pseudo-element styles |
| Element gone from DOM | expect(locator).not.toBeAttached() | Distinguishes hidden vs. removed |
| URL changed | page.waitForURL('/path') or expect(page).toHaveURL('/path') | toHaveURL auto-retries; waitForURL blocks |
| Page title | expect(page).toHaveTitle('Title') | Auto-retries |
| API response status | expect(response.status()).toBe(200) | Already resolved — non-retrying |
| Background job / polling | expect.poll(() => fetchStatus()) | Retries a function, not a locator |
| Multiple assertions as one | expect(async () => { ... }).toPass() | Retries the entire block |
| Multiple independent checks | expect.soft(locator) | Collects all failures |
| Resolved JS value | expect(value).toBe(x) | No retry needed |
Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
await page.waitForTimeout(2000) | Arbitrary delay. Too slow when fast, too short when slow. Flaky. | Use a web-first assertion: await expect(locator).toBeVisible() |
const visible = await el.isVisible(); expect(visible).toBe(true) | isVisible() resolves once — no retry. Race condition. | await expect(el).toBeVisible() |
try { await expect(el).toBeVisible() } catch { /* ignore */ } | Swallows real failures. Masks bugs. | Use expect.soft() or restructure the test |
expect(locator).toBeVisible().then(...) | Missing await. Assertion runs detached, test passes before it resolves. | Always await expect(locator).toBeVisible() |
await expect(btn).toBeVisible(); await btn.click() | Redundant. click() auto-waits for visibility. | Just await btn.click() |
await page.waitForLoadState('networkidle') before every action | networkidle is fragile (long-poll, analytics, websockets break it). Slows tests. | Wait for a specific element or URL instead |
expect(await el.textContent()).toBe('X') | Resolves text once — no retry. | await expect(el).toHaveText('X') |
expect(await page.locator('.item').count()).toBe(5) | Resolves count once — no retry. | await expect(page.locator('.item')).toHaveCount(5) |
Using toPass() for a single assertion | Unnecessary complexity. | Use the web-first assertion directly |
| Huge timeout per assertion (>60s) | Hides real performance problems. Tests become unbearably slow on failure. | Fix the app or split the test. Use 10-30s max. |
Troubleshooting
"Timed out 5000ms waiting for expect(...).toBeVisible()"
Cause: The element never appeared within the assertion timeout. Common reasons: 1. Wrong locator — element exists but locator doesn't match. 2. Element is behind a loading spinner or inside a collapsed section. 3. Network request that populates the element is slow.
Fix:
- Run with
--uior--debugto visually inspect the page state at failure time. - Check the locator matches in the browser console:
playwright.$(selector). - Increase timeout for genuinely slow operations:
{ timeout: 15_000 }. - Verify the locator targets the right element:
await expect(locator).toHaveCount(1)first.
"expect.soft: Test finished with X failed assertions"
Cause: Soft assertions collected failures but you have no immediate visibility into which ones.
Fix: Check the HTML report (npx playwright show-report). Each soft failure is listed with its locator, expected value, and actual value. Group related soft assertions under test.step() for better readability.
"Expected ' Dashboard ' to have text 'Dashboard'"
Cause: toHaveText() performs full text match including normalization, but whitespace mismatch still trips people up when elements have unusual rendering.
Fix:
- Use
toContainText('Dashboard')for a substring match that is more resilient to whitespace. - Use regex:
toHaveText(/Dashboard/). - Check for zero-width spaces or special Unicode characters with
--debug.
Related
- core/locators.md — locator strategies used in assertion targets
- core/fixtures-and-hooks.md — custom fixtures for reusable assertion setup
- core/debugging.md — debugging assertion failures with UI mode and traces
- core/flaky-tests.md — fixing timing-related flakiness
- core/error-index.md — specific error messages and fixes
Related skills
How it compares
Pick playwright-core over generic testing skills when you need TestDino's 46-guide Playwright canon with Golden Rules instead of ad hoc selector advice.
FAQ
How many guides does playwright-core include?
playwright-core includes 46 reference guides in the testdino-hq/playwright-skill core pack. Topics span locators, assertions, fixtures, authentication, API testing, network mocking, visual regression, accessibility, and framework-specific recipes.
What are the Playwright Golden Rules in playwright-core?
playwright-core lists ten Golden Rules including preferring getByRole locators, banning page.waitForTimeout, using web-first expect(locator) assertions, isolating tests, setting baseURL in config, enabling CI retries of 2, and capturing traces on-first-retry.
Which frameworks does playwright-core document?
playwright-core provides dedicated guides for Next.js App and Pages Router, React with CRA or Vite, Vue 3 and Nuxt, and Angular projects. It also covers API, component, mobile, Electron, and security testing specialized topics.