Skip to main content
Technology & EngineeringTesting Services318 lines

Playwright

"Playwright: end-to-end testing, browser automation, selectors, assertions, fixtures, page objects, visual regression, CI, codegen"

Quick Summary18 lines
Playwright is a cross-browser end-to-end testing framework built by Microsoft. It treats the browser as a first-class automation target, providing reliable, fast, and capable testing across Chromium, Firefox, and WebKit with a single API. Tests run in isolated browser contexts, ensuring zero interference between tests. Playwright auto-waits for elements to be actionable before performing operations, eliminating the most common source of flaky tests. Every test gets a fresh browser context, equivalent to a brand-new browser profile, making isolation the default rather than an afterthought.

## Key Points

- Use role-based and label-based locators over CSS/XPath selectors for resilience and accessibility alignment.
- Leverage auto-waiting rather than adding manual `waitForTimeout` calls.
- Keep browser state isolated with per-test contexts; share authentication state via `storageState` files.
- Use `test.describe.configure({ mode: "serial" })` sparingly and only when tests truly depend on order.
- Run `npx playwright codegen` to bootstrap selectors and interactions, then refine them in code.
- Capture traces on first retry (`trace: "on-first-retry"`) to debug failures without re-running.
- Pin visual regression baselines per platform and update them intentionally.
- Avoid `page.waitForTimeout(ms)` as a synchronization mechanism; rely on locator assertions and auto-wait instead.
- Do not use fragile CSS selectors like `.btn-primary > span:nth-child(2)`; they break on minor markup changes.
- Do not share mutable state between tests. Each test must be independently runnable.
- Avoid testing third-party services (payment gateways, OAuth providers) end-to-end; mock them at the network layer.
- Do not skip cross-browser testing in CI; bugs that only appear in WebKit or Firefox will reach production.
skilldb get testing-services-skills/PlaywrightFull skill: 318 lines
Paste into your CLAUDE.md or agent config

Playwright

Core Philosophy

Playwright is a cross-browser end-to-end testing framework built by Microsoft. It treats the browser as a first-class automation target, providing reliable, fast, and capable testing across Chromium, Firefox, and WebKit with a single API. Tests run in isolated browser contexts, ensuring zero interference between tests. Playwright auto-waits for elements to be actionable before performing operations, eliminating the most common source of flaky tests. Every test gets a fresh browser context, equivalent to a brand-new browser profile, making isolation the default rather than an afterthought.

Setup

Installation and Configuration

// Install Playwright and browsers
// npm init playwright@latest

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ["html", { open: "never" }],
    ["junit", { outputFile: "results.xml" }],
  ],
  use: {
    baseURL: "http://localhost:3000",
    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"] } },
    { name: "mobile-chrome", use: { ...devices["Pixel 5"] } },
  ],
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Project Structure

e2e/
  fixtures/
    auth.fixture.ts
    db.fixture.ts
  pages/
    login.page.ts
    dashboard.page.ts
  specs/
    auth.spec.ts
    dashboard.spec.ts
  utils/
    test-data.ts

Key Techniques

Selectors and Locators

import { test, expect } from "@playwright/test";

test("use recommended locator strategies", async ({ page }) => {
  await page.goto("/signup");

  // Preferred: role-based selectors (accessibility-first)
  await page.getByRole("heading", { name: "Create Account" }).isVisible();
  await page.getByRole("textbox", { name: "Email" }).fill("user@test.com");
  await page.getByRole("button", { name: "Sign Up" }).click();

  // Labels and placeholders
  await page.getByLabel("Password").fill("secure-pass-123");
  await page.getByPlaceholder("Enter your name").fill("Jane");

  // Test IDs for elements without semantic roles
  await page.getByTestId("avatar-upload").setInputFiles("avatar.png");

  // Text content
  await page.getByText("Terms of Service").click();

  // Filtering and chaining locators
  const row = page.getByRole("row").filter({ hasText: "admin@test.com" });
  await row.getByRole("button", { name: "Edit" }).click();
});

Assertions

test("web-first assertions with auto-wait", async ({ page }) => {
  await page.goto("/dashboard");

  // These auto-retry until the condition is met or timeout
  await expect(page.getByRole("heading")).toHaveText("Dashboard");
  await expect(page.getByTestId("user-count")).toHaveText(/\d+ users/);
  await expect(page.getByRole("alert")).not.toBeVisible();
  await expect(page).toHaveURL(/.*dashboard/);
  await expect(page).toHaveTitle("My App - Dashboard");

  // Soft assertions (don't stop the test on failure)
  await expect.soft(page.getByTestId("metric-a")).toHaveText("42");
  await expect.soft(page.getByTestId("metric-b")).toHaveText("99");
});

Custom Fixtures

// e2e/fixtures/auth.fixture.ts
import { test as base, expect } from "@playwright/test";

type AuthFixtures = {
  authenticatedPage: ReturnType<typeof base["extend"]> extends infer T ? T : never;
  adminPage: ReturnType<typeof base["extend"]> extends infer T ? T : never;
};

export const test = base.extend<{
  userPage: Awaited<ReturnType<typeof createAuthPage>>;
  adminPage: Awaited<ReturnType<typeof createAuthPage>>;
}>({
  userPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: "e2e/.auth/user.json",
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: "e2e/.auth/admin.json",
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});

async function createAuthPage(browser: any, stateFile: string) {
  const ctx = await browser.newContext({ storageState: stateFile });
  return ctx.newPage();
}

export { expect };

Page Object Model

// e2e/pages/login.page.ts
import { type Page, type Locator, expect } from "@playwright/test";

export class LoginPage {
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitButton: Locator;
  private readonly errorAlert: Locator;

  constructor(private readonly page: Page) {
    this.emailInput = page.getByLabel("Email");
    this.passwordInput = page.getByLabel("Password");
    this.submitButton = page.getByRole("button", { name: "Sign In" });
    this.errorAlert = page.getByRole("alert");
  }

  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();
  }

  async expectError(message: string) {
    await expect(this.errorAlert).toHaveText(message);
  }

  async expectRedirectToDashboard() {
    await expect(this.page).toHaveURL(/.*dashboard/);
  }
}

// e2e/specs/auth.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/login.page";

test.describe("Authentication", () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test("successful login redirects to dashboard", async () => {
    await loginPage.login("user@test.com", "password123");
    await loginPage.expectRedirectToDashboard();
  });

  test("invalid credentials show error", async () => {
    await loginPage.login("user@test.com", "wrong-password");
    await loginPage.expectError("Invalid email or password");
  });
});

API Testing and Network Interception

test("mock API responses", async ({ page }) => {
  // Intercept and mock API calls
  await page.route("**/api/users", (route) =>
    route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify([
        { id: 1, name: "Alice" },
        { id: 2, name: "Bob" },
      ]),
    })
  );

  await page.goto("/users");
  await expect(page.getByRole("listitem")).toHaveCount(2);
});

test("standalone API testing", async ({ request }) => {
  const response = await request.post("/api/users", {
    data: { name: "Charlie", email: "charlie@test.com" },
  });
  expect(response.ok()).toBeTruthy();

  const user = await response.json();
  expect(user.name).toBe("Charlie");
});

Visual Regression Testing

test("visual regression for dashboard", async ({ page }) => {
  await page.goto("/dashboard");
  await page.waitForLoadState("networkidle");

  // Full-page screenshot comparison
  await expect(page).toHaveScreenshot("dashboard-full.png", {
    maxDiffPixelRatio: 0.01,
  });

  // Component-level screenshot
  const chart = page.getByTestId("revenue-chart");
  await expect(chart).toHaveScreenshot("revenue-chart.png", {
    animations: "disabled",
    mask: [page.getByTestId("dynamic-timestamp")],
  });
});

CI Configuration

# .github/workflows/e2e.yml
name: E2E Tests
on: [push]
jobs:
  test:
    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
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

Best Practices

  • Use role-based and label-based locators over CSS/XPath selectors for resilience and accessibility alignment.
  • Leverage auto-waiting rather than adding manual waitForTimeout calls.
  • Keep browser state isolated with per-test contexts; share authentication state via storageState files.
  • Use test.describe.configure({ mode: "serial" }) sparingly and only when tests truly depend on order.
  • Run npx playwright codegen to bootstrap selectors and interactions, then refine them in code.
  • Capture traces on first retry (trace: "on-first-retry") to debug failures without re-running.
  • Pin visual regression baselines per platform and update them intentionally.

Anti-Patterns

  • Avoid page.waitForTimeout(ms) as a synchronization mechanism; rely on locator assertions and auto-wait instead.
  • Do not use fragile CSS selectors like .btn-primary > span:nth-child(2); they break on minor markup changes.
  • Do not share mutable state between tests. Each test must be independently runnable.
  • Avoid testing third-party services (payment gateways, OAuth providers) end-to-end; mock them at the network layer.
  • Do not skip cross-browser testing in CI; bugs that only appear in WebKit or Firefox will reach production.
  • Avoid storing authentication credentials in test files; use environment variables or secret management.

Install this skill directly: skilldb add testing-services-skills

Get CLI access →