Skip to content

Playwright — Testing Patterns

OWNER: marije, judith ALSO_USED_BY: ashley (adversarial), antje LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: @playwright/test ^1.58.2


Overview

Standard E2E testing patterns used across GE projects with Playwright. Covers page objects, fixtures, locators, network mocking, visual regression, auth reuse, and parallel execution. Agents MUST follow these patterns for maintainable, non-flaky E2E tests.


Page Object Model (POM)

Every page under test gets a dedicated page object class. Page objects encapsulate locators and actions — tests read like user stories.

// e2e/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.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.errorMessage).toContainText(message)
  }
}
// e2e/auth.spec.ts
import { test, expect } from "@playwright/test"
import { LoginPage } from "./pages/login-page"

test.describe("authentication", () => {
  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("wrong@example.com", "wrong")

    await loginPage.expectError("Invalid email or password")
  })
})

CHECK: An E2E test interacts with page elements. THEN: Create a page object — never use raw locators in test files. THEN: Page objects define locators in the constructor. THEN: Action methods (login, search, submit) live on the page object. THEN: Assertions can live in the test file OR as expect* methods on the page object.

ANTI_PATTERN: Mixing actions and assertions in page object methods. FIX: Keep action methods pure (perform action, return nothing). Add separate expect* methods for assertions.

ANTI_PATTERN: Bloated page objects with 50+ methods. FIX: Split into component-level objects: NavBar, Sidebar, DataTable.


Custom Fixtures

Fixtures replace beforeEach boilerplate and integrate with Playwright's test runner.

// e2e/fixtures/index.ts
import { test as base } from "@playwright/test"
import { LoginPage } from "../pages/login-page"
import { DashboardPage } from "../pages/dashboard-page"

type GEFixtures = {
  loginPage: LoginPage
  dashboardPage: DashboardPage
}

export const test = base.extend<GEFixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page)
    await use(loginPage)
  },
  dashboardPage: async ({ page }, use) => {
    const dashboardPage = new DashboardPage(page)
    await use(dashboardPage)
  },
})

export { expect } from "@playwright/test"
// e2e/dashboard.spec.ts
import { test, expect } from "./fixtures"

test("dashboard shows welcome message", async ({ dashboardPage }) => {
  await dashboardPage.goto()
  await expect(dashboardPage.welcomeMessage).toBeVisible()
})

CHECK: Multiple test files need the same setup. THEN: Create a fixture — not beforeEach duplication.


Locator Strategy (Priority Order)

GE locator priority — most resilient first:

  1. getByRole — targets accessibility roles (button, heading, link)
  2. getByLabel — targets form labels
  3. getByPlaceholder — targets placeholder text
  4. getByText — targets visible text content
  5. getByTestId — targets data-testid attributes (last resort)
// BEST — role-based, resilient to styling changes
page.getByRole("button", { name: "Save changes" })
page.getByRole("heading", { level: 1 })
page.getByRole("link", { name: "Settings" })

// GOOD — label-based for form fields
page.getByLabel("Email address")

// ACCEPTABLE — when no semantic alternative exists
page.getByTestId("user-avatar")

// AVOID — fragile, breaks on any DOM change
page.locator(".btn-primary")
page.locator("#submit-form")
page.locator("div > span:nth-child(2)")

ANTI_PATTERN: Using CSS selectors or XPath for locators. FIX: Use role/label/text locators. Add data-testid only when no accessible selector exists.

ANTI_PATTERN: Using page.locator("text=...") instead of page.getByText(...). FIX: getByText is the modern API — use it.


Network Mocking (Route Interception)

Mock API responses to test UI behavior independently of backend state.

test("shows empty state when no items", async ({ page }) => {
  // Intercept API call and return empty array
  await page.route("**/api/items", (route) => {
    route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({ items: [], total: 0 }),
    })
  })

  await page.goto("/items")
  await expect(page.getByText("No items found")).toBeVisible()
})

test("handles API error gracefully", async ({ page }) => {
  await page.route("**/api/items", (route) => {
    route.fulfill({ status: 500 })
  })

  await page.goto("/items")
  await expect(page.getByText("Something went wrong")).toBeVisible()
})

test("shows loading state while fetching", async ({ page }) => {
  // Delay response to observe loading state
  await page.route("**/api/items", async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 2000))
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({ items: [{ id: 1, name: "Test" }] }),
    })
  })

  await page.goto("/items")
  await expect(page.getByRole("progressbar")).toBeVisible()
})

CHECK: Testing UI behavior for specific API states (empty, error, loading). THEN: Use page.route to mock the API response. THEN: Never depend on real backend data for E2E edge cases.


Visual Regression Testing

Playwright supports screenshot comparison for detecting unintended visual changes.

test("dashboard layout matches baseline", async ({ page }) => {
  await page.goto("/dashboard")
  await expect(page).toHaveScreenshot("dashboard.png", {
    maxDiffPixelRatio: 0.01,
  })
})

test("button variants render correctly", async ({ page }) => {
  await page.goto("/storybook/button")
  await expect(page.getByTestId("button-group")).toHaveScreenshot("buttons.png")
})

CHECK: Visual regression tests are being added. THEN: Use maxDiffPixelRatio: 0.01 — allows minor anti-aliasing differences. THEN: Run npx playwright test --update-snapshots to update baselines after intentional changes. THEN: Store baseline screenshots in git — they are the source of truth.

ANTI_PATTERN: Visual regression tests for dynamic content (timestamps, user data). FIX: Mock dynamic data before taking screenshots, or mask dynamic regions.


Authentication State Reuse

Avoid logging in before every test. Save and reuse auth state.

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

setup("authenticate", async ({ page }) => {
  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 expect(page).toHaveURL("/dashboard")

  // Save authentication state
  await page.context().storageState({ path: ".auth/user.json" })
})
// playwright.config.ts
projects: [
  { name: "setup", testMatch: /.*\.setup\.ts/ },
  {
    name: "chromium",
    use: {
      ...devices["Desktop Chrome"],
      storageState: ".auth/user.json",
    },
    dependencies: ["setup"],
  },
]

CHECK: Tests require an authenticated user. THEN: Create an auth setup project that saves storageState. THEN: Other projects depend on setup and reuse the saved state. THEN: Auth runs once, all tests reuse the session.

ANTI_PATTERN: Logging in via UI in every test. FIX: Use storageState reuse. Login once per test run, not per test.


Parallel Execution

Playwright runs tests in parallel by default (fullyParallel: true).

CHECK: Tests are being written. THEN: Every test MUST be independent — no shared state, no ordering assumptions. THEN: Use unique test data per test (include test name in entity names). THEN: Clean up created data in afterEach or use API-based setup/teardown.

test("creates a new project", async ({ page }) => {
  const projectName = `Test Project ${Date.now()}`

  await page.goto("/projects/new")
  await page.getByLabel("Project name").fill(projectName)
  await page.getByRole("button", { name: "Create" }).click()

  await expect(page.getByRole("heading", { name: projectName })).toBeVisible()
})

CHECK: A test depends on another test's output. THEN: Refactor — extract shared setup to a fixture or helper. THEN: Never use test.describe.serial unless absolutely required (database migration tests).


Waiting Strategies

Playwright auto-waits for elements to be actionable. Explicit waits are rarely needed.

// CORRECT — Playwright auto-waits for button to be visible and enabled
await page.getByRole("button", { name: "Submit" }).click()

// CORRECT — expect auto-retries until assertion passes or timeout
await expect(page.getByText("Success")).toBeVisible()

// CORRECT — wait for navigation after action
await Promise.all([
  page.waitForURL("/success"),
  page.getByRole("button", { name: "Submit" }).click(),
])

// CORRECT — wait for API response
const responsePromise = page.waitForResponse("**/api/data")
await page.getByRole("button", { name: "Load" }).click()
const response = await responsePromise

ANTI_PATTERN: Using page.waitForTimeout(3000) — the number one cause of flaky tests. FIX: Wait for a specific condition: element visibility, URL change, network response.


Cross-References

READ_ALSO: wiki/docs/stack/playwright/index.md READ_ALSO: wiki/docs/stack/playwright/pitfalls.md READ_ALSO: wiki/docs/stack/playwright/checklist.md READ_ALSO: wiki/docs/stack/vitest/patterns.md READ_ALSO: wiki/docs/stack/tailwind-shadcn/component-patterns.md