Skip to content
AyoKoding

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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Select["Select dropdown option"] --> JS["JavaScript event handler<br/>evaluates selection"]
    JS --> Cond{"Option == 'Other'?"}
    Cond -->|yes| Show["Show conditional field<br/>toBeVisible()"]
    Cond -->|no| Hide["Hide conditional field<br/>toBeHidden()"]
    Show --> Fill["Fill conditional field<br/>with user input"]
    Fill --> ChangeBack["Change selection back"]
    ChangeBack --> JS
 
    style Select fill:#0173B2,color:#fff
    style JS fill:#DE8F05,color:#000
    style Cond fill:#CC78BC,color:#000
    style Show fill:#029E73,color:#fff
    style Hide fill:#CA9161,color:#fff
    style Fill fill:#029E73,color:#fff
    style ChangeBack fill:#0173B2,color:#fff
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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
sequenceDiagram
    participant Test as Test Code
    participant PW as Playwright
    participant DOM as Browser DOM
 
    Test->>PW: firstTask.dragTo(secondTask)
    PW->>DOM: mousedown on firstTask (center)
    DOM-->>PW: dragstart event fired
    PW->>DOM: mousemove toward secondTask
    DOM-->>PW: dragover event on secondTask
    PW->>DOM: mouseup on secondTask
    DOM-->>PW: drop event fired, DOM reordered
    PW-->>Test: dragTo() resolved
    Test->>PW: expect(tasks.nth(0)).toHaveText('Task 2')
    PW-->>Test: assertion passes
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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Submit["button.click() — form submit"] --> API["POST /api/register"]
    API --> Status{"HTTP Status?"}
    Status -->|"201 Created"| Success["Show success message<br/>Navigate to /dashboard"]
    Status -->|"409 Conflict"| Error["Show error message<br/>Stay on /register"]
    Status -->|"500 Server Error"| ServerErr["Show generic error<br/>Stay on /register"]
    Success --> AssertURL["expect(page).toHaveURL(/dashboard/)"]
    Error --> AssertErr["expect(page.getByText('Username taken'))"]
    ServerErr --> AssertErr2["expect(page.getByText('Something went wrong'))"]
 
    style Submit fill:#0173B2,color:#fff
    style API fill:#DE8F05,color:#000
    style Status fill:#CC78BC,color:#000
    style Success fill:#029E73,color:#fff
    style Error fill:#CA9161,color:#fff
    style ServerErr fill:#CA9161,color:#fff
    style AssertURL fill:#029E73,color:#fff
    style AssertErr fill:#DE8F05,color:#000
    style AssertErr2 fill:#DE8F05,color:#000
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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Assert["expect(page).toHaveScreenshot('baseline.png')"] --> FirstRun{"Baseline<br/>exists?"}
    FirstRun -->|no| CreateBase["Capture current screenshot<br/>Save as baseline"]
    FirstRun -->|yes| Capture["Capture current screenshot"]
    CreateBase --> PassFirst["Pass (first run creates baseline)"]
    Capture --> Diff["Pixel diff comparison<br/>(with maxDiffPixels threshold)"]
    Diff -->|"within threshold"| Pass["Assertion Passes"]
    Diff -->|"exceeds threshold"| Fail["Fail: attach diff image<br/>to test report"]
 
    style Assert fill:#0173B2,color:#fff
    style FirstRun fill:#DE8F05,color:#000
    style CreateBase fill:#CA9161,color:#fff
    style Capture fill:#CA9161,color:#fff
    style PassFirst fill:#029E73,color:#fff
    style Diff fill:#CC78BC,color:#000
    style Pass fill:#029E73,color:#fff
    style Fail fill:#DE8F05,color:#000
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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Page["await page.goto(url)"] --> Scan["new AxeBuilder({ page }).analyze()"]
    Scan --> Rules["Run WCAG 2.x rules:<br/>• Missing labels<br/>• Color contrast<br/>• Invalid ARIA<br/>• Keyboard traps"]
    Rules --> Results["violations array"]
    Results --> Check{"violations.length?"}
    Check -->|"== 0"| Pass["All rules pass"]
    Check -->|"> 0"| Fail["Violations found<br/>(rule, impact, element)"]
 
    style Page fill:#0173B2,color:#fff
    style Scan fill:#DE8F05,color:#000
    style Rules fill:#CC78BC,color:#000
    style Results fill:#CA9161,color:#fff
    style Check fill:#DE8F05,color:#000
    style Pass fill:#029E73,color:#fff
    style Fail fill:#DE8F05,color:#000

Why this external dependency: Playwright's built-in accessibility() snapshot API provides access to the accessibility tree for assertions on individual elements, but it does not detect WCAG rule violations automatically. @axe-core/playwright wraps the industry-standard axe-core engine, which tests pages against WCAG 2.x rules (missing labels, color contrast violations, invalid ARIA, keyboard traps, and more) in a single scan. Install with: npm install @axe-core/playwright.

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
  // => Page will be scanned for WCAG violations
 
  const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
  // => Runs axe-core accessibility scan on entire page
  // => Analyzes against WCAG 2.x rules (missing labels, contrast, ARIA)
  // => Returns object with violations array
 
  expect(accessibilityScanResults.violations).toEqual([]);
  // => Asserts no accessibility violations found
  // => Empty array means all WCAG rules pass
 
  await page.getByLabel("Card Number").fill("4111111111111111");
  // => Fills payment form field (test card number)
  await page.getByRole("button", { name: "Place Order" }).click();
  // => Submits order, triggers confirmation modal
 
  const confirmationScan = await new AxeBuilder({ page })
    .include("#confirmation-modal")
    // => .include() scopes scan to specific element
    // => Scans only the modal, not entire page
    .analyze();
  // => Returns violations for modal element only
 
  expect(confirmationScan.violations).toEqual([]);
  // => Asserts modal accessible
  // => Dialog focus management, ARIA roles 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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
sequenceDiagram
    participant Test as Test Code
    participant PW as Playwright
    participant UI as Browser UI
    participant API as Backend API
 
    Test->>PW: Set up waitForResponse() promise
    Test->>UI: page.getByRole('button').click()
    UI->>API: XHR/fetch POST /api/data
    API-->>UI: HTTP 200 { data: [...] }
    UI-->>PW: Network response intercepted
    PW-->>Test: response object resolved
    Test->>Test: expect(response.status()).toBe(200)
    Test->>Test: expect(data).toMatchObject(schema)
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";
// => Import test runner and expect assertion library
 
// Extend Playwright's expect with custom matcher
// => expect.extend adds new assertion methods globally
expect.extend({
  // => Object where keys become new expect().methodName() calls
  async toHaveValidPrice(locator: Locator) {
    // => Custom matcher function: async, receives Locator
    const text = await locator.textContent();
    // => Gets element text content (e.g., "$29.99")
    const priceMatch = text?.match(/\$(\d+(?:\.\d{2})?)/);
    // => Extracts price from text using regex
    // => Regex matches $XX or $XX.XX format
 
    const pass = priceMatch !== null && parseFloat(priceMatch[1]) > 0;
    // => pass=true when: price found AND value > 0
    // => pass=false when: no match or zero/negative price
 
    return {
      // => Return object: Playwright reads message and pass
      message: () =>
        // => message(): function returning error string
        pass
          ? `Expected price to be invalid, but got ${text}`
          : // => .not.toHaveValidPrice() failure message
            `Expected valid price (e.g., substantial amounts.99), but got ${text}`,
      // => Regular toHaveValidPrice() failure message
      // => message(): called when assertion fails
      // => Different message for .not and normal usage
      pass,
      // => Playwright uses pass to determine success/failure
    };
  },
});
 
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 page
 
  const productPrice = page.locator('[data-testid="product-price"]').first();
  // => Locates first product price element by test ID
  await expect(productPrice).toHaveValidPrice();
  // => Calls custom matcher: validates "$XX.XX" format
  // => Fails if price missing, zero, or malformatted
 
  const allPrices = page.locator('[data-testid="product-price"]');
  // => Locates ALL product price elements
  for (const price of await allPrices.all()) {
    // => Iterates over each price element (array of Locators)
    await expect(price).toHaveValidPrice();
    // => Validates each price individually
    // => Custom matcher reused across all products
  }
});

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";
// => Import test and expect from Playwright
 
test("waits for real-time update to appear", async ({ page }) => {
  // => Test polling assertions for async updates
  await page.goto("https://example.com/dashboard");
  // => Navigates to live dashboard with real-time features
 
  const notificationBadge = page.locator('[data-testid="notification-count"]');
  // => Locates notification counter by test ID
  await expect(notificationBadge).toHaveText("0");
  // => Asserts initial state: no notifications
  // => Baseline before triggering change
 
  // Simulate triggering notification (e.g., WebSocket message)
  await page.evaluate(() => {
    // => Executes JavaScript in browser context
    (window as any).simulateNotification();
    // => Calls global function to simulate notification
    // => Triggers WebSocket/server-sent event
  });
  // => Browser state updated asynchronously
 
  await expect(notificationBadge).toHaveText("1", { timeout: 5000 });
  // => Waits up to 5 seconds for count to update to "1"
  // => Polls every ~100ms until condition met or timeout
  // => Auto-retry handles asynchronous state updates
 
  await expect
    .poll(
      async () => {
        // => Custom polling function executed repeatedly
        const text = await notificationBadge.textContent();
        // => Reads current badge text on each poll
        return parseInt(text || "0");
        // => Returns numeric value for comparison
      },
      {
        timeout: 10000,
        // => Max wait time: 10 seconds
        intervals: [100, 250, 500],
        // => Polling intervals: 100ms, 250ms, 500ms (backoff)
      },
    )
    // => .poll().toBeGreaterThan(0): assertion on polled value
    .toBeGreaterThan(0);
  // => Asserts count eventually becomes positive (> 0)
  // => expect.poll retries until assertion passes or timeout
});

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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
sequenceDiagram
    participant Test as Test Code
    participant Req as request fixture
    participant API as Backend API
 
    Test->>Req: request.get('/api/users')
    Req->>API: HTTP GET /api/users<br/>(no browser overhead)
    API-->>Req: HTTP 200 [{ id, name, email }]
    Req-->>Test: APIResponse object
    Test->>Test: expect(response.ok()).toBeTruthy()
    Test->>Test: const data = await response.json()
    Test->>Test: expect(data[0]).toHaveProperty('email')
import { test, expect } from "@playwright/test";
// => Import test and expect from Playwright
 
test("sends GET request to fetch user data", async ({ request }) => {
  // => request: Playwright's API request fixture
  // => No browser overhead - pure HTTP requests
  const response = await request.get("https://api.example.com/users/1");
  // => Sends GET request to user endpoint
  // => Returns APIResponse object
  // => Awaits HTTP response before continuing
 
  expect(response.ok()).toBeTruthy();
  // => response.ok() returns true for 200-299 status codes
  // => Asserts successful response
  expect(response.status()).toBe(200);
  // => Asserts specific HTTP status code is 200
 
  const userData = await response.json();
  // => Parses JSON response body asynchronously
  // => Returns parsed JavaScript object
  expect(userData).toMatchObject({
    // => Validates response contains expected shape (partial match)
    id: 1,
    // => User ID 1 was requested and returned
    name: expect.any(String),
    // => expect.any(String): name exists and is any string
    email: expect.stringMatching(/.+@.+\..+/),
    // => Email matches basic email format regex
  });
  // => toMatchObject allows extra fields (partial match)
});
 
test("sends POST request to create user", async ({ request }) => {
  // => Test API POST endpoint for resource creation
  const newUser = {
    // => Request payload (will be serialized as JSON)
    name: "Alice Smith",
    // => User's full name
    email: "alice@example.com",
    // => Unique email address
    role: "user",
    // => Role determines permissions
  };
 
  const response = await request.post("https://api.example.com/users", {
    data: newUser,
    // => data: automatically serialized to JSON
    // => Sets Content-Type: application/json header
  });
  // => Sends POST request with newUser as body
 
  expect(response.status()).toBe(201);
  // => 201 Created: HTTP standard for successful resource creation
  const createdUser = await response.json();
  // => Parses the response body (created user object)
  expect(createdUser).toMatchObject(newUser);
  // => Validates server echoed back the submitted data
  expect(createdUser.id).toBeDefined();
  // => Asserts server assigned a database ID to new user
});

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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
sequenceDiagram
    participant Test as Test Code
    participant Req as request context
    participant Auth as Auth API
    participant Prot as Protected API
 
    Test->>Req: POST /auth/login { email, password }
    Req->>Auth: HTTP POST with credentials
    Auth-->>Req: HTTP 200 { token: 'eyJ...' }
    Req-->>Test: loginResponse with token
 
    Test->>Req: GET /dashboard/stats<br/>Authorization: Bearer eyJ...
    Req->>Prot: HTTP GET with Bearer token
    Prot->>Prot: Validate JWT signature & expiry
    Prot-->>Req: HTTP 200 { revenue: ... }
    Req-->>Test: protectedResponse
    Test->>Test: expect(stats).toHaveProperty('revenue')
import { test, expect } from "@playwright/test";
// => Import test runner and assertion library
 
test("authenticates with bearer token", async ({ request }) => {
  // => Test API authentication with JWT bearer token
  const loginResponse = await request.post("https://api.example.com/auth/login", {
    // => POST request to authentication endpoint
    data: {
      // => Login credentials as JSON body
      email: "user@example.com",
      // => Test account email
      password: "SecurePass123!",
      // => Test account password
    },
  });
  // => Returns 200 with JWT token in body
 
  const { token } = await loginResponse.json();
  // => Destructures token from JSON response
  // => token: "eyJhbGciOi..." (JWT format)
  expect(token).toBeDefined();
  // => Validates token string received from auth endpoint
 
  const protectedResponse = await request.get("https://api.example.com/dashboard/stats", {
    headers: {
      // => headers: object with HTTP request headers
      Authorization: `Bearer ${token}`,
      // => Authorization header: "Bearer eyJhbGci..."
      // => Standard JWT bearer token format (RFC 6750)
    },
  });
  // => Sends GET request with token in Authorization header
 
  expect(protectedResponse.ok()).toBeTruthy();
  // => Asserts 200 status: server accepted the token
  const stats = await protectedResponse.json();
  // => Parses dashboard statistics response body
  expect(stats).toHaveProperty("revenue");
  // => Asserts protected data (revenue) received
  // => toHaveProperty: checks nested property exists
});
 
test("authenticates with session cookies", async ({ request, context }) => {
  // => Test cookie-based session authentication
  // => context: browser context that stores cookies
  await request.post("https://api.example.com/auth/login", {
    // => POST to login endpoint
    data: {
      // => Login credentials
      email: "user@example.com",
      // => Test account email
      password: "SecurePass123!",
      // => Test account password
    },
  });
  // => Server sets Set-Cookie: session=... header
  // => Playwright's request context stores cookie automatically
 
  const profileResponse = await request.get("https://api.example.com/profile");
  // => GET request: Playwright sends stored session cookie
  // => Cookie header included automatically by request context
 
  expect(profileResponse.ok()).toBeTruthy();
  // => Asserts 200 status: session cookie validated by server
  const profile = await profileResponse.json();
  // => Parses user profile from response body
  expect(profile.email).toBe("user@example.com");
  // => Validates server returned the correct user's profile
});

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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Route["page.route('**/api/products', handler)"] --> Navigate["page.goto('/shop')"]
    Navigate --> BrowserReq["Browser fires fetch<br/>to /api/products"]
    BrowserReq --> Intercept["Playwright intercepts request<br/>(real server NOT called)"]
    Intercept --> Handler["Route handler executes:<br/>delays, modifies, or blocks"]
    Handler --> MockResp["Returns mock response<br/>to browser"]
    MockResp --> UI["UI renders based<br/>on mock data"]
    UI --> Assert["Assertions on UI state<br/>(loading spinner, error msg)"]
 
    style Route fill:#0173B2,color:#fff
    style Navigate fill:#DE8F05,color:#000
    style BrowserReq fill:#CA9161,color:#fff
    style Intercept fill:#CC78BC,color:#000
    style Handler fill:#029E73,color:#fff
    style MockResp fill:#029E73,color:#fff
    style UI fill:#DE8F05,color:#000
    style Assert fill:#0173B2,color:#fff
import { test, expect } from "@playwright/test";
// => Import test runner and expect
 
test("mocks API to simulate slow response", async ({ page }) => {
  // => Test loading state UI with mocked slow API
  await page.route("**/api/products", async (route) => {
    // => page.route intercepts matching network requests
    // => "**" glob matches any domain prefix
    await new Promise((resolve) => setTimeout(resolve, 3000));
    // => Delays response by 3 seconds (simulates slow network)
    // => Frontend should show loading state during this time
 
    await route.fulfill({
      // => route.fulfill: send mock HTTP response
      status: 200,
      // => HTTP 200 OK
      contentType: "application/json",
      // => Sets Content-Type header for JSON
      body: JSON.stringify({
        // => JSON.stringify converts object to string
        products: [
          // => Array of mock product objects
          { id: 1, name: "Laptop", price: 999 },
          // => Mock product 1: Laptop at $999
          { id: 2, name: "Mouse", price: 29 },
          // => Mock product 2: Mouse at $29
        ],
        // => Array of product objects
      }),
      // => body: JSON string for response
    });
    // => Route fulfilled with mock data
  });
  // => Route handler registered (not called yet)
 
  await page.goto("https://example.com/shop");
  // => Navigation triggers XHR to /api/products
  // => Intercepted: response delayed 3 seconds
 
  await expect(page.getByText("Loading...")).toBeVisible();
  // => Asserts loading indicator appears during 3s delay
  // => Validates UI shows loading state correctly
 
  await expect(page.getByText("Laptop")).toBeVisible({ timeout: 5000 });
  // => Waits up to 5s for mocked "Laptop" to appear
  // => Mock response rendered by frontend
});
 
test("mocks API to simulate error response", async ({ page }) => {
  // => Test error handling UI with mocked API failure
  // => Different test: same route, different mock response
  await page.route("**/api/products", async (route) => {
    // => Intercepts products API requests
    await route.fulfill({
      status: 500,
      // => HTTP 500 Internal Server Error
      contentType: "application/json",
      body: JSON.stringify({
        error: "Internal server error",
        // => Error details in response body
      }),
    });
  });
  // => All /api/products requests return 500
 
  await page.goto("https://example.com/shop");
  // => Page loads, API call returns mocked 500 error
 
  await expect(page.getByText("Failed to load products. Please try again.")).toBeVisible();
  // => Asserts user-facing error message displayed
  // => Frontend gracefully handles 500 response
});

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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Extend["base.extend({ authenticatedRequest })"] --> FixSetup["Fixture Setup:<br/>POST /auth/login → get token"]
    FixSetup --> ConfigReq["Configure request context<br/>with extraHTTPHeaders"]
    ConfigReq --> Use["await use(authenticatedRequest)<br/>(test body runs here)"]
    Use --> Test1["test 1: GET /orders<br/>(token auto-included)"]
    Use --> Test2["test 2: POST /orders<br/>(token auto-included)"]
    Test1 --> Cleanup["Fixture Teardown:<br/>POST /auth/logout"]
    Test2 --> Cleanup
 
    style Extend fill:#0173B2,color:#fff
    style FixSetup fill:#DE8F05,color:#000
    style ConfigReq fill:#CC78BC,color:#000
    style Use fill:#CA9161,color:#fff
    style Test1 fill:#029E73,color:#fff
    style Test2 fill:#029E73,color:#fff
    style Cleanup fill:#DE8F05,color:#000
import { test as base, expect } from "@playwright/test";
// => Import base test for extension, expect for assertions
 
// Extend base test with API auth fixture
// => base.extend creates a new test function with custom fixtures
const test = base.extend<{ authenticatedRequest: APIRequestContext }>({
  // => Type parameter: defines shape of custom fixtures object
  authenticatedRequest: async ({ request }, use) => {
    // => Fixture function: setup → use → teardown pattern
    // => request: built-in Playwright API request context
    const loginResponse = await request.post("https://api.example.com/auth/login", {
      // => POST to auth endpoint
      data: {
        // => Login credentials for obtaining auth token
        email: "test@example.com",
        // => Fixture test account email
        password: "TestPass123!",
        // => Fixture test account password
      },
    });
    // => loginResponse: HTTP 200 with token in body
 
    const { token } = await loginResponse.json();
    // => Destructures token from login response
    // => token: "eyJhbGci..." (JWT string)
 
    const authenticatedRequest = request;
    // => Reuses existing request context reference
    await authenticatedRequest.use({
      // => .use() configures request context defaults
      extraHTTPHeaders: {
        // => extraHTTPHeaders: added to every request automatically
        Authorization: `Bearer ${token}`,
        // => All subsequent requests include this header
        // => No need to add manually in each test
      },
    });
    // => Request context now pre-configured with auth
 
    await use(authenticatedRequest);
    // => Yields authenticated request to test body
    // => Test runs between use() call and completion
 
    // Cleanup after test
    await request.post("https://api.example.com/auth/logout");
    // => Invalidates server session after test completes
    // => Cleanup runs even if test fails
  },
});
 
test("fetches user orders with auth fixture", async ({ authenticatedRequest }) => {
  // => Fixture injected: authenticatedRequest has token pre-configured
  const response = await authenticatedRequest.get("https://api.example.com/orders");
  // => GET /orders with Authorization header automatically included
  // => Token added by fixture - no manual handling needed
 
  expect(response.ok()).toBeTruthy();
  // => Asserts 200 status: authenticated request succeeded
  const orders = await response.json();
  // => Parses orders array from response
  expect(orders.length).toBeGreaterThan(0);
  // => Validates at least one order returned
});
 
test("creates new order with auth fixture", async ({ authenticatedRequest }) => {
  // => Second test reusing same auth fixture - zero login duplication
  const newOrder = {
    productId: 123,
    // => Product to order
    quantity: 2,
    // => How many units
  };
 
  const response = await authenticatedRequest.post("https://api.example.com/orders", {
    data: newOrder,
    // => Request body with order data
  });
  // => POST /orders with auth token auto-included
 
  expect(response.status()).toBe(201);
  // => Asserts HTTP 201 Created: order successfully 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 test: UI interaction + API validation
  // => page: browser page, request: API client (both fixtures)
  await page.goto("https://example.com/products/new");
  // => Navigates to product creation form
 
  await page.getByLabel("Product Name").fill("Wireless Keyboard");
  // => Fills product name via UI (user perspective)
  await page.getByLabel("Price").fill("79.99");
  // => Fills price field
  await page.getByLabel("Category").selectOption("Electronics");
  // => Selects category from dropdown
 
  const responsePromise = page.waitForResponse(
    (response) => response.url().includes("/api/products") && response.status() === 201,
  );
  // => Sets up intercept BEFORE click (avoids race condition)
  // => Waits for specific URL + status code match
 
  await page.getByRole("button", { name: "Create Product" }).click();
  // => Triggers form submission, fires XHR to /api/products
 
  const response = await responsePromise;
  // => Resolves when intercepted response arrives
  const createdProduct = await response.json();
  // => Parses product data from API response
 
  expect(createdProduct.name).toBe("Wireless Keyboard");
  // => Validates API stored correct product name
  expect(createdProduct.price).toBe(79.99);
  // => Validates price stored as number (not string)
 
  // Verify product appears in UI
  await expect(page.getByText("Product created successfully")).toBeVisible();
  // => Asserts success notification shown to user
 
  // Verify product persisted via API GET
  const fetchResponse = await request.get(`https://api.example.com/products/${createdProduct.id}`);
  // => Direct API GET to verify persistence in database
  // => Uses request fixture (no browser)
 
  const fetchedProduct = await fetchResponse.json();
  // => Gets stored product from API
  expect(fetchedProduct).toMatchObject(createdProduct);
  // => Confirms data persisted correctly
  // => Complete chain: UI → API create → Database → API read
});

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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Test["Test File"] --> PO["LoginPage<br/>(Page Object)"]
    PO --> Locators["Locators (getters):<br/>usernameInput<br/>passwordInput<br/>submitButton<br/>errorMessage"]
    PO --> Actions["Action Methods:<br/>navigate()<br/>login(user, pass)"]
    PO --> Assertions["Assertion Helpers:<br/>expectError(msg)"]
    Actions --> Page["Playwright Page API<br/>(fill, click, goto)"]
    Locators --> Page
    Assertions --> Expect["expect() assertions"]
 
    style Test fill:#0173B2,color:#fff
    style PO fill:#DE8F05,color:#000
    style Locators fill:#029E73,color:#fff
    style Actions fill:#029E73,color:#fff
    style Assertions fill:#029E73,color:#fff
    style Page fill:#CC78BC,color:#000
    style Expect fill:#CA9161,color:#fff
import { test, expect, type Page } from "@playwright/test";
// => Import test runner, assertions, and Page type
 
class LoginPage {
  // => Page Object class encapsulating login page
  readonly page: Page;
  // => readonly: page reference never changes after construction
 
  constructor(page: Page) {
    // => Constructor receives Playwright Page object
    this.page = page;
    // => Stores reference for use in methods
  }
 
  // Locators defined as getters (evaluated lazily)
  get usernameInput() {
    // => Getter creates locator on every access (lazy)
    return this.page.getByLabel("Username");
    // => Returns Locator (not DOM element yet)
  }
  // => usernameInput getter defined
 
  get passwordInput() {
    // => Getter for password input field
    return this.page.getByLabel("Password");
    // => Locator re-evaluated each time (handles DOM updates)
  }
  // => passwordInput getter defined
 
  get submitButton() {
    // => Getter for login submit button
    return this.page.getByRole("button", { name: "Log In" });
    // => Locates by ARIA role + accessible name
  }
  // => submitButton getter defined
 
  get errorMessage() {
    // => Getter for error alert element
    return this.page.getByRole("alert");
    // => ARIA role "alert" used for error messages
  }
  // => errorMessage getter defined
 
  // Action methods combining multiple locator interactions
  async navigate() {
    // => Encapsulates login URL - single place to update
    await this.page.goto("https://example.com/login");
    // => Navigates to login page
  }
  // => navigate() method: URL encapsulated
 
  async login(username: string, password: string) {
    // => High-level login action: three steps as one method
    await this.usernameInput.fill(username);
    // => Fills username using page object's locator getter
    await this.passwordInput.fill(password);
    // => Fills password field
    await this.submitButton.click();
    // => Submits form, triggers navigation
  }
  // => login() method encapsulates 3-step login process
 
  async expectError(message: string) {
    // => Assertion method: encapsulates error verification
    await expect(this.errorMessage).toContainText(message);
    // => Asserts alert element contains expected error text
  }
  // => expectError(): assertion helper encapsulated in page object
}
// => LoginPage class complete: locators + actions + assertions
 
test("logs in successfully with page object", async ({ page }) => {
  // => Test uses page object API (high-level methods)
  const loginPage = new LoginPage(page);
  // => Creates LoginPage instance, passing Playwright page
 
  await loginPage.navigate();
  // => Navigates to login page (URL encapsulated in page object)
  await loginPage.login("testuser", "TestPass123!");
  // => Calls login method (fills fields + clicks - 3 actions in 1)
 
  await expect(page).toHaveURL(/\/dashboard/);
  // => Asserts URL changed to dashboard after successful login
  // => Test reads as user narrative: navigate → login → verify
});
 
test("shows error for invalid credentials", async ({ page }) => {
  // => Tests error path using same page object
  const loginPage = new LoginPage(page);
  // => Same page object, different scenario
 
  await loginPage.navigate();
  // => Goes to login page
  await loginPage.login("wronguser", "wrongpass");
  // => Submits invalid credentials (triggers error)
 
  await loginPage.expectError("Invalid username or password");
  // => Verifies error message via page object helper
  // => No raw locators in test - all encapsulated
});

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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Extend["test = base.extend({ loginPage, authenticatedPage })"] --> Setup["Fixture Setup Phase"]
    Setup --> LoginFix["loginPage fixture:<br/>creates LoginPage instance"]
    Setup --> AuthFix["authenticatedPage fixture:<br/>navigates + logs in"]
    LoginFix --> TestBody["Test Body Executes<br/>(await use() yields to test)"]
    AuthFix --> TestBody
    TestBody --> Teardown["Fixture Teardown Phase<br/>(runs after test, even on failure)"]
    Teardown --> Cleanup["Cleanup actions:<br/>logout, clear state, delete data"]
 
    style Extend fill:#0173B2,color:#fff
    style Setup fill:#DE8F05,color:#000
    style LoginFix fill:#029E73,color:#fff
    style AuthFix fill:#029E73,color:#fff
    style TestBody fill:#CC78BC,color:#000
    style Teardown fill:#CA9161,color:#fff
    style Cleanup fill:#CA9161,color:#fff
import { test as base, expect, Page } from "@playwright/test";
// => Import base test for extending, expect, and Page type
 
// LoginPage class defined inline (self-contained example)
class LoginPage {
  // => Simple LoginPage page object for fixture use
  readonly page: Page;
  // => readonly: page reference cannot be reassigned
 
  constructor(page: Page) {
    // => Receives Playwright Page from fixture
    this.page = page;
    // => Stores page reference for method use
  }
 
  async goto(): Promise<void> {
    // => Navigates to login URL
    await this.page.goto("https://example.com/login");
    // => Encapsulates login URL in page object
  }
  // => goto() method: one-line navigation action
 
  async login(username: string, password: string): Promise<void> {
    // => Performs complete login sequence
    await this.page.getByLabel("Username").fill(username);
    // => Fills username field with provided value
    await this.page.getByLabel("Password").fill(password);
    // => Fills password field
    await this.page.getByRole("button", { name: "Log In" }).click();
    // => Clicks submit button
    await this.page.waitForURL(/\/dashboard/);
    // => Awaits redirect to dashboard (post-login)
  }
  // => login() method complete: fills, clicks, waits
}
// => LoginPage class complete
 
type CustomFixtures = {
  // => TypeScript type for fixture parameter injection
  loginPage: LoginPage;
  // => LoginPage page object (injected as fixture)
  authenticatedPage: Page;
  // => Playwright Page already logged in
};
// => CustomFixtures: shape of extended test's injected params
 
const test = base.extend<CustomFixtures>({
  // => Creates new test function with custom fixtures
  loginPage: async ({ page }, use) => {
    // => Setup: create LoginPage instance
    const loginPage = new LoginPage(page);
    // => Constructs page object with test's page
    await use(loginPage);
    // => Yields loginPage to test body
    // => Cleanup: none needed (page auto-closed)
  },
 
  authenticatedPage: async ({ page }, use) => {
    // => Setup: perform login before test
    await page.goto("https://example.com/login");
    // => Navigates to login page
    await page.getByLabel("Username").fill("testuser");
    // => Fills username
    await page.getByLabel("Password").fill("TestPass123!");
    // => Fills password
    await page.getByRole("button", { name: "Log In" }).click();
    // => Submits login form
 
    await page.waitForURL(/\/dashboard/);
    // => Waits for redirect to dashboard
    // => Page is now authenticated and ready
 
    await use(page);
    // => Yields authenticated page to test
 
    // Cleanup: logout after test completes
    await page.goto("https://example.com/logout");
    // => Invalidates session (cleanup runs after test)
  },
});
 
test("navigates to settings from dashboard", async ({ authenticatedPage }) => {
  // => authenticatedPage fixture: already logged in (no boilerplate)
 
  await authenticatedPage.getByRole("link", { name: "Settings" }).click();
  // => Clicks Settings link from authenticated dashboard
  // => Test logic is all that's here - no setup code
 
  await expect(authenticatedPage).toHaveURL(/\/settings/);
  // => Asserts URL matches settings path
});
 
test("creates new project from dashboard", async ({ authenticatedPage }) => {
  // => Reuses authenticatedPage fixture (auth repeated automatically)
  await authenticatedPage.getByRole("button", { name: "New Project" }).click();
  // => Clicks New Project from dashboard
  // => Zero login duplication between tests
});

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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Suite["test.describe('Shopping cart tests')"] --> BeforeAll["test.beforeAll()<br/>Runs ONCE: create test product via API"]
    BeforeAll --> BeforeEach1["test.beforeEach()<br/>Before test 1: goto homepage, clear localStorage"]
    BeforeEach1 --> T1["test('adds product to cart')"]
    T1 --> AfterEach1["test.afterEach()<br/>After test 1: clear localStorage"]
    AfterEach1 --> BeforeEach2["test.beforeEach()<br/>Before test 2: goto homepage, clear localStorage"]
    BeforeEach2 --> T2["test('removes product from cart')"]
    T2 --> AfterEach2["test.afterEach()<br/>After test 2: clear localStorage"]
    AfterEach2 --> AfterAll["test.afterAll()<br/>Runs ONCE: delete test product via API"]
 
    style Suite fill:#0173B2,color:#fff
    style BeforeAll fill:#DE8F05,color:#000
    style BeforeEach1 fill:#CC78BC,color:#000
    style BeforeEach2 fill:#CC78BC,color:#000
    style T1 fill:#029E73,color:#fff
    style T2 fill:#029E73,color:#fff
    style AfterEach1 fill:#CA9161,color:#fff
    style AfterEach2 fill:#CA9161,color:#fff
    style AfterAll fill:#DE8F05,color:#000
import { test, expect } from "@playwright/test";
// => Import test and expect from Playwright
 
test.describe("Shopping cart tests", () => {
  // => Groups all shopping cart tests in one suite
  let testProductId: string;
  // => Shared variable accessible across all hooks and tests
  // => Initialized in beforeAll, used in test bodies
 
  test.beforeAll(async ({ request }) => {
    // => beforeAll: runs ONCE before any test in suite starts
    // => request: API fixture available in beforeAll
    const response = await request.post("https://api.example.com/test/products", {
      data: {
        // => Creates test product via API (test data)
        name: "Test Product",
        // => Product name for all tests in suite
        price: 99.99,
        // => Product price
      },
    });
    // => POST creates product, returns 201 Created
    // => POST /test/products returns 201 Created
 
    const product = await response.json();
    // => Parses created product with server-assigned ID
    testProductId = product.id;
    // => Stores ID in shared variable for all tests
    // => All tests in suite can access testProductId
  });
 
  test.beforeEach(async ({ page }) => {
    // => beforeEach: runs before EVERY individual test
    await page.goto("https://example.com");
    // => Ensures each test starts from homepage
    // => Prevents URL state leakage between tests
 
    await page.evaluate(() => localStorage.clear());
    // => Clears cart data stored in localStorage
    // => Each test starts with empty cart
  });
  // => beforeEach registered: runs before each test automatically
 
  test("adds product to cart", async ({ page }) => {
    // => Test body: beforeAll and beforeEach already ran
    await page.goto(`https://example.com/products/${testProductId}`);
    // => Navigates to test product page using shared ID
    await page.getByRole("button", { name: "Add to Cart" }).click();
    // => Adds product to cart via UI button
 
    await expect(page.getByText("1 item in cart")).toBeVisible();
    // => Asserts cart counter updated to 1
  });
 
  test("removes product from cart", async ({ page }) => {
    // => beforeEach ran: fresh start from homepage, empty cart
    await page.goto(`https://example.com/products/${testProductId}`);
    // => Navigates to same test product
    await page.getByRole("button", { name: "Add to Cart" }).click();
    // => Adds to cart first (prerequisite for remove test)
 
    await page.getByRole("link", { name: "Cart" }).click();
    // => Navigates to cart page
    await page.getByRole("button", { name: "Remove" }).click();
    // => Removes the added item
 
    await expect(page.getByText("Cart is empty")).toBeVisible();
    // => Asserts cart shows empty state after removal
  });
 
  test.afterEach(async ({ page }) => {
    // => afterEach: runs after EVERY test (even if test fails)
    await page.evaluate(() => localStorage.clear());
    // => Ensures cart state cleared after each test
    // => Guards against incomplete test runs leaving state
  });
 
  test.afterAll(async ({ request }) => {
    // => afterAll: runs ONCE after all tests complete
    await request.delete(`https://api.example.com/test/products/${testProductId}`);
    // => Removes test product from database
    // => Prevents test data accumulation across runs
  });
});

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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Annotations["Test Annotations"] --> Skip["test.skip(condition)<br/>Skip conditionally based on env/flag"]
    Annotations --> Slow["test.slow()<br/>Triple the default timeout"]
    Annotations --> Fail["test.fail()<br/>Mark expected failure (known bug)"]
    Annotations --> Info["test.info().annotations<br/>Add custom { type, description } metadata"]
    Skip --> CI["Example: skip on CI<br/>skip(!process.env.CI, 'Local only')"]
    Slow --> Timeout["Example: known slow operation<br/>timeout multiplied by 3"]
    Info --> Report["Metadata appears in<br/>HTML test report"]
 
    style Annotations fill:#0173B2,color:#fff
    style Skip fill:#DE8F05,color:#000
    style Slow fill:#CC78BC,color:#000
    style Fail fill:#CA9161,color:#fff
    style Info fill:#029E73,color:#fff
    style CI fill:#DE8F05,color:#000
    style Timeout fill:#CC78BC,color:#000
    style Report fill:#029E73,color:#fff
import { test, expect } from "@playwright/test";
// => Import test and expect from Playwright
 
test("basic login test", async ({ page }) => {
  // => Standard test: no special annotations
  await page.goto("https://example.com/login");
  // => Runs with default timeout (30s) and no retries
});
 
test("slow database migration test", async ({ page }) => {
  // => Test with slow annotation for extended timeout
  test.slow();
  // => test.slow() triples the default test timeout
  // => 30s default → 90s with test.slow()
  // => Use for known slow operations (migrations, reports)
 
  await page.goto("https://example.com/admin/migrations");
  // => Navigates to migration admin page
  await page.getByRole("button", { name: "Run Migration" }).click();
  // => Triggers long-running database migration
});
 
test("mobile-only responsive test", async ({ page, isMobile }) => {
  // => Test with conditional skip based on fixture value
  test.skip(!isMobile, "This test is only for mobile viewports");
  // => isMobile: true when running with mobile project config
  // => Skips test if !isMobile (desktop run)
  // => Avoids false failures on desktop viewport
 
  await page.goto("https://example.com");
  // => Only runs when isMobile is true
  await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
  // => Hamburger menu only visible on mobile
});
 
test("flaky API integration test", async ({ page }) => {
  // => Test marked with fixme (known broken)
  test.fixme(true, "Known flaky test - API rate limiting issue");
  // => fixme(condition, reason): marks test as expected to fail
  // => Test is skipped with "fixme" status in report
  // => Documents known issues without deleting tests
 
  // Test would run here if fixme removed
});
 
test("payment processing test", async ({ page }) => {
  // => Test with custom metadata annotations
  test.info().annotations.push({
    type: "issue",
    // => Annotation type: links to issue tracker
    description: "https://github.com/org/repo/issues/123",
    // => URL to related GitHub issue
  });
  // => First annotation added to test metadata
 
  test.info().annotations.push({
    type: "category",
    // => Custom annotation type for filtering
    description: "payment",
    // => Category label for selective test execution
  });
  // => Annotations visible in HTML reporter
 
  await page.goto("https://example.com/checkout");
  // => Test executes normally with metadata attached
});
 
test.describe("WebKit-specific tests", () => {
  // => Suite conditionally skipped for non-WebKit browsers
  test.skip(({ browserName }) => browserName !== "webkit", "WebKit only");
  // => Arrow function receives fixtures: ({ browserName })
  // => Evaluates per-test: skip when browser is not webkit
  // => Entire suite skipped on Chromium/Firefox runs
 
  test("Safari-specific CSS rendering", async ({ page }) => {
    // => Only executes when browserName === "webkit"
    await page.goto("https://example.com");
    // => Tests Safari-specific rendering behavior
  });
});

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.

%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
stateDiagram-v2
    [*] --> Running: Test starts (attempt 1)
    Running --> Passed: Test assertions all pass
    Running --> Failed: Assertion or timeout fails
    Failed --> Retrying: retries > 0 remaining
    Failed --> FinalFail: No retries left
    Retrying --> Running: New attempt (fresh browser context)
    Passed --> [*]: Report: PASS
    FinalFail --> [*]: Report: FAIL (after N+1 attempts)
 
    note right of Retrying
        Each retry gets a
        fresh browser context.
        Retry count shown in report.
    end note
import { test, expect } from "@playwright/test";
 
test.describe("Tests with custom retry logic", () => {
  // => Suite with configured retry behavior
  test.describe.configure({ retries: 2 });
  // => retries: 2 means: run test, if fails, retry up to 2 more times
  // => Total attempts: 3 (1 original + 2 retries)
  // => Scoped to this suite only
 
  test("flaky network-dependent test", async ({ page }) => {
    // => Test may fail transiently due to external API availability
    await page.goto("https://example.com/api-dashboard");
    // => Loads dashboard that polls external API
    // => Network hiccups may cause intermittent failures
 
    await expect(page.getByText("API Status: Online")).toBeVisible({
      timeout: 10000,
      // => Overrides default 5s assertion timeout to 10s
    });
    // => Waits up to 10s for API status to appear
    // => Handles slow API polling intervals
  });
});
 
test("critical test - no retries", async ({ page }) => {
  // => Important test: should fail fast without masking
  test.describe.configure({ retries: 0 });
  // => Overrides any global retry config
  // => Fail immediately to surface critical issues fast
 
  await page.goto("https://example.com/health");
  // => Health check endpoint
  await expect(page.getByText("System Healthy")).toBeVisible();
  // => If fails: system is down, not flaky - no retry needed
});
 
test("slow e2e test with extended timeout", async ({ page }) => {
  // => Test for long-running operation needing custom timeout
  test.setTimeout(120000);
  // => Sets test timeout to 120,000ms (2 minutes)
  // => Overrides global default (30s) for this test only
 
  await page.goto("https://example.com/report/generate");
  // => Navigates to report generation page
  await page.getByRole("button", { name: "Generate Annual Report" }).click();
  // => Triggers report generation (may take 60+ seconds)
 
  await expect(page.getByText("Report Ready")).toBeVisible({ timeout: 90000 });
  // => Assertion timeout: 90s (within 120s test timeout)
  // => Assertion timeout must be less than test timeout
});
 
test("dynamic timeout based on environment", async ({ page }) => {
  // => Adapts timeout to execution environment
  const timeout = process.env.CI ? 60000 : 30000;
  // => CI=true: 60s (CI servers slower than developer machines)
  // => CI undefined: 30s (faster on local hardware)
 
  test.setTimeout(timeout);
  // => Applies computed timeout for this test
 
  await page.goto("https://example.com/dashboard");
  // => Navigates to dashboard
  await expect(page.getByText("Dashboard Loaded")).toBeVisible({
    timeout: timeout / 2,
    // => Assertion timeout: half of test timeout (proportional)
  });
  // => Leaves buffer: assertion timeout < test 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 January 31, 2026

Command Palette

Search for a command to run...