Intermediate

This tutorial covers intermediate Playwright techniques including comprehensive form handling, advanced assertion patterns, API testing integration, and test organization best practices used in production test suites.

Form Handling (Examples 31-40)

Example 31: Multi-Field Form - Contact Form

Test forms with multiple input types working together. This pattern validates end-to-end form submission workflows.

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

test("submits contact form with multiple fields", async ({ page }) => {
  // => Test multi-field form submission
  await page.goto("https://example.com/contact");
  // => Navigates to contact page

  await page.getByLabel("Name").fill("Alice Smith");
  // => Fills text input with full name
  // => Uses accessible label selector

  await page.getByLabel("Email").fill("alice@example.com");
  // => Fills email input
  // => Validates with pattern: [text]@[domain].[tld]

  await page.getByLabel("Subject").selectOption("Support");
  // => Selects dropdown option
  // => Value: "Support" from available options

  await page.getByLabel("Message").fill("I need help with my account.");
  // => Fills textarea with message
  // => Multi-line text input

  await page.getByRole("button", { name: "Send Message" }).click();
  // => Submits form via button click
  // => Triggers form validation and submission

  await expect(page.getByText("Thank you for your message")).toBeVisible();
  // => Asserts success feedback
  // => Confirms form submitted successfully
});

Key Takeaway: Use getByLabel for accessible form field selection. Test complete submission workflows, not individual fields in isolation.

Why It Matters: Multi-field forms are the primary user interaction pattern in web applications. Label-based selectors reduce test brittleness compared to CSS selectors, as labels remain stable while implementation details change. Testing complete workflows catches integration bugs that field-level tests miss.

Example 32: Form Validation - Client-Side Errors

Test client-side validation feedback without server submission. This verifies user-facing error messages appear correctly.

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

test("displays validation error for invalid email", async ({ page }) => {
  // => Test client-side validation
  await page.goto("https://example.com/signup");
  // => Navigates to signup form

  await page.getByLabel("Email").fill("invalid-email");
  // => Fills email with invalid format
  // => Missing @ symbol and domain

  await page.getByLabel("Password").fill("short");
  // => Fills password with insufficient length
  // => Less than minimum requirement

  await page.getByRole("button", { name: "Sign Up" }).click();
  // => Attempts form submission
  // => Triggers client-side validation

  await expect(page.getByText("Please enter a valid email address")).toBeVisible();
  // => Asserts email validation error appears
  // => Client-side feedback without server round-trip

  await expect(page.getByText("Password must be at least 8 characters")).toBeVisible();
  // => Asserts password validation error
  // => Multiple validation messages shown simultaneously
});

Key Takeaway: Test validation errors appear before form submission reaches server. Verify specific error messages, not just presence of errors.

Why It Matters: Client-side validation provides immediate user feedback and reduces server load. Immediate validation feedback improves form completion rates. Testing validation messages ensures accessibility compliance—screen reader users depend on clear error text to fix input mistakes.

Example 33: Dynamic Forms - Conditional Fields

Test forms where fields appear/disappear based on user selections. This validates conditional logic in interactive forms.

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

test("shows additional field when 'Other' selected", async ({ page }) => {
  // => Test conditional field visibility
  await page.goto("https://example.com/survey");
  // => Navigates to survey form

  await expect(page.getByLabel("Please specify")).toBeHidden();
  // => Confirms conditional field initially hidden
  // => Field doesn't exist in DOM or has display: none

  await page.getByLabel("How did you hear about us?").selectOption("Other");
  // => Selects 'Other' from dropdown
  // => Triggers conditional field visibility

  await expect(page.getByLabel("Please specify")).toBeVisible();
  // => Asserts conditional field now visible
  // => JavaScript toggled visibility based on selection

  await page.getByLabel("Please specify").fill("Friend's recommendation");
  // => Fills newly-visible text field
  // => Conditional input now accepts user data

  await page.getByLabel("How did you hear about us?").selectOption("Social Media");
  // => Changes selection back to non-conditional option
  // => Should hide conditional field again

  await expect(page.getByLabel("Please specify")).toBeHidden();
  // => Confirms conditional field hidden again
  // => Dynamic visibility works bidirectionally
});

Key Takeaway: Use toBeVisible/toBeHidden for conditional field testing. Test both appearance and disappearance of dynamic elements.

Why It Matters: Dynamic forms reduce cognitive load by showing only relevant fields. Conditional fields can reduce form abandonment but increase UI complexity. Testing visibility state changes ensures JavaScript logic works correctly—broken conditional logic frustrates users who can’t access needed fields or are confused by irrelevant ones.

Example 34: Date Pickers - Calendar Widget

Test date selection using calendar widgets. This handles complex date picker interactions common in booking and scheduling apps.

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

test("selects date from calendar widget", async ({ page }) => {
  // => Test calendar date picker
  await page.goto("https://example.com/booking");
  // => Navigates to booking page

  await page.getByLabel("Check-in Date").click();
  // => Opens calendar widget
  // => Triggers date picker overlay

  await page.getByRole("button", { name: "Next Month" }).click();
  // => Navigates calendar to next month
  // => Updates calendar display

  await page.getByRole("button", { name: "15" }).click();
  // => Selects 15th day from calendar
  // => Closes calendar and fills input

  await expect(page.getByLabel("Check-in Date")).toHaveValue(/2024-\d{2}-15/);
  // => Asserts date value in input field
  // => Regex matches YYYY-MM-15 format

  const selectedDate = await page.getByLabel("Check-in Date").inputValue();
  // => Retrieves selected date value
  // => Returns string like "2024-03-15"

  await page.getByLabel("Check-out Date").click();
  // => Opens check-out calendar
  await page.getByRole("button", { name: "20" }).filter({ hasText: /^20$/ }).click();
  // => Selects 20th day, filtering exact match
  // => Avoids selecting "20XX" year buttons

  const checkOutDate = await page.getByLabel("Check-out Date").inputValue();
  // => Retrieves check-out date value

  expect(new Date(checkOutDate) > new Date(selectedDate)).toBeTruthy();
  // => Asserts check-out after check-in
  // => Business logic validation
});

Key Takeaway: Use getByRole for calendar navigation and date selection. Validate date values in input fields, not just widget interactions.

Why It Matters: Date pickers are notoriously complex UI components with accessibility challenges. Calendar widgets can increase date entry errors compared to simple text inputs if implemented poorly. Testing date picker interactions ensures keyboard navigation, screen reader compatibility, and correct value population—critical for booking systems where date errors cause revenue loss.

Example 35: Multi-Select - Checkbox Groups

Test multiple selection patterns using checkbox groups. This validates selection state management across related options.

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

test("selects multiple interests from checkbox group", async ({ page }) => {
  // => Test checkbox group multi-select
  await page.goto("https://example.com/preferences");
  // => Navigates to preferences form

  const programmingCheckbox = page.getByLabel("Programming");
  // => Locates programming checkbox
  const designCheckbox = page.getByLabel("Design");
  // => Locates design checkbox
  const marketingCheckbox = page.getByLabel("Marketing");
  // => Locates marketing checkbox

  await programmingCheckbox.check();
  // => Checks programming option
  // => Sets checked state to true

  await designCheckbox.check();
  // => Checks design option
  // => Independent of other checkboxes

  await expect(programmingCheckbox).toBeChecked();
  // => Asserts programming checked
  // => Verifies checked state persists

  await expect(designCheckbox).toBeChecked();
  // => Asserts design checked
  // => Both checkboxes selected simultaneously

  await expect(marketingCheckbox).not.toBeChecked();
  // => Asserts marketing unchecked
  // => Unselected options remain unchecked

  await programmingCheckbox.uncheck();
  // => Unchecks programming option
  // => Removes selection

  await expect(programmingCheckbox).not.toBeChecked();
  // => Confirms programming now unchecked
  await expect(designCheckbox).toBeChecked();
  // => Confirms design still checked
  // => Selections independent
});

Key Takeaway: Use check() and uncheck() methods instead of click() for checkbox state management. Assert checked state explicitly with toBeChecked.

Why It Matters: Checkbox groups allow users to select multiple options simultaneously, common in preference settings and filter interfaces. Checkbox state confusion causes many user support tickets—users don’t understand whether checkboxes are selected. Testing explicit checked states ensures visual feedback matches data state, preventing silent data loss when forms submit with unexpected values.

Example 36: Autocomplete - Search Suggestions

Test autocomplete/typeahead components that show suggestions as users type. This validates dynamic search filtering.

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

test("selects item from autocomplete suggestions", async ({ page }) => {
  // => Test autocomplete search
  await page.goto("https://example.com/search");
  // => Navigates to search page

  const searchInput = page.getByPlaceholder("Search for cities...");
  // => Locates search input by placeholder
  await searchInput.fill("San");
  // => Types partial query
  // => Triggers autocomplete suggestions

  await page.waitForSelector('[role="listbox"]');
  // => Waits for suggestions dropdown to appear
  // => Ensures suggestions loaded before interaction

  await expect(page.getByRole("option", { name: /San Francisco/ })).toBeVisible();
  // => Asserts San Francisco in suggestions
  // => Partial match shows relevant results

  await expect(page.getByRole("option", { name: /San Diego/ })).toBeVisible();
  // => Asserts San Diego in suggestions
  // => Multiple matching results displayed

  await page.getByRole("option", { name: /San Francisco/ }).click();
  // => Selects San Francisco from suggestions
  // => Fills input with selected value

  await expect(searchInput).toHaveValue("San Francisco");
  // => Asserts input filled with selected city
  // => Autocomplete completed input

  await expect(page.getByRole("listbox")).toBeHidden();
  // => Asserts suggestions dropdown closed
  // => Selection closes autocomplete
});

Key Takeaway: Wait for suggestions to load before interacting. Use role=“option” to select autocomplete items accessibly.

Why It Matters: Autocomplete reduces typing effort and guides users toward valid options. Autocomplete improves query accuracy but adds timing complexity. Testing autocomplete requires waiting for asynchronous suggestion loading—race conditions between typing and suggestions appearing cause flaky tests that mask real bugs in debounce logic or API response handling.

Example 37: Rich Text Editor - WYSIWYG Input

Test rich text editors with formatting controls. This validates WYSIWYG editor interactions and HTML content extraction.

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

test("formats text in rich text editor", async ({ page }) => {
  // => Test WYSIWYG editor formatting
  await page.goto("https://example.com/compose");
  // => Navigates to composition page

  const editor = page.locator('[contenteditable="true"]');
  // => Locates contenteditable div (editor)
  // => Rich text editors use contenteditable

  await editor.fill("Important announcement");
  // => Fills editor with plain text
  // => Sets innerHTML of contenteditable

  await editor.press("Control+A");
  // => Selects all text
  // => Keyboard shortcut for select all

  await page.getByRole("button", { name: "Bold" }).click();
  // => Clicks bold formatting button
  // => Applies <strong> or <b> tag to selection

  await expect(editor.locator("strong")).toHaveText("Important announcement");
  // => Asserts bold tag wraps text
  // => Verifies HTML structure created

  await editor.click();
  // => Focuses editor for additional input
  await editor.press("End");
  // => Moves cursor to end
  await editor.type(" - Please read");
  // => Appends additional text
  // => Text added to existing content

  await page.getByRole("button", { name: "Italic" }).click();
  // => Clicks italic button
  // => Applies to newly selected text

  const htmlContent = await editor.innerHTML();
  // => Retrieves HTML content from editor
  // => Returns full HTML structure

  expect(htmlContent).toContain("<strong>Important announcement</strong>");
  // => Asserts bold formatting present
  expect(htmlContent).toContain("Please read");
  // => Asserts appended text present
});

Key Takeaway: Use locator(’[contenteditable=“true”]’) to target rich text editors. Validate HTML structure, not just visible text.

Why It Matters: WYSIWYG editors are critical for content management systems but notoriously difficult to test. Many content corruption bugs originate from incorrect HTML structure generation. Testing HTML output ensures formatting buttons create correct markup—visual appearance may match while underlying HTML is malformed, causing rendering issues or data loss when content is saved.

Example 38: Drag-and-Drop - Reordering Items

Test drag-and-drop interactions for reordering lists. This validates mouse-based manipulation patterns.

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

test("reorders items via drag and drop", async ({ page }) => {
  // => Test drag-and-drop reordering
  await page.goto("https://example.com/kanban");
  // => Navigates to kanban board

  const firstTask = page.locator('[data-task-id="1"]');
  // => Locates first task by data attribute
  const secondTask = page.locator('[data-task-id="2"]');
  // => Locates second task

  await expect(firstTask).toHaveText("Task 1");
  // => Confirms first task content
  await expect(secondTask).toHaveText("Task 2");
  // => Confirms second task content

  await firstTask.dragTo(secondTask);
  // => Drags first task to second task position
  // => Triggers drop event and reorder

  const tasks = page.locator("[data-task-id]");
  // => Locates all tasks after reorder
  await expect(tasks.nth(0)).toHaveText("Task 2");
  // => Asserts Task 2 now first
  // => Order changed successfully

  await expect(tasks.nth(1)).toHaveText("Task 1");
  // => Asserts Task 1 now second
  // => Drag-and-drop completed reorder
});

Key Takeaway: Use dragTo() method for drag-and-drop operations. Verify element order after drag completes, not during drag.

Why It Matters: Drag-and-drop provides intuitive reordering but requires complex mouse event sequences. Drag-and-drop reduces task organization time compared to modal-based reordering, but implementation is error-prone. Testing drag-and-drop validates mouse event handling, visual feedback during drag, and data persistence after drop—critical for kanban boards, file uploads, and priority management interfaces.

Example 39: Range Slider - Numeric Input

Test range slider controls for numeric value selection. This validates slider interaction and value synchronization.

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

test("adjusts price range with sliders", async ({ page }) => {
  // => Test range slider interaction
  await page.goto("https://example.com/products");
  // => Navigates to product listing

  const minPriceSlider = page.locator('input[type="range"][name="minPrice"]');
  // => Locates minimum price slider
  const maxPriceSlider = page.locator('input[type="range"][name="maxPrice"]');
  // => Locates maximum price slider

  await minPriceSlider.fill("50");
  // => Sets minimum price to substantial amounts
  // => fill() works with range inputs

  await maxPriceSlider.fill("200");
  // => Sets maximum price to substantial amounts
  // => Programmatic value setting

  await expect(page.getByText("substantial amounts - substantial amounts")).toBeVisible();
  // => Asserts price range display updated
  // => UI reflects slider values

  const minValue = await minPriceSlider.inputValue();
  // => Retrieves current minimum value
  const maxValue = await maxPriceSlider.inputValue();
  // => Retrieves current maximum value

  expect(parseInt(minValue)).toBe(50);
  // => Validates minimum value numeric
  expect(parseInt(maxValue)).toBe(200);
  // => Validates maximum value numeric

  expect(parseInt(maxValue) > parseInt(minValue)).toBeTruthy();
  // => Asserts max greater than min
  // => Business logic validation
});

Key Takeaway: Use fill() to set range input values programmatically. Validate both slider state and corresponding UI display updates.

Why It Matters: Range sliders provide visual feedback for numeric input but synchronization between slider position and value display is error-prone. Many price filter bugs involve slider-value mismatches. Testing slider values ensures accessibility (keyboard users can set values), business logic validation (min < max), and UI synchronization—critical for e-commerce filters where incorrect ranges hide products users want to see.

Example 40: Form Submission - Success and Error Handling

Test complete form submission lifecycle including success responses and server errors. This validates end-to-end form workflows.

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

test("handles successful form submission", async ({ page }) => {
  // => Test successful submission flow
  await page.goto("https://example.com/register");
  // => Navigates to registration form

  await page.getByLabel("Username").fill("newuser123");
  // => Fills username field
  await page.getByLabel("Email").fill("newuser@example.com");
  // => Fills email field
  await page.getByLabel("Password").fill("SecurePass123!");
  // => Fills password field

  const responsePromise = page.waitForResponse(
    (response) => response.url().includes("/api/register") && response.status() === 201,
  );
  // => Waits for successful API response
  // => Status 201 indicates resource created

  await page.getByRole("button", { name: "Register" }).click();
  // => Submits registration form
  // => Triggers POST to /api/register

  await responsePromise;
  // => Ensures response received before assertion
  // => Prevents race condition

  await expect(page.getByText("Registration successful!")).toBeVisible();
  // => Asserts success message displayed
  // => User receives feedback

  await expect(page).toHaveURL(/\/dashboard/);
  // => Asserts navigation to dashboard
  // => Successful registration redirects user
});

test("handles server error during submission", async ({ page }) => {
  // => Test error handling flow
  await page.goto("https://example.com/register");
  // => Navigates to registration form

  await page.getByLabel("Username").fill("existinguser");
  // => Fills with username that already exists
  await page.getByLabel("Email").fill("existing@example.com");
  // => Fills with existing email
  await page.getByLabel("Password").fill("SecurePass123!");
  // => Fills password field

  const responsePromise = page.waitForResponse(
    (response) => response.url().includes("/api/register") && response.status() === 409,
  );
  // => Waits for conflict error response
  // => Status 409 indicates resource already exists

  await page.getByRole("button", { name: "Register" }).click();
  // => Submits registration form
  // => Server returns error

  await responsePromise;
  // => Ensures error response received

  await expect(page.getByText("Username already taken")).toBeVisible();
  // => Asserts error message displayed
  // => User informed of specific problem

  await expect(page).toHaveURL(/\/register/);
  // => Asserts user remains on registration page
  // => No navigation on error
});

Key Takeaway: Use waitForResponse to validate server communication. Test both success and error paths for complete form coverage.

Why It Matters: Forms bridge UI and backend systems—testing only UI interactions misses critical failure modes. Many form bugs occur in success/error handling, not input validation. Testing response handling ensures users receive appropriate feedback, data submits correctly, and errors are actionable. Network failures, server errors, and validation errors each require different user feedback patterns.

Advanced Assertions (Examples 41-50)

Example 41: URL Assertions - Navigation Validation

Test URL changes during navigation and after user actions. This validates routing and deep linking.

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

test("validates URL changes during multi-step flow", async ({ page }) => {
  // => Test URL assertions throughout flow
  await page.goto("https://example.com");
  // => Navigates to homepage

  await expect(page).toHaveURL("https://example.com/");
  // => Asserts exact URL match
  // => Confirms navigation completed

  await page.getByRole("link", { name: "Products" }).click();
  // => Clicks products navigation link
  // => Triggers route change

  await expect(page).toHaveURL(/\/products/);
  // => Asserts URL contains /products path
  // => Regex allows for query parameters

  await page.getByPlaceholder("Search products...").fill("laptop");
  // => Fills search input
  await page.keyboard.press("Enter");
  // => Submits search via Enter key

  await expect(page).toHaveURL(/\/products\?q=laptop/);
  // => Asserts URL includes query parameter
  // => Search term added to URL

  const url = new URL(page.url());
  // => Parses current URL for inspection
  expect(url.searchParams.get("q")).toBe("laptop");
  // => Validates query parameter value
  // => Ensures correct search term in URL

  await page.getByRole("link", { name: "Laptop Pro 15" }).click();
  // => Clicks product link
  await expect(page).toHaveURL(/\/products\/\d+/);
  // => Asserts URL matches product detail pattern
  // => Dynamic ID in URL path
});

Key Takeaway: Use toHaveURL with strings for exact matches, regex for patterns. Parse URLs with URL API for query parameter validation.

Why It Matters: URL structure affects SEO, deep linking, and browser history. Many users bookmark or share product URLs—incorrect URLs break navigation. Testing URL assertions validates routing logic, ensures query parameters persist correctly, and confirms single-page apps update browser history. URLs are the contract between frontend and backend routing systems.

Example 42: Attribute Assertions - Element Properties

Test HTML element attributes that control behavior and styling. This validates data attributes, ARIA labels, and dynamic properties.

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

test("validates element attributes", async ({ page }) => {
  // => Test attribute assertions
  await page.goto("https://example.com/dashboard");
  // => Navigates to dashboard

  const profileButton = page.getByRole("button", { name: "Profile" });
  // => Locates profile button

  await expect(profileButton).toHaveAttribute("data-testid", "profile-btn");
  // => Asserts data attribute present
  // => Test ID attribute for stable selection

  await expect(profileButton).toHaveAttribute("aria-label", "Open profile menu");
  // => Asserts ARIA label for accessibility
  // => Screen readers use aria-label

  await profileButton.click();
  // => Opens profile dropdown
  // => May toggle aria-expanded

  await expect(profileButton).toHaveAttribute("aria-expanded", "true");
  // => Asserts expanded state attribute
  // => Dropdown open state communicated to AT

  const profileMenu = page.getByRole("menu");
  // => Locates profile menu dropdown
  await expect(profileMenu).toHaveAttribute("aria-labelledby", "profile-btn");
  // => Asserts menu labeled by button
  // => Accessibility relationship established

  const themeToggle = page.getByRole("switch", { name: "Dark Mode" });
  // => Locates theme toggle switch
  await expect(themeToggle).toHaveAttribute("aria-checked", "false");
  // => Asserts switch unchecked initially
  // => Dark mode disabled

  await themeToggle.click();
  // => Toggles dark mode on
  await expect(themeToggle).toHaveAttribute("aria-checked", "true");
  // => Asserts switch now checked
  // => State change reflected in attribute
});

Key Takeaway: Use toHaveAttribute to validate both data attributes and ARIA properties. Test attribute changes for interactive components.

Why It Matters: HTML attributes control accessibility, behavior, and testing stability. Many ARIA attribute errors involve incorrect state management. Testing attributes validates screen reader compatibility (aria-label, aria-expanded), component state (data-testid), and dynamic behavior (attribute changes on interaction). Data attributes provide stable selectors immune to text or style changes.

Example 43: Element Count - Collection Assertions

Test the number of elements matching a selector. This validates list rendering, search results, and dynamic content.

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

test("validates search result count", async ({ page }) => {
  // => Test element count assertions
  await page.goto("https://example.com/products");
  // => Navigates to product listing

  const productCards = page.locator('[data-testid="product-card"]');
  // => Locates all product cards
  await expect(productCards).toHaveCount(20);
  // => Asserts 20 products displayed
  // => Default page size

  await page.getByPlaceholder("Search...").fill("laptop");
  // => Filters products by search term
  await page.keyboard.press("Enter");
  // => Submits search

  await expect(productCards).toHaveCount(5);
  // => Asserts filtered results count
  // => 5 products match "laptop"

  await page.getByLabel("Category").selectOption("Electronics");
  // => Applies category filter
  // => Narrows results further

  await expect(productCards).toHaveCount(3);
  // => Asserts combined filter count
  // => 3 products match both filters

  await page.getByRole("button", { name: "Clear Filters" }).click();
  // => Removes all filters
  await expect(productCards).toHaveCount(20);
  // => Asserts count back to default
  // => Filter reset successful
});

Key Takeaway: Use toHaveCount to assert exact element counts. Test count changes when filters or pagination change state.

Why It Matters: Element counts validate that filtering, pagination, and search work correctly. Count discrepancies are a key indicator of broken filtering logic. Testing counts ensures all matching items render, pagination displays correct totals, and filter combinations don’t unexpectedly exclude results. Count mismatches signal data fetching bugs, race conditions, or incorrect query logic.

Example 44: Screenshot Comparison - Visual Regression

Test visual appearance by comparing screenshots. This catches unintended UI changes across releases.

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

test("detects visual changes in button styling", async ({ page }) => {
  // => Test visual regression with screenshots
  await page.goto("https://example.com/components");
  // => Navigates to component showcase

  const primaryButton = page.getByRole("button", { name: "Primary Action" });
  // => Locates primary button

  await expect(primaryButton).toHaveScreenshot("primary-button.png");
  // => Captures button screenshot
  // => Compares against baseline image
  // => Fails if visual difference detected

  await page.getByRole("button", { name: "Toggle Dark Mode" }).click();
  // => Switches to dark theme
  // => Changes component appearance

  await expect(primaryButton).toHaveScreenshot("primary-button-dark.png");
  // => Captures dark mode screenshot
  // => Separate baseline for theme variant

  const cardComponent = page.locator('[data-testid="product-card"]').first();
  // => Locates product card component
  await expect(cardComponent).toHaveScreenshot("product-card.png", {
    // => Screenshot options
    maxDiffPixels: 100,
    // => Allows up to 100 pixels difference
    // => Tolerates minor rendering variations
  });
});

Key Takeaway: Use toHaveScreenshot for visual regression testing. Set maxDiffPixels threshold to tolerate minor rendering differences.

Why It Matters: Visual bugs slip past traditional assertions but frustrate users immediately. Many production bugs are visual regressions undetected by functional tests. Screenshot comparison catches CSS changes, layout shifts, font rendering issues, and theme problems. Anti-aliasing and font rendering vary across systems—maxDiffPixels threshold prevents flaky tests from rendering variations while catching real visual bugs.

Example 45: Accessibility Assertions - Axe Integration

Test accessibility violations using axe-core integration. This validates WCAG compliance automatically.

import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("checks for accessibility violations", async ({ page }) => {
  // => Test accessibility with axe-core
  await page.goto("https://example.com/checkout");
  // => Navigates to checkout page

  const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
  // => Runs axe-core accessibility scan
  // => Analyzes entire page against WCAG rules

  expect(accessibilityScanResults.violations).toEqual([]);
  // => Asserts no accessibility violations found
  // => Empty array means WCAG compliant

  await page.getByLabel("Card Number").fill("4111111111111111");
  // => Fills payment form field
  await page.getByRole("button", { name: "Place Order" }).click();
  // => Submits order, shows confirmation

  const confirmationScan = await new AxeBuilder({ page })
    .include("#confirmation-modal")
    // => Scans specific element only
    // => Focuses on modal dialog
    .analyze();

  expect(confirmationScan.violations).toEqual([]);
  // => Asserts modal accessible
  // => Dialog focus management correct
});

Key Takeaway: Use AxeBuilder for automated accessibility testing. Scan full pages and specific components after dynamic changes.

Why It Matters: Accessibility compliance is legal requirement in many jurisdictions and moral imperative for inclusive design. Automated testing catches a significant portion of WCAG violations—remaining 60% require manual testing, but 40% is significant. Axe-core detects missing labels, poor color contrast, invalid ARIA, keyboard traps, and heading structure issues. Testing accessibility programmatically prevents lawsuits and ensures disabled users can complete critical workflows.

Example 46: Network Response Assertions - API Validation

Test network responses for data integrity and error handling. This validates API contract compliance.

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

test("validates API response data structure", async ({ page }) => {
  // => Test API response assertions
  const responsePromise = page.waitForResponse((response) => response.url().includes("/api/users") && response.ok());
  // => Waits for successful users API call
  // => response.ok() means status 200-299

  await page.goto("https://example.com/admin/users");
  // => Navigates to user management page
  // => Triggers API request

  const response = await responsePromise;
  // => Captures response object
  const responseBody = await response.json();
  // => Parses JSON response body

  expect(response.status()).toBe(200);
  // => Asserts HTTP status code
  // => Successful request

  expect(responseBody).toHaveProperty("users");
  // => Asserts response has users array
  // => Expected data structure

  expect(Array.isArray(responseBody.users)).toBeTruthy();
  // => Validates users is array
  // => Not object or null

  expect(responseBody.users.length).toBeGreaterThan(0);
  // => Asserts users array not empty
  // => Contains data

  expect(responseBody.users[0]).toMatchObject({
    // => Validates user object structure
    id: expect.any(Number),
    // => ID is numeric
    name: expect.any(String),
    // => Name is string
    email: expect.stringMatching(/.+@.+\..+/),
    // => Email matches pattern
  });
});

Key Takeaway: Use waitForResponse to capture and validate API responses. Verify both HTTP status and response body structure.

Why It Matters: Frontend tests often miss API contract violations until production. Many production errors involve API response structure changes breaking frontend code. Testing response structure validates that backend sends expected data format, handles pagination correctly, and includes required fields. API contract tests prevent silent data loss when optional fields become required or data types change.

Example 47: Custom Matchers - Domain-Specific Assertions

Create custom matchers for domain-specific validation. This improves test readability and reusability.

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

// Extend Playwright's expect with custom matcher
expect.extend({
  async toHaveValidPrice(locator: Locator) {
    // => Custom matcher for price validation
    const text = await locator.textContent();
    // => Gets element text content
    const priceMatch = text?.match(/\$(\d+(?:\.\d{2})?)/);
    // => Extracts price from text
    // => Regex matches $XX.XX format

    const pass = priceMatch !== null && parseFloat(priceMatch[1]) > 0;
    // => Validates price format and positive value
    // => Returns boolean for assertion result

    return {
      message: () =>
        pass
          ? `Expected price to be invalid, but got ${text}`
          : `Expected valid price (e.g., substantial amounts.99), but got ${text}`,
      // => Error message for assertion failure
      pass,
      // => Pass/fail status
    };
  },
});

test("validates product prices with custom matcher", async ({ page }) => {
  // => Test using custom price matcher
  await page.goto("https://example.com/products");
  // => Navigates to product listing

  const productPrice = page.locator('[data-testid="product-price"]').first();
  // => Locates first product price
  await expect(productPrice).toHaveValidPrice();
  // => Uses custom matcher
  // => Validates price format and value

  const allPrices = page.locator('[data-testid="product-price"]');
  // => Locates all product prices
  for (const price of await allPrices.all()) {
    // => Iterates over all price elements
    await expect(price).toHaveValidPrice();
    // => Validates each price
    // => Domain-specific assertion
  }
});

Key Takeaway: Use expect.extend to create custom matchers for domain-specific patterns. Custom matchers improve test readability and reduce duplication.

Why It Matters: Generic assertions don’t express domain concepts clearly. Custom matchers can reduce test maintenance by centralizing validation logic. Custom matchers like toHaveValidPrice, toBeWithinDateRange, or toMatchPhoneFormat make tests self-documenting and easier to maintain. Domain logic changes once in the matcher instead of across dozens of tests.

Example 48: Soft Assertions - Continue After Failures

Use soft assertions to collect multiple failures in a single test run. This validates multiple conditions without stopping at the first failure.

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

test("validates all form fields with soft assertions", async ({ page }) => {
  // => Test with soft assertions
  await page.goto("https://example.com/profile");
  // => Navigates to profile page

  // Soft assertions don't stop test execution
  await expect.soft(page.getByLabel("Username")).toHaveValue(/\w+/);
  // => Soft assert username has value
  // => Test continues even if fails

  await expect.soft(page.getByLabel("Email")).toHaveValue(/.+@.+\..+/);
  // => Soft assert email format valid
  // => Continues to next assertion

  await expect.soft(page.getByLabel("Bio")).toHaveValue(/.{10,}/);
  // => Soft assert bio minimum length
  // => Continues collecting failures

  await expect.soft(page.getByLabel("Location")).toHaveValue(/\w+/);
  // => Soft assert location has value
  // => All assertions execute

  // Test fails only after all soft assertions collected
  // => Reports all failures together
  // => Shows complete validation picture
});

Key Takeaway: Use expect.soft() to continue test execution after assertion failures. Soft assertions collect all failures for comprehensive validation.

Why It Matters: Hard assertions stop at first failure, hiding subsequent issues. Soft assertions can reduce debugging time by revealing all problems simultaneously. Soft assertions are ideal for validating multiple fields, checking responsive layouts across breakpoints, or auditing pages for compliance violations. Seeing all failures at once prevents fix-test-fix-test cycles that waste developer time.

Example 49: Polling Assertions - Wait for Conditions

Use polling assertions to wait for conditions that update asynchronously. This handles dynamic content updates.

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

test("waits for real-time update to appear", async ({ page }) => {
  // => Test polling assertions
  await page.goto("https://example.com/dashboard");
  // => Navigates to live dashboard

  const notificationBadge = page.locator('[data-testid="notification-count"]');
  // => Locates notification counter
  await expect(notificationBadge).toHaveText("0");
  // => Initially no notifications

  // Simulate triggering notification (e.g., WebSocket message)
  await page.evaluate(() => {
    // => Executes code in browser context
    (window as any).simulateNotification();
    // => Triggers notification system
  });

  await expect(notificationBadge).toHaveText("1", { timeout: 5000 });
  // => Waits up to 5 seconds for count update
  // => Polls until condition met or timeout
  // => Handles asynchronous state updates

  await expect
    .poll(
      async () => {
        // => Custom polling function
        const text = await notificationBadge.textContent();
        // => Gets current count
        return parseInt(text || "0");
        // => Converts to number
      },
      {
        // => Polling configuration
        timeout: 10000,
        // => Max wait time
        intervals: [100, 250, 500],
        // => Polling intervals (ms)
      },
    )
    .toBeGreaterThan(0);
  // => Asserts count eventually positive
  // => Custom polling logic
});

Key Takeaway: Use timeout option for built-in assertions waiting for async updates. Use expect.poll() for custom polling logic.

Why It Matters: Modern web apps update asynchronously via WebSockets, polling, or real-time APIs. Much test flakiness comes from incorrect wait strategies. Polling assertions provide explicit wait conditions for dynamic content. Default timeouts (30 seconds) work for most cases, but configurable intervals optimize test speed—short intervals for fast updates, longer intervals for slow polling endpoints.

Example 50: Negative Assertions - Verify Absence

Test that elements or content do NOT exist or appear. This validates security controls and conditional rendering.

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

test("verifies admin panel hidden from regular users", async ({ page }) => {
  // => Test negative assertions
  await page.goto("https://example.com/dashboard");
  // => Navigates as regular user

  await expect(page.getByRole("link", { name: "Admin Panel" })).not.toBeVisible();
  // => Asserts admin link not visible
  // => Access control validation

  await expect(page.getByRole("link", { name: "Admin Panel" })).toHaveCount(0);
  // => Asserts admin link doesn't exist in DOM
  // => Stronger assertion than not.toBeVisible

  await expect(page.locator('[data-admin-only="true"]')).toHaveCount(0);
  // => Asserts no admin-only elements present
  // => Validates no admin features leaked

  await page.getByRole("button", { name: "Settings" }).click();
  // => Opens settings menu
  await expect(page.getByText("Delete All Users")).not.toBeVisible();
  // => Asserts dangerous action hidden
  // => Security feature validation

  await expect(page.getByRole("dialog")).not.toBeAttached();
  // => Asserts no modal dialog present
  // => not.toBeAttached checks DOM presence
  // => Differentiates from hidden modals
});

Key Takeaway: Use not.toBeVisible to assert elements hidden, toHaveCount(0) to assert elements absent from DOM. Choose assertion based on whether elements should exist but be hidden.

Why It Matters: Security bugs often involve showing restricted content to unauthorized users. Many access control bugs are UI-level leaks where API correctly restricts access but UI shows restricted options. Testing absence validates that admin features, premium content, or sensitive data don’t appear to unauthorized users. Differentiating “not visible” (exists but hidden) from “not present” (doesn’t exist) matters for performance and security.

API Testing (Examples 51-55)

Example 51: API Request Basics - REST Endpoint Testing

Test API endpoints directly using Playwright’s request context. This validates backend behavior without UI interaction.

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

test("sends GET request to fetch user data", async ({ request }) => {
  // => Test API GET request
  const response = await request.get("https://api.example.com/users/1");
  // => Sends GET request to user endpoint
  // => Returns response object

  expect(response.ok()).toBeTruthy();
  // => Asserts successful response (200-299)
  expect(response.status()).toBe(200);
  // => Asserts specific status code

  const userData = await response.json();
  // => Parses JSON response body
  expect(userData).toMatchObject({
    // => Validates response structure
    id: 1,
    // => User ID matches requested ID
    name: expect.any(String),
    // => Name field exists and is string
    email: expect.stringMatching(/.+@.+\..+/),
    // => Email matches format
  });
});

test("sends POST request to create user", async ({ request }) => {
  // => Test API POST request
  const newUser = {
    // => Request payload
    name: "Alice Smith",
    email: "alice@example.com",
    role: "user",
  };

  const response = await request.post("https://api.example.com/users", {
    // => Sends POST request
    data: newUser,
    // => Request body
  });

  expect(response.status()).toBe(201);
  // => Asserts resource created status
  const createdUser = await response.json();
  // => Gets created user from response
  expect(createdUser).toMatchObject(newUser);
  // => Validates created user matches input
  expect(createdUser.id).toBeDefined();
  // => Asserts server assigned ID
});

Key Takeaway: Use request fixture for API testing without browser overhead. Validate both response status and body structure.

Why It Matters: API testing is significantly faster than UI testing for backend logic validation. Test pyramid recommends more unit tests than API tests, and more API tests than UI tests for optimal speed and coverage. Testing APIs directly validates business logic, data persistence, and error handling without browser rendering overhead. API tests run in milliseconds vs. seconds for UI tests, enabling rapid TDD cycles.

Example 52: API Authentication - Bearer Token and Cookies

Test API endpoints requiring authentication. This validates auth flows and protected endpoint access.

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

test("authenticates with bearer token", async ({ request }) => {
  // => Test API authentication with token
  const loginResponse = await request.post("https://api.example.com/auth/login", {
    // => Login to get auth token
    data: {
      email: "user@example.com",
      password: "SecurePass123!",
    },
  });

  const { token } = await loginResponse.json();
  // => Extracts auth token from response
  expect(token).toBeDefined();
  // => Validates token received

  const protectedResponse = await request.get("https://api.example.com/dashboard/stats", {
    // => Requests protected endpoint
    headers: {
      Authorization: `Bearer ${token}`,
      // => Includes bearer token in header
    },
  });

  expect(protectedResponse.ok()).toBeTruthy();
  // => Asserts authenticated request succeeds
  const stats = await protectedResponse.json();
  // => Gets dashboard stats
  expect(stats).toHaveProperty("revenue");
  // => Validates protected data received
});

test("authenticates with session cookies", async ({ request, context }) => {
  // => Test cookie-based authentication
  await request.post("https://api.example.com/auth/login", {
    // => Login creates session cookie
    data: {
      email: "user@example.com",
      password: "SecurePass123!",
    },
  });
  // => Session cookie automatically stored in context

  const profileResponse = await request.get("https://api.example.com/profile");
  // => Requests profile with session cookie
  // => Cookie automatically included

  expect(profileResponse.ok()).toBeTruthy();
  // => Asserts cookie authentication worked
  const profile = await profileResponse.json();
  expect(profile.email).toBe("user@example.com");
  // => Validates correct user profile returned
});

Key Takeaway: Use headers option for bearer token auth, request context automatically handles cookies. Store tokens for reuse across requests.

Why It Matters: Authentication testing validates security controls and session management. Many authentication bugs involve token handling errors—expired tokens, missing refresh, or token leakage. Testing authentication flows ensures protected endpoints reject unauthenticated requests, tokens work across requests, and session cookies persist correctly. API-level auth tests run faster than UI login flows while providing better security validation.

Example 53: API Mocking - Stubbing External Services

Mock API responses to test frontend behavior in isolation. This enables testing error conditions and edge cases.

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

test("mocks API to simulate slow response", async ({ page }) => {
  // => Test API mocking for loading states
  await page.route("**/api/products", async (route) => {
    // => Intercepts requests to products API
    await new Promise((resolve) => setTimeout(resolve, 3000));
    // => Delays response by 3 seconds
    // => Simulates slow network

    await route.fulfill({
      // => Responds with mock data
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({
        products: [
          { id: 1, name: "Laptop", price: 999 },
          { id: 2, name: "Mouse", price: 29 },
        ],
      }),
    });
  });

  await page.goto("https://example.com/shop");
  // => Navigates to shop page
  // => Triggers mocked API request

  await expect(page.getByText("Loading...")).toBeVisible();
  // => Asserts loading indicator appears
  // => Slow response makes indicator visible

  await expect(page.getByText("Laptop")).toBeVisible({ timeout: 5000 });
  // => Asserts product appears after load
  // => Mock response rendered
});

test("mocks API to simulate error response", async ({ page }) => {
  // => Test error handling with mock
  await page.route("**/api/products", async (route) => {
    // => Intercepts products API
    await route.fulfill({
      // => Returns error response
      status: 500,
      contentType: "application/json",
      body: JSON.stringify({
        error: "Internal server error",
      }),
    });
  });

  await page.goto("https://example.com/shop");
  // => Navigates to shop
  // => API returns mocked error

  await expect(page.getByText("Failed to load products. Please try again.")).toBeVisible();
  // => Asserts error message displayed
  // => Frontend handles API error gracefully
});

Key Takeaway: Use page.route to intercept and mock API requests. Mock slow responses, errors, and edge cases impossible to reliably trigger with real API.

Why It Matters: Real APIs are unreliable test dependencies—external services fail, rate limits trigger, or test data changes. Mocked API tests are significantly faster and more reliable than tests hitting real APIs. Mocking enables testing error states (500 errors, timeouts), loading states (slow responses), and edge cases (empty results, pagination boundaries) that are difficult or impossible to reproduce consistently with real backend services.

Example 54: API Test Fixtures - Reusable Setup

Create test fixtures for API authentication and data setup. This reduces duplication in API tests.

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

// Extend base test with API auth fixture
const test = base.extend<{ authenticatedRequest: APIRequestContext }>({
  // => Creates custom fixture
  authenticatedRequest: async ({ request }, use) => {
    // => Fixture setup
    const loginResponse = await request.post("https://api.example.com/auth/login", {
      // => Logs in to get token
      data: {
        email: "test@example.com",
        password: "TestPass123!",
      },
    });

    const { token } = await loginResponse.json();
    // => Extracts auth token

    const authenticatedRequest = request;
    // => Stores authenticated request context
    await authenticatedRequest.use({
      // => Sets default headers
      extraHTTPHeaders: {
        Authorization: `Bearer ${token}`,
        // => All requests include auth token
      },
    });

    await use(authenticatedRequest);
    // => Provides fixture to test
    // => Test runs with authenticated request

    // Cleanup after test
    await request.post("https://api.example.com/auth/logout");
    // => Logs out to clean up session
  },
});

test("fetches user orders with auth fixture", async ({ authenticatedRequest }) => {
  // => Test uses authenticated request fixture
  const response = await authenticatedRequest.get("https://api.example.com/orders");
  // => Request automatically includes auth token
  // => No manual token handling needed

  expect(response.ok()).toBeTruthy();
  // => Asserts request succeeds
  const orders = await response.json();
  expect(orders.length).toBeGreaterThan(0);
  // => Validates orders returned
});

test("creates new order with auth fixture", async ({ authenticatedRequest }) => {
  // => Another test using same fixture
  const newOrder = {
    productId: 123,
    quantity: 2,
  };

  const response = await authenticatedRequest.post("https://api.example.com/orders", {
    data: newOrder,
  });
  // => Creates order with authenticated request
  // => Token automatically included

  expect(response.status()).toBe(201);
  // => Asserts order created
});

Key Takeaway: Extend base test with API fixtures for reusable authentication. Fixtures handle setup and cleanup automatically.

Why It Matters: API test duplication wastes time and makes tests fragile. Much API test code involves duplicated authentication setup. Fixtures centralize authentication, eliminate token management boilerplate, and ensure consistent cleanup. When auth logic changes, update the fixture once instead of dozens of tests. Fixtures also enable testing with different user roles by creating multiple authenticated request fixtures.

Example 55: Combined UI and API Testing - Hybrid Validation

Combine UI interactions with API assertions for comprehensive validation. This tests both user experience and data integrity.

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

test("validates UI form submission creates API resource", async ({ page, request }) => {
  // => Hybrid UI and API test
  await page.goto("https://example.com/products/new");
  // => Navigates to product creation form

  await page.getByLabel("Product Name").fill("Wireless Keyboard");
  // => Fills product name field
  await page.getByLabel("Price").fill("79.99");
  // => Fills price field
  await page.getByLabel("Category").selectOption("Electronics");
  // => Selects category

  const responsePromise = page.waitForResponse(
    (response) => response.url().includes("/api/products") && response.status() === 201,
  );
  // => Waits for product creation API call

  await page.getByRole("button", { name: "Create Product" }).click();
  // => Submits form via UI
  // => Triggers API request

  const response = await responsePromise;
  const createdProduct = await response.json();
  // => Captures created product from API response

  expect(createdProduct.name).toBe("Wireless Keyboard");
  // => Validates API created correct product
  expect(createdProduct.price).toBe(79.99);
  // => Validates price stored correctly

  // Verify product appears in UI
  await expect(page.getByText("Product created successfully")).toBeVisible();
  // => Asserts UI success feedback

  // Verify product persisted via API GET
  const fetchResponse = await request.get(`https://api.example.com/products/${createdProduct.id}`);
  // => Fetches product directly via API
  // => Validates persistence

  const fetchedProduct = await fetchResponse.json();
  expect(fetchedProduct).toMatchObject(createdProduct);
  // => Confirms API GET returns created product
  // => End-to-end validation: UI → API → Storage → API
});

Key Takeaway: Combine UI interactions with API validation for end-to-end testing. Verify both user experience and data persistence.

Why It Matters: UI tests alone miss data corruption bugs; API tests alone miss user experience issues. Hybrid tests can catch more bugs than separate UI or API tests. Hybrid testing validates complete workflows: UI submits correctly, API processes correctly, data persists correctly, and subsequent API reads return correct data. This approach catches integration bugs where UI and backend disagree on data format or validation rules.

Test Organization (Examples 56-60)

Example 56: Page Object Model Basics - Encapsulation

Create page objects to encapsulate page-specific locators and actions. This improves test maintainability and reduces duplication.

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

class LoginPage {
  // => Page Object for login page
  readonly page: Page;
  // => Stores page instance

  constructor(page: Page) {
    // => Constructor receives page
    this.page = page;
  }

  // Locators
  get usernameInput() {
    // => Getter for username field
    return this.page.getByLabel("Username");
    // => Returns locator (not element)
  }

  get passwordInput() {
    // => Getter for password field
    return this.page.getByLabel("Password");
  }

  get submitButton() {
    // => Getter for submit button
    return this.page.getByRole("button", { name: "Log In" });
  }

  get errorMessage() {
    // => Getter for error message
    return this.page.getByRole("alert");
  }

  // Actions
  async navigate() {
    // => Navigation method
    await this.page.goto("https://example.com/login");
    // => Encapsulates URL
  }

  async login(username: string, password: string) {
    // => Login action method
    await this.usernameInput.fill(username);
    // => Fills username using page object locator
    await this.passwordInput.fill(password);
    // => Fills password
    await this.submitButton.click();
    // => Submits form
  }

  async expectError(message: string) {
    // => Assertion helper
    await expect(this.errorMessage).toContainText(message);
    // => Encapsulates error assertion
  }
}

test("logs in successfully with page object", async ({ page }) => {
  // => Test using page object
  const loginPage = new LoginPage(page);
  // => Creates page object instance

  await loginPage.navigate();
  // => Navigates using page object method
  await loginPage.login("testuser", "TestPass123!");
  // => Performs login action

  await expect(page).toHaveURL(/\/dashboard/);
  // => Asserts navigation after login
  // => Test reads like user actions
});

test("shows error for invalid credentials", async ({ page }) => {
  // => Another test using same page object
  const loginPage = new LoginPage(page);

  await loginPage.navigate();
  await loginPage.login("wronguser", "wrongpass");
  // => Attempts invalid login

  await loginPage.expectError("Invalid username or password");
  // => Uses page object assertion helper
  // => No direct locator references in test
});

Key Takeaway: Page objects encapsulate locators and actions for specific pages. Tests use high-level methods instead of low-level locator calls.

Why It Matters: Direct locator usage creates fragile tests—when UI changes, every test using that locator breaks. Page object pattern significantly reduces test maintenance burden. Page objects provide single source of truth for locators—when “Username” label changes to “Email”, update one getter instead of 50 tests. Page objects also improve readability—loginPage.login(user, pass) is clearer than three fill/click calls.

Example 57: Test Fixtures - Custom Setup and Teardown

Create custom test fixtures for reusable setup, teardown, and test data. This eliminates duplication across tests.

import { test as base, expect } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";

type CustomFixtures = {
  // => Type definition for custom fixtures
  loginPage: LoginPage;
  // => LoginPage instance fixture
  authenticatedPage: Page;
  // => Pre-authenticated page fixture
};

const test = base.extend<CustomFixtures>({
  // => Extends base test with fixtures
  loginPage: async ({ page }, use) => {
    // => LoginPage fixture
    const loginPage = new LoginPage(page);
    // => Creates page object
    await use(loginPage);
    // => Provides to test
    // => Automatic cleanup after test
  },

  authenticatedPage: async ({ page }, use) => {
    // => Authenticated page fixture
    await page.goto("https://example.com/login");
    // => Navigates to login
    await page.getByLabel("Username").fill("testuser");
    // => Fills credentials
    await page.getByLabel("Password").fill("TestPass123!");
    await page.getByRole("button", { name: "Log In" }).click();
    // => Submits login

    await page.waitForURL(/\/dashboard/);
    // => Waits for redirect after login
    // => Page now authenticated

    await use(page);
    // => Provides authenticated page to test

    // Cleanup: logout after test
    await page.goto("https://example.com/logout");
    // => Logs out to clean state
  },
});

test("navigates to settings from dashboard", async ({ authenticatedPage }) => {
  // => Test receives authenticated page
  // => No login boilerplate needed

  await authenticatedPage.getByRole("link", { name: "Settings" }).click();
  // => Navigates to settings
  // => Test focuses on actual test logic

  await expect(authenticatedPage).toHaveURL(/\/settings/);
  // => Asserts navigation successful
});

test("creates new project from dashboard", async ({ authenticatedPage }) => {
  // => Another test using same fixture
  await authenticatedPage.getByRole("button", { name: "New Project" }).click();
  // => No repeated login code
  // => Fixture handles authentication
});

Key Takeaway: Use fixtures for reusable setup and teardown. Fixtures provide clean state and reduce test duplication.

Why It Matters: Test duplication wastes time and makes suites fragile. Fixtures significantly reduce setup code while improving test isolation. Fixtures handle cleanup automatically—even if test fails, fixture teardown runs, preventing state leakage between tests. Fixtures also compose—authenticatedPage fixture can depend on loginPage fixture, building complex setup from simple building blocks.

Example 58: Test Hooks - Setup and Teardown

Use beforeEach, afterEach, beforeAll, and afterAll hooks for test lifecycle management. This handles common setup/cleanup patterns.

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

test.describe("Shopping cart tests", () => {
  // => Test suite for shopping cart
  let testProductId: string;
  // => Shared variable across tests

  test.beforeAll(async ({ request }) => {
    // => Runs once before all tests
    const response = await request.post("https://api.example.com/test/products", {
      // => Creates test product
      data: {
        name: "Test Product",
        price: 99.99,
      },
    });

    const product = await response.json();
    testProductId = product.id;
    // => Stores product ID for use in tests
    // => Shared test data
  });

  test.beforeEach(async ({ page }) => {
    // => Runs before each test
    await page.goto("https://example.com");
    // => Navigates to homepage
    // => Ensures consistent starting state

    await page.evaluate(() => localStorage.clear());
    // => Clears local storage
    // => Prevents cart state leakage
  });

  test("adds product to cart", async ({ page }) => {
    // => Test case
    await page.goto(`https://example.com/products/${testProductId}`);
    // => Uses shared test product
    await page.getByRole("button", { name: "Add to Cart" }).click();
    // => Adds to cart

    await expect(page.getByText("1 item in cart")).toBeVisible();
    // => Asserts cart updated
  });

  test("removes product from cart", async ({ page }) => {
    // => Another test with same setup
    await page.goto(`https://example.com/products/${testProductId}`);
    await page.getByRole("button", { name: "Add to Cart" }).click();
    // => Adds to cart first

    await page.getByRole("link", { name: "Cart" }).click();
    // => Opens cart
    await page.getByRole("button", { name: "Remove" }).click();
    // => Removes item

    await expect(page.getByText("Cart is empty")).toBeVisible();
    // => Asserts cart empty
  });

  test.afterEach(async ({ page }) => {
    // => Runs after each test
    await page.evaluate(() => localStorage.clear());
    // => Clears cart state
    // => Cleanup after test
  });

  test.afterAll(async ({ request }) => {
    // => Runs once after all tests
    await request.delete(`https://api.example.com/test/products/${testProductId}`);
    // => Deletes test product
    // => Cleanup shared test data
  });
});

Key Takeaway: Use beforeEach/afterEach for per-test setup/cleanup, beforeAll/afterAll for suite-level setup/cleanup. Hooks ensure consistent test state.

Why It Matters: Test isolation prevents flaky tests from state leakage. Improper cleanup causes much test flakiness. beforeEach ensures every test starts with clean state (cleared storage, logged out, fresh navigation). afterAll prevents test data accumulation—without cleanup, thousands of test runs create millions of test products. Hooks centralize lifecycle management instead of copy-pasting setup/cleanup in every test.

Example 59: Test Annotations - Metadata and Conditional Execution

Use test annotations to add metadata, skip tests conditionally, or mark tests as slow. This improves test organization and execution control.

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

test("basic login test", async ({ page }) => {
  // => Standard test without annotations
  await page.goto("https://example.com/login");
  // => Normal test execution
});

test("slow database migration test", async ({ page }) => {
  // => Test with slow annotation
  test.slow();
  // => Triples timeout for this test
  // => Useful for known slow operations

  await page.goto("https://example.com/admin/migrations");
  await page.getByRole("button", { name: "Run Migration" }).click();
  // => Long-running operation
});

test("mobile-only responsive test", async ({ page, isMobile }) => {
  // => Test with conditional skip
  test.skip(!isMobile, "This test is only for mobile viewports");
  // => Skips test if not mobile
  // => Conditional execution based on config

  await page.goto("https://example.com");
  await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
  // => Mobile-specific UI element
});

test("flaky API integration test", async ({ page }) => {
  // => Test marked as flaky
  test.fixme(true, "Known flaky test - API rate limiting issue");
  // => Marks test as failing but doesn't run
  // => Documents known issues

  // Test would run here if fixme removed
});

test("payment processing test", async ({ page }) => {
  // => Test with custom annotation
  test.info().annotations.push({
    type: "issue",
    description: "https://github.com/org/repo/issues/123",
    // => Links to related issue
  });

  test.info().annotations.push({
    type: "category",
    description: "payment",
    // => Custom categorization
  });

  await page.goto("https://example.com/checkout");
  // => Test executes with metadata attached
});

test.describe("WebKit-specific tests", () => {
  // => Test suite with conditional skip
  test.skip(({ browserName }) => browserName !== "webkit", "WebKit only");
  // => Skips entire suite for non-WebKit browsers

  test("Safari-specific CSS rendering", async ({ page }) => {
    // => Only runs on WebKit
    await page.goto("https://example.com");
    // => WebKit-specific test logic
  });
});

Key Takeaway: Use test.slow() for known slow tests, test.skip() for conditional execution, and custom annotations for metadata. Annotations improve test reporting and filtering.

Why It Matters: Test metadata enables intelligent test execution and better reporting. Conditional skipping can significantly reduce CI time by running only relevant tests per environment. test.slow() prevents timeout failures for legitimate slow operations without inflating timeout for entire suite. Annotations document flaky tests, link to issues, and categorize tests for selective execution—run only “payment” tests for payment system changes.

Example 60: Test Retries and Timeouts - Reliability Configuration

Configure test retries and timeouts to handle flaky tests and slow operations. This balances reliability with execution speed.

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

test.describe("Tests with custom retry logic", () => {
  // => Suite with retry configuration
  test.describe.configure({ retries: 2 });
  // => Retries failed tests up to 2 times
  // => Suite-level retry configuration

  test("flaky network-dependent test", async ({ page }) => {
    // => Test that might fail due to network
    await page.goto("https://example.com/api-dashboard");
    // => Loads data from external API
    // => May timeout or fail intermittently

    await expect(page.getByText("API Status: Online")).toBeVisible({
      timeout: 10000,
    });
    // => Custom timeout for specific assertion
    // => Allows longer wait for API response
  });
});

test("critical test - no retries", async ({ page }) => {
  // => Test with retry override
  test.describe.configure({ retries: 0 });
  // => No retries for this test
  // => Fail immediately to surface critical issues

  await page.goto("https://example.com/health");
  await expect(page.getByText("System Healthy")).toBeVisible();
  // => If this fails, something is seriously wrong
});

test("slow e2e test with extended timeout", async ({ page }) => {
  // => Test with custom timeout
  test.setTimeout(120000);
  // => Sets 2-minute timeout for entire test
  // => Default is 30 seconds

  await page.goto("https://example.com/report/generate");
  await page.getByRole("button", { name: "Generate Annual Report" }).click();
  // => Triggers long-running report generation

  await expect(page.getByText("Report Ready")).toBeVisible({ timeout: 90000 });
  // => Waits up to 90 seconds for report
  // => Custom assertion timeout within extended test timeout
});

test("dynamic timeout based on environment", async ({ page }) => {
  // => Test with conditional timeout
  const timeout = process.env.CI ? 60000 : 30000;
  // => Longer timeout in CI environment
  // => CI servers often slower than local

  test.setTimeout(timeout);
  // => Applies environment-specific timeout

  await page.goto("https://example.com/dashboard");
  await expect(page.getByText("Dashboard Loaded")).toBeVisible({
    timeout: timeout / 2,
  });
  // => Proportional assertion timeout
});

Key Takeaway: Configure retries at suite level with test.describe.configure(), timeouts with test.setTimeout(). Balance reliability (retries) with fast failure detection.

Why It Matters: Flaky tests erode confidence in test suites but retrying every test wastes CI time. Multiple retries catch most transient failures while limiting retries to flaky suites prevents masking real bugs. Timeout configuration prevents false failures for legitimate slow operations while keeping default timeouts short to catch infinite loops. Environment-specific timeouts account for CI performance variability—CI servers are noticeably slower than developer machines.

Last updated