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 waitsfill()clears the input first then types the value -- unliketype()which appends character by character- Navigation waits are automatic --
click()on a link waits for the navigation to complete
Variations
Locator strategies comparison:
| Strategy | Example | Use When |
|---|---|---|
getByRole | page.getByRole("button", { name: /submit/i }) | Element has an ARIA role (preferred) |
getByLabel | page.getByLabel("Email") | Form inputs with labels |
getByText | page.getByText("Welcome") | Static text content |
getByPlaceholder | page.getByPlaceholder("Search...") | Input placeholders |
getByTestId | page.getByTestId("sidebar") | No semantic query works |
locator | page.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-snapshotsWaiting 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: UsegetByRole,getByLabel, orgetByTestIdinstead. -
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 addawait 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()) orwaitForResponseinstead.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Inline locators (no POM) | Small test suites with few pages | Tests grow beyond a dozen files |
| Fixture-based page objects | You want automatic page object setup via test fixtures | Simple POM construction in test body is sufficient |
| Cypress | You prefer Cypress's time-travel debugging or component testing | You need multi-browser support |
| Percy / Chromatic | You need enterprise visual regression with review workflow | Built-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?
getByRole-- accessible ARIA role (preferred)getByLabel-- form input labelsgetByText-- static text contentgetByPlaceholder-- input placeholdersgetByTestId-- last resortlocator()-- 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");Related
- Playwright E2E Setup -- installation and configuration
- Advanced Playwright -- authentication and API testing
- Testing Strategy & Best Practices -- choosing test types
- React Testing Library Fundamentals -- similar query patterns for unit tests