
E2e Testing
- 87 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with testing & qa tasks during AI-assisted development.
About
e2e-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted coding.
- e2e-testing
- Testing & QA
- AI-coding skill
E2e Testing by the numbers
- 87 all-time installs (skills.sh)
- +4 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,049 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill e2e-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 87 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with testing & qa tasks during AI-assisted development.
Files
E2E Testing
Overview
Covers E2E test architecture and patterns using Playwright — how to structure test suites, organize tests, and apply proven patterns for authentication, mocking, visual regression, accessibility auditing, and CI parallelism. Focuses on the WHY and WHEN of test patterns rather than Playwright API specifics.
When to use: Designing test suite architecture, structuring Page Object Models, planning CI sharding strategies, setting up authentication flows, organizing tests with tags and annotations, implementing visual regression workflows, mocking network requests, auditing accessibility.
When NOT to use: Unit testing (use Vitest/Jest), API-only testing (use integration tests), component testing in isolation (use component test runners). For Playwright API details, browser automation, scraping, or troubleshooting Playwright errors, use the playwright skill.
Quick Reference
| Pattern | API/Tool | Key Points |
|---|---|---|
| Role-based locators | getByRole, getByText, getByLabel | Preferred over CSS/XPath selectors |
| Page Object Model | Classes in tests/pages/ | Encapsulate all page-specific locators and actions |
| Authentication | storageState + setup projects | Authenticate once, reuse across tests |
| Visual regression | expect(page).toHaveScreenshot() | Mask dynamic content to prevent flakes |
| Accessibility audit | AxeBuilder from @axe-core/playwright | .withTags() + .analyze() on key flows |
| Trace debugging | trace: 'on-first-retry' | Full DOM snapshots, network logs, and timeline |
| Network mocking | page.route() | Stable tests without third-party dependencies |
| HAR replay | page.routeFromHAR() | High-fidelity mocks from recorded traffic |
| Image blocking | page.route('**/*.{png,jpg}', abort) | Speed up tests by skipping images |
| CI sharding | --shard=1/4 | Split suite across parallel CI machines |
| Blob reports | --reporter=blob + merge-reports | Merge sharded results into a single report |
| Test tags | { tag: ['@smoke'] } | Filter tests by category with --grep |
| Test steps | test.step('name', async () => {}) | Group actions in trace viewer and reports |
| Changed tests only | --only-changed=$GITHUB_BASE_REF | Run only test files changed since base branch |
| Native a11y checks | toHaveAccessibleName, toHaveRole | Lightweight alternative to full axe-core scans |
| Git info in reports | captureGitInfo reporter option | Link test reports to commits for CI debugging |
| Web-first assertions | expect(element).toBeVisible() | Auto-wait instead of waitForTimeout |
| Fixture composition | mergeTests() / mergeExpects() | Combine 3+ fixture modules into one test |
| Auto fixtures | { auto: true } on test.extend() | Run for every test (logging, screenshots) |
| Fixture options | { option: true } on test.extend() | Configurable via config or test.use() |
| Data-driven tests | for...of loop generating test() calls | Parameterized tests from arrays or CSV files |
| Context emulation | browser.newContext() options | locale, timezone, colorScheme, offline, isMobile |
| Two roles in one test | Concurrent browser.newContext() calls | Separate storageState per context |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Using page.waitForTimeout() for element visibility | Use web-first assertions like expect(element).toBeVisible() which auto-wait |
| Writing raw locators directly in test files | Encapsulate all selectors in Page Object Model classes |
| Testing third-party APIs (Stripe, Auth0) directly | Mock external services with page.route() for stable, fast tests |
| Debugging CI failures with screenshots instead of traces | Configure trace: 'on-first-retry' and use Playwright Trace Viewer |
| Sharing state between tests via global variables | Use a fresh BrowserContext per test for full isolation |
| Running all tests in a single CI job | Use Playwright sharding (--shard=1/N) across parallel machines |
| Using CSS/XPath selectors for element location | Use role-based locators that survive refactors and enforce accessibility |
| Logging in via UI in every test | Use storageState with setup projects to authenticate once |
Using injectAxe/checkA11y from axe-playwright | Use AxeBuilder from @axe-core/playwright with .analyze() |
Committing storageState JSON files to git | Add playwright/.auth/ to .gitignore |
Relying on storageState for sign-out tests | Sign-out invalidates server session — sign in via UI for each sign-out test |
Assuming storageState persists sessionStorage | It only saves cookies + localStorage — use addInitScript() for sessionStorage |
| Duplicating test code for locale/currency variants | Use fixture options ({ option: true }) with per-project config overrides |
Creating fixtures without mergeTests() for 3+ modules | Use mergeTests() to compose fixture files into a single test export |
Delegation
- Discover which user flows lack E2E coverage: Use
Exploreagent - Build a full Page Object Model test suite for an application: Use
Taskagent - Plan a CI sharding strategy for a large test suite: Use
Planagent
For Playwright API details, browser automation, scraping, stealth mode, screenshots/PDFs, Docker deployment, or troubleshooting Playwright errors, use the playwright skill.References
- Role-based locators, auto-waiting, chaining, filtering, and locator strategy
- Page Object Model architecture, fixtures integration, and base page patterns
- Fixtures: scoping, composition, auto fixtures, options, and data-driven tests
- Authentication with storageState, setup projects, and multi-role testing
- Network mocking, route interception, HAR replay, and WebSocket mocking
- Accessibility auditing with AxeBuilder, WCAG tags, and reusable fixtures
- Visual regression, snapshot testing, masking, tolerance thresholds, and baseline management
- CI sharding, parallelism, blob reports, and browser caching
- Test organization with tags, annotations, test.step, and project configuration
Accessibility Testing
Setup
Install the official axe-core Playwright integration:
npm install -D @axe-core/playwrightImport AxeBuilder in test files:
import AxeBuilder from '@axe-core/playwright';Basic Full-Page Scan
Run a full accessibility scan and assert no violations:
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage has no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});Filtering by WCAG Standards
Target specific WCAG success criteria with .withTags():
test('meets WCAG 2.1 AA standards', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});Common tag groups:
| Tags | Standard |
|---|---|
wcag2a | WCAG 2.0 Level A |
wcag2aa | WCAG 2.0 Level AA |
wcag21a | WCAG 2.1 Level A |
wcag21aa | WCAG 2.1 Level AA |
best-practice | axe-core best practices (not WCAG) |
Scanning Specific Sections
Limit scans to a part of the page with .include() and .exclude():
test('navigation menu is accessible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.include('#navigation-menu')
.analyze();
expect(results.violations).toEqual([]);
});Exclude known problematic elements (third-party widgets, ads):
const results = await new AxeBuilder({ page })
.exclude('[id^="google_ads_iframe_"]')
.exclude('#third-party-chat-widget')
.analyze();Reusable Accessibility Fixture
Create a shared fixture with pre-configured rules:
import { test as base } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
type AxeFixture = {
makeAxeBuilder: () => AxeBuilder;
};
export const test = base.extend<AxeFixture>({
makeAxeBuilder: async ({ page }, use) => {
const makeAxeBuilder = () =>
new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.exclude('#known-issue-banner');
await use(makeAxeBuilder);
},
});
export { expect } from '@playwright/test';Use in tests:
import { test, expect } from './fixtures';
test('dashboard is accessible', async ({ page, makeAxeBuilder }) => {
await page.goto('/dashboard');
const results = await makeAxeBuilder().analyze();
expect(results.violations).toEqual([]);
});
test('settings page is accessible', async ({ page, makeAxeBuilder }) => {
await page.goto('/settings');
const results = await makeAxeBuilder().include('#settings-form').analyze();
expect(results.violations).toEqual([]);
});Integrating with User Flow Tests
Add accessibility checks to existing E2E tests at key interaction points:
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('checkout flow is accessible at each step', async ({ page }) => {
await page.goto('/cart');
await test.step('cart page accessibility', async () => {
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
await page.getByRole('button', { name: 'Checkout' }).click();
await test.step('payment page accessibility', async () => {
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
});Handling Violations in Reports
Log detailed violation info when tests fail:
test('page is accessible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
for (const violation of results.violations) {
console.log(
`${violation.id}: ${violation.help} (${violation.impact})`,
violation.nodes.map((n) => n.html),
);
}
expect(results.violations).toEqual([]);
});Disabling Specific Rules
Disable rules that produce false positives in your context:
const results = await new AxeBuilder({ page })
.disableRules(['color-contrast'])
.analyze();Use sparingly. Document why a rule is disabled.
Native Playwright Accessibility Assertions
For simple accessibility checks, Playwright provides built-in assertions that don't require axe-core:
import { test, expect } from '@playwright/test';
test('form elements have accessible names', async ({ page }) => {
await page.goto('/form');
await expect(page.getByRole('textbox')).toHaveAccessibleName('Email address');
await expect(page.getByRole('textbox')).toHaveAccessibleDescription(
'Enter your work email',
);
await expect(page.locator('#submit-btn')).toHaveRole('button');
});| Assertion | Checks |
|---|---|
toHaveAccessibleName(name) | aria-label, aria-labelledby, <label> |
toHaveAccessibleDescription(desc) | aria-describedby, title |
toHaveRole(role) | ARIA role of the element |
When to use native vs axe-core:
- Native assertions -- quick spot checks on specific elements (no install, no overhead)
- AxeBuilder -- full WCAG audits scanning entire pages for all violation types
Limitations
- Automated tools detect roughly 30-50% of WCAG issues
- Cannot verify subjective criteria like alt text quality or logical reading order
- Cannot test keyboard navigation flow or screen reader announcements
- Complement automated scans with manual testing for full coverage
Authentication
Why Reuse Auth State
Logging in via UI for every test is slow and fragile. Playwright's storageState captures cookies and localStorage from an authenticated session and replays them in subsequent tests, skipping the login UI entirely.
Setup Projects (Recommended)
Configure a setup project in playwright.config.ts that runs authentication before all test projects:
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});Auth Setup File
Create tests/auth.setup.ts to perform login once and save the auth state:
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/auth/login');
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});All tests in projects that depend on setup automatically receive the authenticated state.
API-Based Authentication
For faster setup, authenticate via API instead of UI:
import { test as setup } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate via API', async ({ request }) => {
await request.post('/api/auth/login', {
data: {
email: 'user@example.com',
password: 'password123',
},
});
await request.storageState({ path: authFile });
});Multi-Role Authentication
Test different user roles by creating separate auth states and projects:
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'admin tests',
use: { storageState: 'playwright/.auth/admin.json' },
dependencies: ['setup'],
},
{
name: 'user tests',
use: { storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});Auth setup file for multiple roles:
import { test as setup } from '@playwright/test';
setup('authenticate as admin', async ({ page }) => {
await page.goto('/auth/login');
await page.getByLabel('Email address').fill('admin@example.com');
await page.getByLabel('Password').fill('admin-pass');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/admin');
await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});
setup('authenticate as user', async ({ page }) => {
await page.goto('/auth/login');
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Password').fill('user-pass');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});Unauthenticated Tests
For tests that need no auth (login page tests, public pages), override storageState to clear it:
import { test, expect } from '@playwright/test';
test.use({ storageState: { cookies: [], origins: [] } });
test('login page shows sign-in form', async ({ page }) => {
await page.goto('/auth/login');
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});Worker-Scoped Auth for Parallel Tests
When tests run in parallel and each worker needs its own authenticated session (to avoid sharing session state), use worker-scoped fixtures:
import { test as base, type BrowserContext } from '@playwright/test';
import path from 'node:path';
export const test = base.extend<{}, { workerStorageState: string }>({
storageState: ({ workerStorageState }, use) => use(workerStorageState),
workerStorageState: [
async ({ browser }, use) => {
const id = test.info().parallelIndex;
const fileName = path.resolve(`playwright/.auth/worker-${id}.json`);
const page = await browser.newPage({
storageState: undefined,
});
const account = { email: `user-${id}@example.com`, password: 'pass' };
await page.goto('/auth/login');
await page.getByLabel('Email address').fill(account.email);
await page.getByLabel('Password').fill(account.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: fileName });
await page.close();
await use(fileName);
},
{ scope: 'worker' },
],
});
export { expect } from '@playwright/test';Session Storage Gotcha
storageState persists cookies and localStorage but NOT sessionStorage. Inject sessionStorage via addInitScript():
const sessionData = await page.evaluate(() => JSON.stringify(sessionStorage));
const newContext = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
await newContext.addInitScript((data) => {
for (const [key, value] of Object.entries(JSON.parse(data))) {
sessionStorage.setItem(key, value as string);
}
}, sessionData);Sign-Out Tests
Do NOT rely on storageState for tests that sign out. Sign-out invalidates the server-side session, making shared storageState unreliable. Each sign-out test should sign in via UI to create its own fresh session.
Two Roles in One Test
When testing interactions between two authenticated users (e.g., admin approves user request), create concurrent browser contexts:
test('admin approves user request', async ({ browser }) => {
const userCtx = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
const adminCtx = await browser.newContext({
storageState: 'playwright/.auth/admin.json',
});
const userPage = await userCtx.newPage();
const adminPage = await adminCtx.newPage();
// ... test interactions between both
await userCtx.close();
await adminCtx.close();
});Manually created contexts must be explicitly closed.
Context Emulation
browser.newContext() and test.use() accept emulation options beyond storageState:
const context = await browser.newContext({
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
geolocation: { latitude: 52.52, longitude: 13.405 },
permissions: ['geolocation'],
colorScheme: 'dark',
...devices['iPhone 15 Pro Max'],
});Additional options: offline, ignoreHTTPSErrors, httpCredentials (HTTP Basic Auth), bypassCSP, extraHTTPHeaders, isMobile, deviceScaleFactor, userAgent, javaScriptEnabled: false (verify SSR output).
Runtime Viewport and Media Changes
await page.setViewportSize({ width: 375, height: 812 });
await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
await page.emulateMedia({ media: 'print' });
await page.emulateMedia({ reducedMotion: 'reduce' });Security
- Add
playwright/.auth/to.gitignoreto avoid committing session data - Use environment variables for credentials, never hardcode in test files
- Use separate test accounts with minimal permissions
- Regenerate auth state on each CI run (do not cache
storageStatefiles)
CI Sharding
What is Sharding
Sharding splits a test suite across multiple CI machines that run in parallel. Each machine runs a subset of tests independently, reducing total execution time for large suites.
GitHub Actions Workflow
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Cache Playwright Browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Tests
run: npx playwright test --shard=${{ matrix.shard }}/4
- name: Upload blob report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shard }}
path: blob-report/
retention-days: 1
merge-reports:
if: ${{ !cancelled() }}
needs: e2e
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
- run: npm ci
- name: Download blob reports
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge reports
run: npx playwright merge-reports --reporter=html ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
with:
name: html-report
path: playwright-report/
retention-days: 14Workers vs Sharding
| Dimension | Workers | Sharding |
|---|---|---|
| Scope | Single machine | Multiple machines |
| Config | workers in config or --workers=N | --shard=X/N on CLI |
| Use case | Maximize CPU utilization locally | Distribute across CI matrix |
Use both together for maximum throughput: multiple workers per shard.
export default defineConfig({
workers: process.env.CI ? 2 : undefined,
});Blob Reports (Merging Results)
When sharding, each machine produces its own test report. Use blob reports to merge them:
1. Configure the blob reporter in playwright.config.ts:
export default defineConfig({
reporter: process.env.CI ? 'blob' : 'html',
});2. Each shard uploads its blob report as a CI artifact 3. A final job downloads all blobs and merges them:
npx playwright merge-reports --reporter=html ./all-blob-reportsBrowser Caching
Cache downloaded browser binaries to speed up CI:
- name: Cache Playwright Browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}The cache key uses the lockfile hash so browsers are re-downloaded when the Playwright version changes.
Retry Strategy
Configure retries to handle flaky tests in CI without masking real failures:
export default defineConfig({
retries: process.env.CI ? 2 : 0,
use: {
trace: 'on-first-retry',
},
});retries: 2in CI gives tests two extra attemptstrace: 'on-first-retry'captures a trace on the first retry for debugging without the overhead of tracing every run
Trace Upload
Upload traces as artifacts for debugging CI failures:
- name: Upload traces
if: failure()
uses: actions/upload-artifact@v4
with:
name: traces-${{ matrix.shard }}
path: test-results/
retention-days: 7View traces locally or at trace.playwright.dev.
Running Only Changed Tests
Run tests affected by a PR to get faster feedback:
npx playwright test --only-changed=$GITHUB_BASE_REFIn GitHub Actions:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run changed tests only
if: github.event_name == 'pull_request'
run: npx playwright test --only-changed=$GITHUB_BASE_REF
- name: Run full suite
if: github.event_name == 'push'
run: npx playwright testRequires fetch-depth: 0 for git history access. Useful as a fast-feedback step before the full sharded suite.
Git Info in Test Reports
Link test reports to the commit that produced them with captureGitInfo:
export default defineConfig({
reporter: [['html', { captureGitInfo: { commit: true, diff: true } }]],
});The HTML report will include the commit hash and diff, making it easy to trace CI failures back to the exact change.
Playwright Configuration for CI
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? 'blob' : 'html',
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});Key CI settings:
forbidOnly: Fail if.onlyis left in testsretries: Allow retries only in CIworkers: Limit parallelism to avoid resource contentiontrace: 'on-first-retry': Capture traces only on retriesscreenshot: 'only-on-failure': Save screenshots on failures for quick triage
Fixtures
Fixture Scoping
Test-scoped fixtures (the default) are created fresh for every test. Worker-scoped fixtures are created once per worker process and shared across all tests that worker runs.
import { test as base } from '@playwright/test';
type WorkerFixtures = {
dbName: string;
};
const test = base.extend<{}, WorkerFixtures>({
dbName: [
async ({}, use, workerInfo) => {
const name = `test_db_${workerInfo.workerIndex}`;
await createDatabase(name);
await use(name);
await dropDatabase(name);
},
{ scope: 'worker' },
],
});Worker-scoped fixtures get a separate timeout equal to the test timeout. Use them for: database seeding, API auth tokens, expensive one-time setup.
Worker-scoped fixtures cannot depend on test-scoped fixtures. Test-scoped fixtures can depend on worker-scoped ones.
workerIndex vs parallelIndex
workerInfo.workerIndex is unique per worker for the entire test run and never reused. Use it for naming resources that must not collide across workers (temp DB names, log files, ports).
testInfo.parallelIndex ranges from 0 to workers - 1 and is reused across retries. Use it for partitioning a fixed pool of resources (pre-seeded user accounts, pre-allocated ports).
const test = base.extend<
{},
{ testAccount: { email: string; password: string } }
>({
testAccount: [
async ({}, use, workerInfo) => {
// parallelIndex reuses indices — maps to pre-seeded accounts
const accounts = [
{ email: 'user0@test.com', password: 'pass0' },
{ email: 'user1@test.com', password: 'pass1' },
{ email: 'user2@test.com', password: 'pass2' },
{ email: 'user3@test.com', password: 'pass3' },
];
await use(accounts[workerInfo.parallelIndex]);
},
{ scope: 'worker' },
],
});Automatic Fixtures
{ auto: true } runs the fixture for every test without the test explicitly requesting it. Use for cross-cutting concerns: logging, screenshots on failure, performance instrumentation.
import { test as base } from '@playwright/test';
const test = base.extend<{ serverLogs: void }>({
serverLogs: [
async ({}, use, testInfo) => {
const logs: string[] = [];
const collector = startLogCollector(logs);
await use();
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach('server-logs', {
body: logs.join('\n'),
contentType: 'text/plain',
});
}
collector.stop();
},
{ auto: true },
],
});Auto fixtures can be test-scoped or worker-scoped. Combine { auto: true, scope: 'worker' } for one-time per-worker setup that every test benefits from.
Composing Fixtures with mergeTests
When multiple fixture modules each export their own test, merge them into a single test with mergeTests:
import { mergeTests } from '@playwright/test';
import { test as a11yTest } from './a11y-fixtures';
import { test as accountsTest } from './account-fixtures';
import { test as dbTest } from './db-fixtures';
export const test = mergeTests(a11yTest, accountsTest, dbTest);For one or two modules, chain base.extend() calls instead:
import { test as base } from '@playwright/test';
const withDb = base.extend<{ db: Database }>({
db: async ({}, use) => {
const db = await connectDb();
await use(db);
await db.disconnect();
},
});
export const test = withDb.extend<{ apiClient: ApiClient }>({
apiClient: async ({ db }, use) => {
await use(new ApiClient(db));
},
});mergeExpects works the same way for combining custom matchers:
import { mergeExpects } from '@playwright/test';
import { expect as a11yExpect } from './a11y-matchers';
import { expect as snapshotExpect } from './snapshot-matchers';
export const expect = mergeExpects(a11yExpect, snapshotExpect);Boxed Fixtures
{ box: true } hides fixture setup steps from the HTML report. When a boxed fixture fails, the report shows the failure at the test level rather than buried inside fixture internals.
const test = base.extend<{ page: Page }>({
page: [
async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await use(page);
await context.close();
},
{ box: true },
],
});Use for infrastructure fixtures where the internal steps are noise in test reports.
Fixture Options
{ option: true } makes a fixture configurable via playwright.config.ts or test.use():
type Options = {
defaultLocale: string;
currencyCode: string;
};
const test = base.extend<Options>({
defaultLocale: ['en-US', { option: true }],
currencyCode: ['USD', { option: true }],
});
export default test;Override per-project in config:
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'en',
use: { defaultLocale: 'en-US', currencyCode: 'USD' },
},
{
name: 'de',
use: { defaultLocale: 'de-DE', currencyCode: 'EUR' },
},
],
});Override per-describe block:
test.describe('Japanese locale', () => {
test.use({ defaultLocale: 'ja-JP', currencyCode: 'JPY' });
test('formats prices in yen', async ({
page,
defaultLocale,
currencyCode,
}) => {
// ...
});
});Use options for: locale, currency, feature-flag variants, API base URLs. Avoids duplicating test logic across configurations.
Fixture Timeout
Set an independent timeout on slow fixtures, separate from the test timeout:
const test = base.extend<{}, { heavySeed: void }>({
heavySeed: [
async ({}, use) => {
await seedLargeDataset();
await use();
},
{ scope: 'worker', timeout: 60_000 },
],
});If a fixture exceeds its timeout, the test fails with a clear message identifying which fixture timed out.
Data-Driven Tests
Generate parameterized tests with for...of. Each iteration becomes a separate named test in the report:
const cases = [
{ type: 'checking', label: 'Checking Account' },
{ type: 'savings', label: 'Savings Account' },
{ type: 'money-market', label: 'Money Market Account' },
];
for (const { type, label } of cases) {
test(`creates a ${type} account`, async ({ page }) => {
await page.goto('/accounts/new');
await page
.getByRole('combobox', { name: 'Account type' })
.selectOption(type);
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByRole('heading')).toHaveText(label);
});
}For CSV-driven tests, read the file and parse it:
import fs from 'node:fs';
import path from 'node:path';
import { test, expect } from '@playwright/test';
const csv = fs.readFileSync(path.join(__dirname, 'test-data.csv'), 'utf-8');
const rows = csv
.trim()
.split('\n')
.slice(1)
.map((line) => {
const [email, name, role] = line.split(',');
return { email, name, role };
});
for (const { email, name, role } of rows) {
test(`user ${email} has role ${role}`, async ({ page }) => {
await page.goto(`/admin/users?search=${email}`);
await expect(page.getByRole('cell', { name })).toBeVisible();
await expect(page.getByRole('cell', { name: role })).toBeVisible();
});
}Wrap data-driven tests in test.describe to group them in the report:
test.describe('account creation', () => {
for (const { type, label } of cases) {
test(`creates a ${type} account`, async ({ page }) => {
// ...
});
}
});Locators and Auto-Waiting
Locator Priority
Prioritize locators that match how users and screen readers perceive the UI. Playwright recommends this order:
1. `getByRole` — matches ARIA roles (preferred for all interactive elements) 2. `getByLabel` — matches form fields by their associated label 3. `getByPlaceholder` — matches inputs by placeholder text 4. `getByText` — matches by visible text content 5. `getByAltText` — matches images by alt text 6. `getByTitle` — matches by title attribute 7. `getByTestId` — last resort fallback when no accessible attribute exists
getByRole (Preferred)
Matches elements by their ARIA role and accessible name:
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('heading', { level: 1 }).toBeVisible();
await page.getByRole('link', { name: 'Documentation' }).click();
await page.getByRole('checkbox', { name: 'Accept terms' }).check();
await page.getByRole('textbox', { name: 'Search' }).fill('playwright');Common roles: button, link, heading, textbox, checkbox, radio, combobox, listbox, option, tab, tabpanel, dialog, alert, navigation, main.
getByLabel
Best for form fields with visible labels:
await page.getByLabel('Username').fill('john_doe');
await page.getByLabel('Password').fill('secret');
await page.getByLabel('Remember me').check();getByText
Best for verifying content and clicking non-interactive elements:
await expect(page.getByText('Success! Your order is placed.')).toBeVisible();
await page.getByText('Read more', { exact: true }).click();Use { exact: true } to prevent partial matches.
getByTestId
Use only when no accessible attribute exists. Configure the test ID attribute in playwright.config.ts:
export default defineConfig({
use: {
testIdAttribute: 'data-testid',
},
});Then use in tests:
await page.getByTestId('nav-menu').click();If you need getByTestId, consider fixing the accessibility tree first.
Chaining and Filtering
Narrow locators by chaining or filtering to find elements within a specific scope:
const productCard = page.locator('.product-card').filter({
hasText: 'Running Shoes',
});
await productCard.getByRole('button', { name: 'Add to cart' }).click();Filter by another locator:
const row = page.getByRole('row').filter({
has: page.getByRole('cell', { name: 'John' }),
});
await row.getByRole('button', { name: 'Edit' }).click();Chain locators to scope within a parent:
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Confirm' }).click();Locator Strictness
Locators are strict by default. If multiple elements match, the action throws. Use .first(), .last(), or .nth(index) to disambiguate:
await page.getByRole('button', { name: 'Delete' }).first().click();
await page.getByRole('listitem').nth(2).click();Why Avoid CSS/XPath
- Fragile: Changing
div.btn-primarytobutton.ctabreaks a CSS selector, butgetByRole('button')continues to work - Not accessible: Role-based locators enforce accessible HTML. If an element cannot be found by role, it is probably not accessible to screen readers
- Coupled to implementation: CSS selectors expose internal DOM structure that changes during refactors
Auto-Waiting (Web-First Assertions)
Playwright automatically waits for elements to be actionable before performing actions. Web-first assertions retry until the condition is met or the timeout expires (default 5 seconds):
await expect(page.getByRole('alert')).toContainText('Error');
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Loading')).toBeHidden();Never use manual timeouts:
await page.waitForTimeout(3000);Configure the assertion timeout globally in playwright.config.ts:
export default defineConfig({
expect: {
timeout: 10_000,
},
});Shadow DOM
Playwright locators pierce the Shadow DOM by default. No special configuration is needed for Web Components:
await page.getByRole('button', { name: 'Shadow Button' }).click();Using the Locator Picker
Playwright provides tools to help find the best locator:
- Codegen: Run
npx playwright codegen <url>to generate tests interactively. Playwright picks the most resilient locator automatically. - VS Code extension: Use the Pick Locator button to click any element and get a recommended locator.
- Inspector: Run
npx playwright test --debugto step through tests and inspect locators.
Network Mocking
Why Mock
1. Speed: Skipping real network calls makes tests 5-10x faster 2. Stability: Third-party APIs (Stripe, GitHub) can be down or have rate limits 3. Edge case testing: Easily simulate 500 errors, timeouts, or malformed JSON 4. Determinism: Tests produce the same results regardless of backend state
Mocking a REST API
Intercept requests and return custom responses:
test('shows error when API fails', async ({ page }) => {
await page.route('**/api/user/*', (route) =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
}),
);
await page.goto('/profile');
await expect(page.getByText('Something went wrong')).toBeVisible();
});Mocking Successful Responses
test('displays user profile', async ({ page }) => {
await page.route('**/api/user/1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 1,
name: 'Jane Doe',
email: 'jane@example.com',
}),
}),
);
await page.goto('/profile/1');
await expect(page.getByRole('heading', { name: 'Jane Doe' })).toBeVisible();
});Modifying Real Responses
Intercept the real response and modify it before it reaches the page:
test('adds feature flag to API response', async ({ page }) => {
await page.route('**/api/config', async (route) => {
const response = await route.fetch();
const json = await response.json();
json.featureFlags.newDashboard = true;
await route.fulfill({ response, json });
});
await page.goto('/dashboard');
await expect(page.getByText('New Dashboard')).toBeVisible();
});Waiting for API Responses
Use page.waitForResponse to wait for a specific request to complete:
test('submits form and waits for API', async ({ page }) => {
await page.goto('/contact');
await page.getByLabel('Message').fill('Hello');
await page.getByRole('button', { name: 'Send' }).click();
const response = await page.waitForResponse('**/api/messages');
expect(response.status()).toBe(201);
});Blocking Images for Performance
Skip image downloads to speed up tests:
await page.route('**/*.{png,jpg,jpeg,gif,svg,webp}', (route) => route.abort());Conditional Mocking
Mock only specific conditions while letting other requests through:
await page.route('**/api/**', (route) => {
if (route.request().method() === 'POST') {
return route.fulfill({
status: 201,
body: JSON.stringify({ id: 'new-item' }),
});
}
return route.continue();
});HAR Replay
Record real network traffic into a .har file and replay it during tests:
await page.routeFromHAR('tests/fixtures/api-snapshot.har', {
url: '**/api/**',
update: false,
});Recording HAR Files
Set update: true to capture fresh traffic:
await page.routeFromHAR('tests/fixtures/api-snapshot.har', {
url: '**/api/**',
update: true,
});Or record from the command line:
npx playwright open --save-har=tests/fixtures/api-snapshot.har https://your-app.comSwitch update back to false for stable test execution.
WebSocket Mocking
Mock WebSocket connections for real-time features:
test('receives live notification', async ({ page }) => {
const ws = await page.routeWebSocket('**/ws/notifications', (ws) => {
ws.onMessage((message) => {
if (message === 'ping') {
ws.send('pong');
}
});
});
await page.goto('/dashboard');
ws.send(JSON.stringify({ type: 'notification', text: 'New message' }));
await expect(page.getByText('New message')).toBeVisible();
});Route Patterns
| Pattern | Matches |
|---|---|
**/api/** | Any URL containing /api/ |
**/api/user/* | Single path segment after /api/user/ |
https://api.stripe.com/** | All Stripe API calls |
**/*.{png,jpg} | All PNG and JPG files |
MSW Integration
Teams sharing mock definitions between unit tests and E2E tests can use @msw/playwright:
npm install -D @msw/playwright mswimport { test } from '@playwright/test';
import { createWorkerFixture } from '@msw/playwright';
import { handlers } from '../mocks/handlers';
const test = base.extend({
worker: createWorkerFixture(handlers),
});
test('uses shared MSW handlers', async ({ page, worker }) => {
await page.goto('/dashboard');
await expect(page.getByText('Mock User')).toBeVisible();
});This avoids duplicating mock definitions across vitest (unit) and playwright (E2E) test suites.
Context-Level Mocking
Apply mocks to all pages within a context:
test('mock at context level', async ({ context, page }) => {
await context.route('**/api/**', (route) =>
route.fulfill({
status: 200,
body: JSON.stringify({ data: 'mocked' }),
}),
);
await page.goto('/page-one');
const popup = await page.waitForEvent('popup');
await expect(popup.getByText('mocked')).toBeVisible();
});Page Object Model
Why POM
The Page Object Model encapsulates page-specific locators and actions into classes, keeping test files focused on user behavior rather than DOM structure. When UI changes, update one POM class instead of every test.
Basic POM Structure
import { type Locator, type Page, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email address');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/auth/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}Using POMs in Tests
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';
test('successful login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'wrong');
await loginPage.expectError('Invalid credentials');
});Integrating POMs with Fixtures
Reduce boilerplate by extending Playwright fixtures to provide POMs automatically:
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { DashboardPage } from './pages/dashboard-page';
type Fixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});
export { expect } from '@playwright/test';Then use in tests:
import { test, expect } from './fixtures';
test('dashboard shows user name', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(dashboardPage.welcomeMessage).toContainText('Welcome');
});Base Page Class
Extract common functionality into a base class:
import { type Locator, type Page, expect } from '@playwright/test';
export abstract class BasePage {
readonly page: Page;
readonly heading: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole('heading', { level: 1 });
}
async expectHeading(text: string) {
await expect(this.heading).toHaveText(text);
}
async expectURL(path: string) {
await expect(this.page).toHaveURL(path);
}
}Extend for specific pages:
import { type Locator, type Page } from '@playwright/test';
import { BasePage } from './base-page';
export class SettingsPage extends BasePage {
readonly saveButton: Locator;
readonly nameInput: Locator;
constructor(page: Page) {
super(page);
this.saveButton = page.getByRole('button', { name: 'Save changes' });
this.nameInput = page.getByLabel('Display name');
}
async goto() {
await this.page.goto('/settings');
}
async updateName(name: string) {
await this.nameInput.fill(name);
await this.saveButton.click();
}
}Component Objects
For reusable UI components that appear on multiple pages, create component objects:
import { type Locator, type Page, expect } from '@playwright/test';
export class NavigationBar {
readonly container: Locator;
readonly searchInput: Locator;
readonly profileMenu: Locator;
constructor(page: Page) {
this.container = page.getByRole('navigation');
this.searchInput = this.container.getByRole('searchbox');
this.profileMenu = this.container.getByRole('button', { name: 'Profile' });
}
async search(query: string) {
await this.searchInput.fill(query);
await this.searchInput.press('Enter');
}
async openProfile() {
await this.profileMenu.click();
}
}Compose into page objects:
import { type Locator, type Page } from '@playwright/test';
import { BasePage } from './base-page';
import { NavigationBar } from './components/navigation-bar';
export class DashboardPage extends BasePage {
readonly nav: NavigationBar;
readonly welcomeMessage: Locator;
constructor(page: Page) {
super(page);
this.nav = new NavigationBar(page);
this.welcomeMessage = page.getByRole('heading', { level: 2 });
}
}Project Structure
tests/
├── fixtures.ts # Custom fixtures with POMs
├── pages/ # Page objects
│ ├── base-page.ts
│ ├── login-page.ts
│ ├── dashboard-page.ts
│ └── settings-page.ts
├── components/ # Reusable component objects
│ └── navigation-bar.ts
├── auth.setup.ts # Authentication setup
└── specs/ # Test files
├── login.spec.ts
├── dashboard.spec.ts
└── settings.spec.tsPOM Guidelines
- Single responsibility: One POM class per page or distinct section
- No assertions in action methods: Keep assertions in dedicated
expect*methods or in test files - No state between tests: POMs receive a fresh
pageinstance per test - Use role-based locators: Define locators with
getByRole,getByLabel,getByTextin the constructor - Keep methods user-centric: Name methods after user actions (
login,submitForm,openSettings) not implementation details (clickButton,fillInput) - Return types for navigation: Methods that navigate to a new page can return the target POM
Test Organization
Tags
Tags categorize tests for selective execution. Use the tag property:
test('quick login check', { tag: ['@smoke'] }, async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading')).toBeVisible();
});
test(
'full checkout flow',
{ tag: ['@regression', '@slow'] },
async ({ page }) => {
// ...
},
);Apply tags to a group of tests:
test.describe('payment flows', { tag: ['@payments'] }, () => {
test('credit card payment', async ({ page }) => {
// ...
});
test('PayPal payment', async ({ page }) => {
// ...
});
});Running Tagged Tests
npx playwright test --grep @smoke
npx playwright test --grep @regression
npx playwright test --grep-invert @slowConfigure in playwright.config.ts:
export default defineConfig({
grep: /@smoke/,
grepInvert: /@slow/,
});Recommended Tags
| Tag | Purpose |
|---|---|
@smoke | Fast critical path checks |
@regression | Full regression suite |
@slow | Long-running tests |
@visual | Visual regression tests |
@a11y | Accessibility audits |
Annotations
Built-in annotations control test execution:
test.skip('feature not implemented yet', async ({ page }) => {
// Skipped entirely
});
test.fixme('known bug - crashes on Safari', async ({ page }) => {
// Skipped, marked as needing fix
});
test('known failure', async ({ page }) => {
test.fail();
// Playwright runs this and expects it to fail
});
test('heavy computation', async ({ page }) => {
test.slow();
// Triples the test timeout
});Conditional Annotations
Skip based on conditions:
test('Safari-only feature', async ({ page, browserName }) => {
test.skip(browserName !== 'webkit', 'Safari-only test');
// ...
});Custom Annotations
Add metadata visible in reports:
test('checkout flow', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/org/repo/issues/123',
});
// ...
});test.step
Break complex tests into named steps visible in traces and reports:
test('complete purchase flow', async ({ page }) => {
await test.step('add item to cart', async () => {
await page.goto('/products');
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByText('Added to cart')).toBeVisible();
});
await test.step('proceed to checkout', async () => {
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByRole('button', { name: 'Checkout' }).click();
});
await test.step('complete payment', async () => {
await page.getByLabel('Card number').fill('4242424242424242');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
});Steps nest for complex workflows:
await test.step('fill shipping address', async () => {
await test.step('enter street address', async () => {
await page.getByLabel('Street').fill('123 Main St');
});
await test.step('enter city and zip', async () => {
await page.getByLabel('City').fill('Springfield');
await page.getByLabel('ZIP').fill('62701');
});
});Boxed Steps
Box a step to make error stack traces point to the step call site instead of the internal failure:
await test.step(
'login',
async () => {
await loginPage.login('user@example.com', 'pass');
},
{ box: true },
);Describe Blocks
Group related tests:
test.describe('authenticated user', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard');
});
test('sees welcome message', async ({ page }) => {
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('can update profile', async ({ page }) => {
await page.getByRole('link', { name: 'Settings' }).click();
// ...
});
});Serial Mode
Force tests within a describe to run sequentially:
test.describe.configure({ mode: 'serial' });
test.describe('onboarding wizard', () => {
test('step 1: create account', async ({ page }) => {
// ...
});
test('step 2: choose plan', async ({ page }) => {
// ...
});
});Use serial mode sparingly. Prefer independent, isolated tests.
Projects for Test Organization
Use Playwright projects to run different test configurations:
export default defineConfig({
projects: [
{
name: 'smoke',
grep: /@smoke/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'regression',
grep: /@regression/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 14'] },
},
],
});Config-Level Test Tags (v1.57+)
Apply tags to all tests in a project via testConfig.tag:
export default defineConfig({
projects: [
{
name: 'smoke',
testMatch: /smoke\/.*\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
tag: '@smoke',
},
{
name: 'regression',
use: { ...devices['Desktop Chrome'] },
tag: '@regression',
},
],
});Tests in the smoke project automatically receive the @smoke tag without annotating individual tests. Combine with --grep @smoke to run all smoke tests across projects.
Folder Structure
tests/
├── fixtures.ts # Custom test fixtures
├── pages/ # Page Object Model classes
│ ├── base-page.ts
│ ├── login-page.ts
│ └── dashboard-page.ts
├── auth.setup.ts # Authentication setup
├── smoke/ # Smoke tests
│ └── critical-paths.spec.ts
├── features/ # Feature tests
│ ├── checkout.spec.ts
│ ├── search.spec.ts
│ └── settings.spec.ts
└── visual/ # Visual regression tests
└── screenshots.spec.tsHooks
test.beforeAll(async () => {
// Runs once before all tests in the file
});
test.beforeEach(async ({ page }) => {
// Runs before each test
});
test.afterEach(async ({ page }) => {
// Runs after each test
});
test.afterAll(async () => {
// Runs once after all tests in the file
});Prefer fixtures over hooks for reusable setup. Hooks are best for file-scoped shared state.
Visual Testing
Snapshot Testing
Visual regression compares a screenshot of the current UI with a baseline (golden) image:
await expect(page).toHaveScreenshot('dashboard.png');If the screenshot differs from the baseline beyond the tolerance threshold, the test fails. On the first run, Playwright creates the baseline automatically.
Full-Page Screenshots
Capture the entire scrollable page:
await expect(page).toHaveScreenshot('full-page.png', {
fullPage: true,
});Element-Level Screenshots
Capture a specific element instead of the full page:
const card = page.getByTestId('pricing-card');
await expect(card).toHaveScreenshot('pricing-card.png');Handling Dynamic Content
Dynamic content like timestamps, avatars, or random IDs cause flaky visual tests.
Masking
Hide specific elements with a solid color before taking the screenshot:
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
page.getByTestId('timestamp'),
page.getByRole('img', { name: 'Avatar' }),
page.locator('.random-ad'),
],
maskColor: '#FF00FF',
});Mocking Time
Freeze the clock so date-dependent UI renders consistently:
test('dashboard with frozen time', async ({ page }) => {
await page.clock.install({ time: new Date('2025-01-15T12:00:00') });
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard-frozen.png');
});Hiding Animations
Disable CSS animations and transitions to prevent timing-based differences:
await expect(page).toHaveScreenshot('page.png', {
animations: 'disabled',
});Tolerance Thresholds
Small rendering differences due to OS, GPU, or font version should not fail the build:
maxDiffPixels: Maximum number of pixels that can be differentmaxDiffPixelRatio: Maximum ratio of pixels that can be different (0 to 1)threshold: Per-pixel color difference threshold (0 to 1, default 0.2)
await expect(page).toHaveScreenshot('chart.png', {
maxDiffPixelRatio: 0.01,
});await expect(page).toHaveScreenshot('hero.png', {
maxDiffPixels: 100,
});Choose thresholds based on UI complexity. Simple layouts can use tighter thresholds; data-heavy dashboards may need more tolerance.
Configure defaults globally in playwright.config.ts:
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01,
animations: 'disabled',
},
},
});Baseline Management
- Store baselines in Git (use Git LFS for large binary files)
- Update baselines only after manual verification:
npx playwright test --update-snapshots - Use separate snapshot directories per platform if OS-level rendering differs
- Review baseline changes in PRs with visual diffs
Platform-Specific Baselines
Playwright stores snapshots in directories named after the project. Configure different expectations per platform:
export default defineConfig({
snapshotPathTemplate:
'{testDir}/__screenshots__/{testFilePath}/{arg}{-projectName}{ext}',
});Comparing Screenshots Programmatically
For custom comparison workflows:
test('compare two states', async ({ page }) => {
await page.goto('/before');
const before = await page.screenshot();
await page.goto('/after');
const after = await page.screenshot();
expect(before).not.toEqual(after);
});CI Considerations
- Run visual tests on a consistent OS (Linux containers) to avoid cross-platform rendering differences
- Use Docker images with fixed font packages for deterministic rendering
- Consider running visual tests as a separate CI job with
--grep @visualtags - Upload diff images as CI artifacts for easy review