React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

playwrightpage-object-modellocatorsassertionsscreenshotsvisual-regression

Playwright E2E Patterns

Apply Page Object Model, robust locator strategies, and assertion patterns for maintainable E2E tests.

Recipe

Quick-reference recipe card -- copy-paste ready.

import { test, expect, Page } from "@playwright/test";
 
// Locator strategies (prefer accessible queries)
page.getByRole("button", { name: /submit/i });
page.getByLabel("Email");
page.getByText("Welcome back");
page.getByPlaceholder("Search...");
page.getByTestId("checkout-form");
 
// Navigation and waiting
await page.goto("/products");
await page.waitForURL("/products/**");
await page.getByRole("link", { name: /details/i }).click();
 
// Form interaction
await page.getByLabel("Email").fill("alice@example.com");
await page.getByLabel("Password").fill("password123");
await page.getByRole("button", { name: /sign in/i }).click();
 
// Assertions (auto-retry)
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading")).toHaveText("Dashboard");
await expect(page.getByRole("alert")).toBeVisible();
await expect(page.getByRole("button")).toBeEnabled();
 
// Screenshots
await page.screenshot({ path: "screenshots/dashboard.png" });
await expect(page).toHaveScreenshot("dashboard.png");

When to reach for this: When writing E2E tests that need to be maintainable, readable, and resilient to minor UI changes.

Working Example

// e2e/pages/checkout-page.ts
import { Page, Locator, expect } from "@playwright/test";
 
export class CheckoutPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly nameInput: Locator;
  readonly addressInput: Locator;
  readonly cityInput: Locator;
  readonly zipInput: Locator;
  readonly cardNumberInput: Locator;
  readonly submitButton: Locator;
  readonly orderConfirmation: Locator;
  readonly errorMessage: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel("Email");
    this.nameInput = page.getByLabel("Full name");
    this.addressInput = page.getByLabel("Address");
    this.cityInput = page.getByLabel("City");
    this.zipInput = page.getByLabel("ZIP code");
    this.cardNumberInput = page.getByLabel("Card number");
    this.submitButton = page.getByRole("button", { name: /place order/i });
    this.orderConfirmation = page.getByRole("heading", { name: /order confirmed/i });
    this.errorMessage = page.getByRole("alert");
  }
 
  async goto() {
    await this.page.goto("/checkout");
  }
 
  async fillShippingInfo(info: {
    email: string;
    name: string;
    address: string;
    city: string;
    zip: string;
  }) {
    await this.emailInput.fill(info.email);
    await this.nameInput.fill(info.name);
    await this.addressInput.fill(info.address);
    await this.cityInput.fill(info.city);
    await this.zipInput.fill(info.zip);
  }
 
  async fillPayment(cardNumber: string) {
    await this.cardNumberInput.fill(cardNumber);
  }
 
  async submitOrder() {
    await this.submitButton.click();
  }
 
  async expectOrderConfirmed() {
    await expect(this.orderConfirmation).toBeVisible();
  }
 
  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}
// e2e/pages/product-page.ts
import { Page, Locator, expect } from "@playwright/test";
 
export class ProductPage {
  readonly page: Page;
  readonly addToCartButton: Locator;
  readonly cartCount: Locator;
  readonly quantityInput: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.addToCartButton = page.getByRole("button", { name: /add to cart/i });
    this.cartCount = page.getByTestId("cart-count");
    this.quantityInput = page.getByLabel("Quantity");
  }
 
  async goto(productSlug: string) {
    await this.page.goto(`/products/${productSlug}`);
  }
 
  async setQuantity(quantity: number) {
    await this.quantityInput.fill(String(quantity));
  }
 
  async addToCart() {
    await this.addToCartButton.click();
  }
 
  async expectCartCount(count: number) {
    await expect(this.cartCount).toHaveText(String(count));
  }
}
// e2e/checkout.spec.ts
import { test, expect } from "@playwright/test";
import { ProductPage } from "./pages/product-page";
import { CheckoutPage } from "./pages/checkout-page";
 
test.describe("Checkout Flow", () => {
  test("complete purchase from product page to confirmation", async ({ page }) => {
    // Add product to cart
    const productPage = new ProductPage(page);
    await productPage.goto("premium-widget");
    await productPage.setQuantity(2);
    await productPage.addToCart();
    await productPage.expectCartCount(2);
 
    // Navigate to checkout
    await page.getByRole("link", { name: /checkout/i }).click();
    await expect(page).toHaveURL("/checkout");
 
    // Fill checkout form
    const checkoutPage = new CheckoutPage(page);
    await checkoutPage.fillShippingInfo({
      email: "alice@example.com",
      name: "Alice Johnson",
      address: "123 Main St",
      city: "Springfield",
      zip: "62701",
    });
    await checkoutPage.fillPayment("4242424242424242");
    await checkoutPage.submitOrder();
 
    // Verify confirmation
    await checkoutPage.expectOrderConfirmed();
    await expect(page).toHaveURL(/\/orders\/[a-z0-9]+/);
  });
 
  test("shows validation errors for empty form", async ({ page }) => {
    const checkoutPage = new CheckoutPage(page);
    await checkoutPage.goto();
    await checkoutPage.submitOrder();
 
    await checkoutPage.expectError("Email is required");
  });
 
  test("shows error for invalid card", async ({ page }) => {
    const checkoutPage = new CheckoutPage(page);
    await checkoutPage.goto();
 
    await checkoutPage.fillShippingInfo({
      email: "alice@example.com",
      name: "Alice Johnson",
      address: "123 Main St",
      city: "Springfield",
      zip: "62701",
    });
    await checkoutPage.fillPayment("0000000000000000");
    await checkoutPage.submitOrder();
 
    await checkoutPage.expectError("Invalid card number");
  });
});

What this demonstrates:

  • Page Object Model (POM) pattern for encapsulating page interactions
  • Locators defined once in the page object, reused across tests
  • Helper methods for common multi-step operations
  • Assertion methods on page objects for readable test code
  • Full user flow from product browsing to order confirmation

Deep Dive

How It Works

  • The Page Object Model encapsulates locators and interactions for a page -- when the UI changes, you update the page object, not every test
  • Playwright locators are lazy -- they are evaluated when an action or assertion is performed, not when created
  • expect() assertions auto-retry until they pass or timeout (default 5 seconds) -- no need for manual waits
  • fill() clears the input first then types the value -- unlike type() which appends character by character
  • Navigation waits are automatic -- click() on a link waits for the navigation to complete

Variations

Locator strategies comparison:

StrategyExampleUse When
getByRolepage.getByRole("button", { name: /submit/i })Element has an ARIA role (preferred)
getByLabelpage.getByLabel("Email")Form inputs with labels
getByTextpage.getByText("Welcome")Static text content
getByPlaceholderpage.getByPlaceholder("Search...")Input placeholders
getByTestIdpage.getByTestId("sidebar")No semantic query works
locatorpage.locator(".custom-dropdown")CSS selector fallback

Visual regression testing:

test("product page visual", async ({ page }) => {
  await page.goto("/products/widget");
 
  // Full page screenshot comparison
  await expect(page).toHaveScreenshot("product-page.png", {
    maxDiffPixelRatio: 0.01,
  });
 
  // Element-level screenshot
  const card = page.getByTestId("product-card");
  await expect(card).toHaveScreenshot("product-card.png");
});
 
// First run creates baseline screenshots in __screenshots__/
// Subsequent runs compare against baselines
// Update baselines: npx playwright test --update-snapshots

Waiting strategies:

// Wait for network idle (all requests finished)
await page.goto("/dashboard", { waitUntil: "networkidle" });
 
// Wait for a specific response
const response = await page.waitForResponse("/api/products");
expect(response.status()).toBe(200);
 
// Wait for element state
await page.getByRole("button").waitFor({ state: "visible" });
await page.getByText("Loading").waitFor({ state: "hidden" });

TypeScript Notes

// Page objects benefit from strict typing
interface ShippingInfo {
  email: string;
  name: string;
  address: string;
  city: string;
  zip: string;
}
 
// Extend test fixtures for page objects
import { test as base } from "@playwright/test";
 
type Fixtures = {
  checkoutPage: CheckoutPage;
  productPage: ProductPage;
};
 
export const test = base.extend<Fixtures>({
  checkoutPage: async ({ page }, use) => {
    await use(new CheckoutPage(page));
  },
  productPage: async ({ page }, use) => {
    await use(new ProductPage(page));
  },
});
 
// Use in tests
test("checkout", async ({ checkoutPage, productPage }) => {
  await productPage.goto("widget");
  // ...
});

Gotchas

  • Brittle CSS selectors -- page.locator(".btn-primary.mt-4") breaks when classes change. Fix: Use getByRole, getByLabel, or getByTestId instead.

  • Not waiting for navigation -- Clicking a link and immediately asserting may fail if the page has not loaded. Fix: Playwright auto-waits on click, but add await expect(page).toHaveURL(...) for explicit verification.

  • Screenshot baselines across OSes -- Fonts render differently on macOS, Linux, and Windows. Fix: Run screenshot tests in Docker or only on one OS in CI.

  • Overly granular page objects -- Creating a page object for every small section adds indirection. Fix: One page object per page or major section. Keep it practical.

  • Using page.waitForTimeout() -- Hard-coded waits make tests slow and flaky. Fix: Use auto-waiting assertions (expect(...).toBeVisible()) or waitForResponse instead.

Alternatives

AlternativeUse WhenDon't Use When
Inline locators (no POM)Small test suites with few pagesTests grow beyond a dozen files
Fixture-based page objectsYou want automatic page object setup via test fixturesSimple POM construction in test body is sufficient
CypressYou prefer Cypress's time-travel debugging or component testingYou need multi-browser support
Percy / ChromaticYou need enterprise visual regression with review workflowBuilt-in screenshot comparison is sufficient

FAQs

What is the Page Object Model (POM) pattern?

POM encapsulates locators and interactions for a page in a class. When the UI changes, you update the page object once instead of every test that references that page.

What is the locator priority order in Playwright?
  1. getByRole -- accessible ARIA role (preferred)
  2. getByLabel -- form input labels
  3. getByText -- static text content
  4. getByPlaceholder -- input placeholders
  5. getByTestId -- last resort
  6. locator() -- CSS selector fallback
What is the difference between fill() and type() in Playwright?
  • fill() clears the input first, then sets the value.
  • type() appends characters one by one.
  • Use fill() for most form testing.
How do I set up visual regression testing?
await expect(page).toHaveScreenshot("dashboard.png", {
  maxDiffPixelRatio: 0.01,
});

First run creates baseline screenshots. Subsequent runs compare against them. Update with --update-snapshots.

Gotcha: Why do screenshot tests fail across different operating systems?

Fonts render differently on macOS, Linux, and Windows. Run screenshot tests in Docker or restrict them to a single OS in CI.

How do I wait for a specific API response in a test?
const response = await page.waitForResponse("/api/products");
expect(response.status()).toBe(200);
How do I use Playwright test fixtures for page objects?
export const test = base.extend<Fixtures>({
  checkoutPage: async ({ page }, use) => {
    await use(new CheckoutPage(page));
  },
});
test("checkout", async ({ checkoutPage }) => { /* ... */ });
Gotcha: Why should I avoid page.waitForTimeout()?

Hard-coded waits make tests slow and flaky. Use auto-waiting assertions like expect(...).toBeVisible() or waitForResponse instead.

How do I type page objects in TypeScript?
interface ShippingInfo {
  email: string;
  name: string;
  address: string;
  city: string;
  zip: string;
}
async fillShippingInfo(info: ShippingInfo) { /* ... */ }
When should I avoid using the Page Object Model?

For small test suites with few pages, inline locators are simpler. POM adds indirection -- only adopt it when tests grow beyond a dozen files or pages are reused across many tests.

How do Playwright locators differ from Testing Library queries?

They use similar accessible query patterns (getByRole, getByLabel, getByText), but Playwright locators are lazy -- they are evaluated when an action or assertion is performed, not when created.

How do I take an element-level screenshot instead of a full page?
const card = page.getByTestId("product-card");
await expect(card).toHaveScreenshot("product-card.png");