Playwright Testing
When to Use
Use this skill when writing, reviewing, or debugging Playwright end-to-end tests. Covers locator strategy, test patterns, fixtures, authentication, waiting, accessibility snapshots, time manipulation, visual regression, network interception, CI integration, and flaky test prevention.
Targets Playwright v1.49+ (ARIA snapshots, Clock API, setup projects). Check version with npx playwright --version .
Project Structure
tests/ e2e/ auth.setup.ts # Auth setup project auth.spec.ts # Test files use .spec.ts dashboard.spec.ts checkout.spec.ts fixtures/ auth.fixture.ts # Custom fixtures db.fixture.ts pages/ login.page.ts # Page object models dashboard.page.ts checkout.page.ts helpers/ test-data.ts # Test data factories api-helpers.ts # Direct API calls for setup .auth/ # Stored auth state (gitignored) admin.json playwright.config.ts # Configuration
Configuration
// playwright.config.ts import { defineConfig, devices } from "@playwright/test";
export default defineConfig({ testDir: "./tests/e2e", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [["html", { open: "never" }], ["list"]], use: { baseURL: process.env.BASE_URL || "http://localhost:3000", trace: "on-first-retry", screenshot: "only-on-failure", video: "on-first-retry", },
// v1.52+: fail CI if tests only pass on retry (flaky) failOnFlakyTests: !!process.env.CI, // v1.50+: only regenerate snapshots that actually changed updateSnapshots: "changed",
projects: [ // Auth setup runs first — produces storageState files { name: "setup", testMatch: /.*.setup.ts/ },
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: "tests/.auth/admin.json",
},
dependencies: ["setup"],
},
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
storageState: "tests/.auth/admin.json",
},
dependencies: ["setup"],
},
{
name: "mobile",
use: { ...devices["Pixel 5"] },
dependencies: ["setup"],
},
],
webServer: { command: "npm run dev", port: 3000, reuseExistingServer: !process.env.CI, }, });
Selector Strategy
Priority Order (Official Recommendation)
-
getByRole() — best: mirrors how users and assistive tech see the page
-
getByLabel() — form fields associated with a label
-
getByText() — visible text content
-
getByPlaceholder() , getByAltText() , getByTitle() — secondary semantic locators
-
getByTestId() — fallback when no semantic locator works
-
CSS / XPath — last resort only
Selector Examples
// 1. Role selectors (preferred — reflects user-facing semantics) page.getByRole("button", { name: "Submit" }); page.getByRole("heading", { name: "Dashboard" }); page.getByRole("link", { name: "Settings" }); page.getByRole("textbox", { name: "Email" }); page.getByRole("dialog");
// 2. Label (form fields) page.getByLabel("Email address");
// 3. Text page.getByText("Welcome back");
// 4. Secondary semantic locators page.getByPlaceholder("Enter your email"); page.getByAltText("Company logo");
// 5. Test ID (fallback) page.getByTestId("submit-button");
// 6. CSS (avoid unless necessary) page.locator(".nav-menu >> li:first-child");
Locator Combinators
// Match either locator with .or() const saveBtn = page.getByRole("button", { name: "Save" }); const submitBtn = page.getByRole("button", { name: "Submit" }); await saveBtn.or(submitBtn).click();
// Combine conditions with .and() const enabledDialog = page.getByRole("dialog").and(page.locator(":visible"));
// Exclude elements with .filter({ hasNot }) const activeRows = page.getByRole("row").filter({ hasNot: page.getByText("Archived"), });
// Chain and filter for complex UIs await page .getByRole("listitem") .filter({ hasText: "Product 2" }) .getByRole("button", { name: "Add to cart" }) .click();
Selector Anti-Patterns
// BAD: fragile, tied to implementation page.locator("#root > div > div:nth-child(2) > button"); page.locator(".css-1a2b3c"); // generated class names page.locator("button.MuiButton-root"); // library internals
// GOOD: stable, semantic page.getByRole("button", { name: "Checkout" }); page.getByTestId("checkout-button");
Test Patterns
Arrange / Act / Assert
import { test, expect } from "@playwright/test";
test("user can submit feedback form", async ({ page }) => { // Arrange await page.goto("/feedback");
// Act await page.getByLabel("Message").fill("Great product!"); await page.getByRole("button", { name: "Submit" }).click();
// Assert await expect(page.getByText("Thank you for your feedback")).toBeVisible(); });
Page Object Model
// tests/pages/login.page.ts import { type Page, type Locator } 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"); this.passwordInput = page.getByLabel("Password"); this.submitButton = page.getByRole("button", { name: "Sign in" }); this.errorMessage = page.getByTestId("login-error"); }
async goto() { await this.page.goto("/login"); }
async login(email: string, password: string) { await this.emailInput.fill(email); await this.passwordInput.fill(password); await this.submitButton.click(); } }
// tests/e2e/auth.spec.ts 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 expect(loginPage.errorMessage).toBeVisible(); await expect(loginPage.errorMessage).toContainText("Invalid credentials"); });
Custom Fixtures
// tests/fixtures/auth.fixture.ts import { test as base, type Page } from "@playwright/test"; import { LoginPage } from "../pages/login.page";
type AuthFixtures = { loginPage: LoginPage; authenticatedPage: Page; };
export const test = base.extend<AuthFixtures>({ loginPage: async ({ page }, use) => { const loginPage = new LoginPage(page); await loginPage.goto(); await use(loginPage); },
authenticatedPage: async ({ page }, use) => { await page.goto("/login"); await page.getByLabel("Email").fill("test@example.com"); await page.getByLabel("Password").fill("password123"); await page.getByRole("button", { name: "Sign in" }).click(); await page.waitForURL("/dashboard"); await use(page); }, });
export { expect } from "@playwright/test";
Test Isolation
Each test should be independent. Use API calls for setup instead of UI flows:
// BAD: test depends on previous test creating data test("edit user", async ({ page }) => { // assumes "create user" test ran first await page.goto("/users"); // ... });
// GOOD: each test sets up its own data test("edit user", async ({ page, request }) => { // Create user via API const response = await request.post("/api/users", { data: { name: "Test User", email: "test@example.com" }, }); const user = await response.json();
await page.goto(/users/${user.id}/edit);
await page.getByLabel("Name").fill("Updated Name");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Updated Name")).toBeVisible();
});
Authentication via Setup Projects
Use setup projects with dependencies instead of globalSetup — they integrate with traces, HTML reports, and the test runner:
// tests/e2e/auth.setup.ts import { test as setup, expect } from "@playwright/test";
const authFile = "tests/.auth/admin.json";
setup("authenticate", async ({ page }) => { await page.goto("/login"); await page.getByLabel("Email").fill("admin@example.com"); await page.getByLabel("Password").fill("admin123"); await page.getByRole("button", { name: "Sign in" }).click(); await page.waitForURL("/dashboard"); await page.context().storageState({ path: authFile }); });
// In playwright.config.ts — projects reference this (see Configuration): // { name: 'setup', testMatch: /.*.setup.ts/ } // { name: 'chromium', dependencies: ['setup'], // use: { storageState: 'tests/.auth/admin.json' } }
For API-based auth (faster when a login endpoint exists):
setup("authenticate via API", async ({ request }) => { await request.post("/api/auth/login", { data: { email: "admin@example.com", password: "admin123" }, }); await request.storageState({ path: authFile }); });
Tag-Based Filtering
// Tag individual tests (v1.42+) test("checkout flow", { tag: ["@smoke", "@checkout"] }, async ({ page }) => { // ... });
// Tag groups test.describe("payment flow", { tag: "@payment" }, () => { test("credit card", async ({ page }) => { /* ... / }); test("PayPal", async ({ page }) => { / ... */ }); });
Run from CLI
npx playwright test --grep @smoke npx playwright test --grep-invert @slow
Soft Assertions
// Collect multiple failures instead of stopping at the first test("form validation shows all errors", async ({ page }) => { await page.goto("/register"); await page.getByRole("button", { name: "Submit" }).click();
await expect.soft(page.getByText("Name is required")).toBeVisible(); await expect.soft(page.getByText("Email is required")).toBeVisible(); await expect.soft(page.getByText("Password is required")).toBeVisible(); // Test reports all failures, not just the first });
Accessibility Testing
ARIA Snapshots (v1.49+)
Compare the accessibility tree structure rather than pixels — more resilient to styling changes:
test("navigation has correct structure", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("navigation")).toMatchAriaSnapshot( - navigation: - link "Home" - link "Products" - link "About" - link "Contact" );
});
// Partial matching with regex for dynamic content
test("user menu shows name", async ({ page }) => {
await expect(page.getByRole("menu")).toMatchAriaSnapshot( - menu: - menuitem /Hello, .+/ - menuitem "Settings" - menuitem "Sign out" );
});
Update snapshots: npx playwright test --update-snapshots
Accessibility Assertions (v1.44+)
await expect(page.getByTestId("submit")).toHaveRole("button"); await expect(page.getByRole("textbox")).toHaveAccessibleName("Email address"); await expect(page.getByRole("textbox")).toHaveAccessibleDescription( "We will never share your email", ); // v1.50+ await expect(page.getByRole("textbox")).toHaveAccessibleErrorMessage( "Email is required", );
Time Manipulation (Clock API)
Control time in tests without depending on real timers (v1.45+):
// Fix time to a specific moment test("shows greeting based on time of day", async ({ page }) => { await page.clock.setFixedTime(new Date("2025-12-25T08:00:00")); await page.goto("/"); await expect(page.getByText("Good morning")).toBeVisible(); });
// Install fake timers and advance test("session timeout warning", async ({ page }) => { await page.clock.install({ time: new Date("2025-01-01T00:00:00") }); await page.goto("/dashboard");
await page.clock.fastForward("29:00"); // 29 minutes await expect(page.getByText("Session expiring")).not.toBeVisible();
await page.clock.fastForward("01:30"); // 30:30 total await expect(page.getByText("Session expiring")).toBeVisible(); });
Overrides: Date , setTimeout , setInterval , requestAnimationFrame , performance .
Waiting Strategies
Auto-Wait (Default)
Playwright auto-waits for elements to be actionable before interacting:
// Playwright automatically waits for: // - element to be visible // - element to be stable (no animations) // - element to be enabled // - element to receive events await page.getByRole("button", { name: "Submit" }).click();
Assertions with Auto-Retry
expect with web-first assertions auto-retries until timeout:
// These auto-retry until condition is met or timeout await expect(page.getByText("Success")).toBeVisible(); await expect(page.getByTestId("count")).toHaveText("5"); await expect(page).toHaveURL("/dashboard"); await expect(page).toHaveTitle("Dashboard");
// Negate assertions also auto-retry await expect(page.getByTestId("spinner")).not.toBeVisible();
Explicit Waits (When Needed)
// Wait for a specific network response const responsePromise = page.waitForResponse("**/api/users"); await page.getByRole("button", { name: "Load" }).click(); const response = await responsePromise;
// Wait for navigation (sequential — Playwright handles auto-waiting) await page.getByRole("button", { name: "Submit" }).click(); await page.waitForURL("/dashboard");
// Wait for network idle (use sparingly) await page.waitForLoadState("networkidle");
Overlay / Popup Handling (v1.42+)
Auto-dismiss overlays that appear unpredictably during tests:
// Register once — handler runs whenever overlay appears await page.addLocatorHandler( page.getByRole("dialog", { name: "Cookie consent" }), async (dialog) => { await dialog.getByRole("button", { name: "Accept" }).click(); }, ); // All subsequent actions auto-dismiss the cookie dialog if it appears
Waiting Anti-Patterns
// BAD: arbitrary sleep await page.waitForTimeout(3000);
// BAD: polling for element while (!(await page.getByText("Ready").isVisible())) { await page.waitForTimeout(100); }
// GOOD: auto-retrying assertion await expect(page.getByText("Ready")).toBeVisible({ timeout: 10000 });
// GOOD: wait for specific condition await page.waitForResponse( (resp) => resp.url().includes("/api/data") && resp.status() === 200, );
Visual Regression
Screenshot Comparison
test("homepage matches snapshot", async ({ page }) => { await page.goto("/"); await expect(page).toHaveScreenshot("homepage.png"); });
// Element screenshot test("sidebar matches snapshot", async ({ page }) => { await page.goto("/dashboard"); const sidebar = page.getByTestId("sidebar"); await expect(sidebar).toHaveScreenshot("sidebar.png"); });
Configuration
// playwright.config.ts export default defineConfig({ expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.01, // Allow 1% pixel difference threshold: 0.2, // Per-pixel color threshold animations: "disabled", // Disable CSS animations for consistency }, }, // v1.50+: only regenerate changed snapshots updateSnapshots: "changed", });
Best Practices for Visual Tests
// Hide dynamic content before screenshot test("dashboard layout", async ({ page }) => { await page.goto("/dashboard");
// Mask dynamic elements await expect(page).toHaveScreenshot("dashboard.png", { mask: [page.getByTestId("timestamp"), page.getByTestId("random-avatar")], }); });
// Use consistent viewport test.use({ viewport: { width: 1280, height: 720 } });
Network Interception
Route-Based Mocking
// Mock API with json shorthand test("shows error on API failure", async ({ page }) => { await page.route("**/api/users", (route) => route.fulfill({ status: 500, json: { error: "Internal Server Error" } }), );
await page.goto("/users"); await expect(page.getByText("Failed to load users")).toBeVisible(); });
// Mock empty state test("shows empty state", async ({ page }) => { await page.route("**/api/users", (route) => route.fulfill({ status: 200, json: [] }), );
await page.goto("/users"); await expect(page.getByText("No users found")).toBeVisible(); });
// Intercept and modify real responses test("injects extra item", async ({ page }) => { await page.route("**/api/products", async (route) => { const response = await route.fetch(); const json = await response.json(); json.push({ id: 999, name: "Injected Product" }); await route.fulfill({ json }); });
await page.goto("/products"); await expect(page.getByText("Injected Product")).toBeVisible(); });
Register routes before page.goto() to intercept early requests.
WebSocket Interception (v1.48+)
// Full WebSocket mock (no server connection) test("receives live updates", async ({ page }) => { await page.routeWebSocket("wss://example.com/ws", (ws) => { ws.onMessage((message) => { if (message === "ping") ws.send("pong"); }); });
await page.goto("/live-dashboard"); await expect(page.getByTestId("status")).toHaveText("Connected"); });
// Intercept and modify messages between page and server await page.routeWebSocket("wss://example.com/ws", (ws) => { const server = ws.connectToServer(); ws.onMessage((message) => server.send(message)); // forward to server server.onMessage((message) => ws.send(message)); // forward to page });
CI Integration
GitHub Actions
.github/workflows/e2e.yml
name: E2E Tests on: [push, pull_request] jobs: e2e: runs-on: ubuntu-latest strategy: fail-fast: false matrix: shard: [1/4, 2/4, 3/4, 4/4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci # Install only the browsers you need - run: npx playwright install --with-deps chromium - run: npx playwright test --shard=${{ matrix.shard }} - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: name: playwright-report-${{ matrix.shard }} path: playwright-report/ retention-days: 7
Configuration for CI
// Key CI settings in playwright.config.ts export default defineConfig({ retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, forbidOnly: !!process.env.CI, failOnFlakyTests: !!process.env.CI, use: { trace: "on-first-retry", screenshot: "only-on-failure", video: "on-first-retry", }, });
Useful CLI Options
Re-run only tests that failed in the last run (v1.44+)
npx playwright test --last-failed
Run only tests in files changed since last commit (v1.46+)
npx playwright test --only-changed
Update only changed snapshots (v1.50+)
npx playwright test --update-snapshots
Trace Viewer
When a test fails in CI, download the trace and inspect:
npx playwright show-trace trace.zip
Traces include:
-
Step-by-step screenshots
-
DOM snapshots at each action
-
Network requests
-
Console logs
Common Pitfalls
Flaky Test Prevention
// 1. Don't rely on timing // BAD await page.getByTestId("button").click(); await page.waitForTimeout(1000); expect(await page.textContent("#result")).toBe("Done");
// GOOD await page.getByTestId("button").click(); await expect(page.getByTestId("result")).toHaveText("Done");
// 2. Don't depend on test order // BAD: shared state between tests let userId: string; test("create user", async ({ page }) => { // ... creates user userId = "123"; }); test("delete user", async ({ page }) => { // uses userId from previous test });
// GOOD: each test is self-contained test("delete user", async ({ page, request }) => { const user = await request.post("/api/users", { data: { name: "temp" } }); // ... delete the user });
// 3. Don't use exact text matching for dynamic content // BAD await expect(page.getByText("Created 2 seconds ago")).toBeVisible();
// GOOD await expect(page.getByText(/Created \d+ \w+ ago/)).toBeVisible();
Test Data Management
// Use factories for consistent test data
function createTestUser(overrides = {}) {
return {
name: Test User ${Date.now()},
email: test-${Date.now()}@example.com,
...overrides,
};
}
// Clean up after tests test.afterEach(async ({ request }) => { await request.delete("/api/test/cleanup"); });
Deprecated APIs
// BAD: page.type() is deprecated await page.type("#email", "user@example.com");
// GOOD: use fill() or pressSequentially() await page.getByLabel("Email").fill("user@example.com"); await page.getByLabel("Search").pressSequentially("query", { delay: 50 });
// REMOVED in v1.57: page.accessibility — use ARIA snapshots instead // REMOVED in v1.58: _react and _vue selectors — use getByRole/getByTestId
Anti-Patterns
- Testing Implementation Details
// BAD: tests CSS class, not behavior expect(await button.getAttribute("class")).toContain("btn-primary");
// GOOD: tests what user sees await expect(button).toBeVisible(); await expect(button).toBeEnabled();
// If you must check a class (rare), use the dedicated assertion (v1.52+): await expect(button).toContainClass("btn-primary");
- Over-Specifying Assertions
// BAD: brittle, breaks on any text change await expect(page.getByTestId("message")).toHaveText( "Successfully created user John Doe with ID 12345 at 2024-01-15T10:30:00Z", );
// GOOD: assert on the meaningful part await expect(page.getByTestId("message")).toContainText("Successfully created");
- Not Using Test Hooks for Setup
// BAD: repeating setup in every test test("test 1", async ({ page }) => { await page.goto("/login"); await page.getByLabel("Email").fill("admin@test.com"); await page.getByLabel("Password").fill("password"); await page.getByRole("button", { name: "Sign in" }).click(); // ... actual test });
// GOOD: use beforeEach or fixtures test.beforeEach(async ({ page }) => { await page.goto("/dashboard"); });
- Not Waiting for Page State
// BAD: navigates and immediately asserts await page.goto("/dashboard"); const count = await page.textContent("#count");
// GOOD: wait for the page to be ready await page.goto("/dashboard"); await expect(page.getByTestId("count")).toBeVisible(); const count = await page.getByTestId("count").textContent();
- Using Promise.all for Navigation
// BAD: unnecessary — Playwright auto-waits for navigation await Promise.all([ page.waitForURL("/dashboard"), page.getByRole("button", { name: "Submit" }).click(), ]);
// GOOD: sequential is fine — Playwright handles the race await page.getByRole("button", { name: "Submit" }).click(); await page.waitForURL("/dashboard");
- Not Handling Random Overlays
// BAD: test fails when cookie banner appears unpredictably await page.getByRole("button", { name: "Checkout" }).click();
// GOOD: register a handler once, auto-dismiss overlays (v1.42+) await page.addLocatorHandler( page.getByRole("dialog", { name: "Cookie consent" }), async (d) => d.getByRole("button", { name: "Accept" }).click(), );