React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

playwrightauthenticationapi-testingmockingmobileaccessibilityaxe

Advanced Playwright

Handle authentication, API testing, route mocking, multi-tab scenarios, mobile viewports, and accessibility audits.

Recipe

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

import { test, expect } from "@playwright/test";
 
// Shared authentication state
test.use({ storageState: "e2e/.auth/user.json" });
 
// API testing
const response = await request.get("/api/users");
expect(response.ok()).toBe(true);
 
// Mock API routes
await page.route("/api/products", (route) => {
  route.fulfill({
    status: 200,
    contentType: "application/json",
    body: JSON.stringify([{ id: 1, name: "Widget" }]),
  });
});
 
// Mobile viewport
test.use({ ...devices["iPhone 14"] });
 
// Accessibility testing
import AxeBuilder from "@axe-core/playwright";
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);

When to reach for this: When your E2E tests need to handle login sessions, test APIs directly, mock network responses, or verify accessibility.

Working Example

// e2e/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
 
const authFile = "e2e/.auth/user.json";
 
setup("authenticate", async ({ page }) => {
  // Navigate to login page
  await page.goto("/login");
 
  // Fill in credentials
  await page.getByLabel("Email").fill("test@example.com");
  await page.getByLabel("Password").fill("password123");
  await page.getByRole("button", { name: /sign in/i }).click();
 
  // Wait for redirect to dashboard
  await expect(page).toHaveURL("/dashboard");
 
  // Save authentication state (cookies, localStorage)
  await page.context().storageState({ path: authFile });
});
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
 
export default defineConfig({
  projects: [
    // Setup project runs first to authenticate
    {
      name: "setup",
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: "e2e/.auth/user.json",
      },
      dependencies: ["setup"],
    },
    {
      name: "mobile",
      use: {
        ...devices["iPhone 14"],
        storageState: "e2e/.auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
});
// e2e/dashboard.spec.ts
import { test, expect } from "@playwright/test";
 
// All tests in this file are authenticated (via project config)
test.describe("Authenticated Dashboard", () => {
  test("shows user name in header", async ({ page }) => {
    await page.goto("/dashboard");
    await expect(page.getByTestId("user-menu")).toContainText("test@example.com");
  });
 
  test("displays recent orders", async ({ page }) => {
    await page.goto("/dashboard");
    await expect(page.getByRole("table")).toBeVisible();
    const rows = page.getByRole("row");
    await expect(rows).toHaveCount(6); // 1 header + 5 data rows
  });
 
  test("can update profile", async ({ page }) => {
    await page.goto("/settings/profile");
    await page.getByLabel("Display name").fill("Test User Updated");
    await page.getByRole("button", { name: /save/i }).click();
 
    await expect(page.getByRole("status")).toContainText("Profile updated");
  });
});
// e2e/api.spec.ts
import { test, expect } from "@playwright/test";
 
test.describe("API Tests", () => {
  test("GET /api/products returns products", async ({ request }) => {
    const response = await request.get("/api/products");
 
    expect(response.ok()).toBe(true);
    const products = await response.json();
    expect(products.length).toBeGreaterThan(0);
    expect(products[0]).toHaveProperty("id");
    expect(products[0]).toHaveProperty("name");
    expect(products[0]).toHaveProperty("price");
  });
 
  test("POST /api/products creates a product", async ({ request }) => {
    const response = await request.post("/api/products", {
      data: {
        name: "New Widget",
        price: 29.99,
      },
    });
 
    expect(response.status()).toBe(201);
    const product = await response.json();
    expect(product.name).toBe("New Widget");
  });
 
  test("GET /api/products/:id returns 404 for missing product", async ({ request }) => {
    const response = await request.get("/api/products/nonexistent");
    expect(response.status()).toBe(404);
  });
});
// e2e/mocked.spec.ts
import { test, expect } from "@playwright/test";
 
test.describe("With Mocked API", () => {
  test("shows empty state when no products", async ({ page }) => {
    // Mock the API to return empty array
    await page.route("/api/products", (route) => {
      route.fulfill({
        status: 200,
        contentType: "application/json",
        body: JSON.stringify([]),
      });
    });
 
    await page.goto("/products");
    await expect(page.getByText(/no products found/i)).toBeVisible();
  });
 
  test("handles API error gracefully", async ({ page }) => {
    await page.route("/api/products", (route) => {
      route.fulfill({
        status: 500,
        contentType: "application/json",
        body: JSON.stringify({ error: "Internal Server Error" }),
      });
    });
 
    await page.goto("/products");
    await expect(page.getByRole("alert")).toContainText(/error/i);
  });
 
  test("modifies API response", async ({ page }) => {
    await page.route("/api/products", async (route) => {
      // Fetch real response and modify it
      const response = await route.fetch();
      const json = await response.json();
      const modified = json.map((p: { price: number }) => ({
        ...p,
        price: 0, // everything free!
      }));
      await route.fulfill({ response, body: JSON.stringify(modified) });
    });
 
    await page.goto("/products");
    await expect(page.getByText("$0.00").first()).toBeVisible();
  });
});
// e2e/accessibility.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
 
test.describe("Accessibility", () => {
  test("home page has no a11y violations", async ({ page }) => {
    await page.goto("/");
 
    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa"])
      .analyze();
 
    expect(results.violations).toEqual([]);
  });
 
  test("form page has no a11y violations", async ({ page }) => {
    await page.goto("/contact");
 
    const results = await new AxeBuilder({ page })
      .include("#contact-form")
      .analyze();
 
    expect(results.violations).toEqual([]);
  });
 
  test("reports specific violations", async ({ page }) => {
    await page.goto("/products");
 
    const results = await new AxeBuilder({ page }).analyze();
 
    // Log violations for debugging
    for (const violation of results.violations) {
      console.log(`${violation.id}: ${violation.description}`);
      for (const node of violation.nodes) {
        console.log(`  ${node.html}`);
      }
    }
 
    expect(results.violations).toEqual([]);
  });
});

What this demonstrates:

  • Authentication setup project that runs once and saves state
  • All other projects depend on setup and reuse the auth state
  • API testing with Playwright's request fixture
  • Route mocking with page.route() for controlled scenarios
  • Accessibility testing with axe-playwright
  • Mobile viewport configuration via device presets

Deep Dive

How It Works

  • storageState saves and restores cookies and localStorage, so authenticated tests do not repeat login
  • The setup project runs before dependent projects -- Playwright respects the dependencies array
  • page.route() intercepts network requests matching a URL pattern and lets you fulfill, abort, or modify them
  • request is a Playwright fixture for making HTTP requests directly -- useful for API testing without a browser page
  • AxeBuilder runs the axe-core accessibility engine inside the page and returns violations with detailed metadata

Variations

Testing across multiple tabs:

test("opens product in new tab", async ({ page, context }) => {
  await page.goto("/products");
 
  // Listen for new page (tab)
  const newPagePromise = context.waitForEvent("page");
  await page.getByRole("link", { name: /widget/i }).click({
    modifiers: ["Meta"], // Cmd+Click opens new tab
  });
  const newPage = await newPagePromise;
 
  await newPage.waitForLoadState();
  await expect(newPage).toHaveURL(/\/products\/widget/);
  await expect(newPage.getByRole("heading")).toHaveText("Widget");
});

Testing with different viewports:

test.describe("mobile navigation", () => {
  test.use({
    viewport: { width: 375, height: 667 },
    isMobile: true,
    hasTouch: true,
  });
 
  test("shows hamburger menu", async ({ page }) => {
    await page.goto("/");
    await expect(page.getByRole("button", { name: /menu/i })).toBeVisible();
    await expect(page.getByRole("navigation")).not.toBeVisible();
  });
});

Multiple authentication roles:

// playwright.config.ts
projects: [
  { name: "admin-setup", testMatch: /admin\.setup\.ts/ },
  { name: "user-setup", testMatch: /user\.setup\.ts/ },
  {
    name: "admin-tests",
    use: { storageState: "e2e/.auth/admin.json" },
    dependencies: ["admin-setup"],
    testMatch: /.*admin.*\.spec\.ts/,
  },
  {
    name: "user-tests",
    use: { storageState: "e2e/.auth/user.json" },
    dependencies: ["user-setup"],
    testMatch: /.*user.*\.spec\.ts/,
  },
],

TypeScript Notes

// Typed route handler
await page.route("/api/users", async (route: Route) => {
  const method = route.request().method();
  if (method === "GET") {
    await route.fulfill({ body: JSON.stringify([]) });
  } else {
    await route.continue();
  }
});
 
// Typed API response
interface Product {
  id: number;
  name: string;
  price: number;
}
 
const response = await request.get("/api/products");
const products: Product[] = await response.json();

Gotchas

  • storageState file in git -- The auth state file contains session tokens. Fix: Add e2e/.auth/ to .gitignore.

  • Auth state expiring -- If sessions expire quickly, the setup may need to run more frequently. Fix: Use long-lived test tokens or run setup before each test file with test.beforeEach.

  • page.route must be called before navigation -- Routes set after page.goto() do not intercept the initial page load requests. Fix: Call page.route() before page.goto().

  • axe-core false positives -- Some violations may be intentional (e.g., color contrast on branded elements). Fix: Use .exclude() for known exceptions or .disableRules() for specific rules:

    new AxeBuilder(\{ page \})
      .exclude("#brand-banner")
      .disableRules(["color-contrast"])
      .analyze();
  • API tests and authentication -- The request fixture does not use storageState by default. Fix: Use request.newContext({ storageState: "e2e/.auth/user.json" }) or set it in the project config.

Alternatives

AlternativeUse WhenDon't Use When
Cypress interceptYou use Cypress and need route mockingYou use Playwright
Lighthouse CIYou need performance and a11y audits in CIYou want granular a11y violation assertions
jest-axeYou want a11y testing in unit tests (jsdom)You need real browser rendering for accurate results
SupertestYou want API integration tests without a browserYou need browser context (cookies, auth)

FAQs

How does Playwright authentication state sharing work?
  • A setup project logs in once and saves cookies/localStorage to a JSON file via storageState.
  • Other projects declare a dependency on setup and load that file.
  • Tests run already authenticated without repeating login.
Gotcha: Should the storageState auth file be committed to git?

No. It contains session tokens. Add e2e/.auth/ to .gitignore.

How do I mock API responses in Playwright?
await page.route("/api/products", (route) => {
  route.fulfill({
    status: 200,
    contentType: "application/json",
    body: JSON.stringify([{ id: 1, name: "Widget" }]),
  });
});

Call page.route() before page.goto().

What is the request fixture used for?

It makes HTTP requests directly without a browser page -- useful for API testing:

const response = await request.get("/api/products");
expect(response.ok()).toBe(true);
How do I run accessibility audits with Playwright?

Install @axe-core/playwright and use:

const results = await new AxeBuilder({ page })
  .withTags(["wcag2a", "wcag2aa"])
  .analyze();
expect(results.violations).toEqual([]);
How do I test with multiple authentication roles (admin vs user)?

Create separate setup projects and storage state files for each role. Use testMatch patterns to route tests to the correct project:

{ name: "admin-tests", use: { storageState: "e2e/.auth/admin.json" } }
Gotcha: Why must page.route() be called before page.goto()?

Routes set after navigation do not intercept the initial page load requests. Always register route mocks before navigating.

How do I test a multi-tab scenario?
const newPagePromise = context.waitForEvent("page");
await page.getByRole("link").click({ modifiers: ["Meta"] });
const newPage = await newPagePromise;
await newPage.waitForLoadState();
How do I test mobile viewports?
test.use({
  viewport: { width: 375, height: 667 },
  isMobile: true,
  hasTouch: true,
});

Or use device presets: test.use({ ...devices["iPhone 14"] }).

How do I handle axe-core false positives?

Exclude specific elements or disable rules:

new AxeBuilder({ page })
  .exclude("#brand-banner")
  .disableRules(["color-contrast"])
  .analyze();
How do I type route handlers in TypeScript?
await page.route("/api/users", async (route: Route) => {
  const method = route.request().method();
  if (method === "GET") {
    await route.fulfill({ body: JSON.stringify([]) });
  } else {
    await route.continue();
  }
});
Does the request fixture use storageState by default?

No. Use request.newContext({ storageState: "e2e/.auth/user.json" }) or configure it in the project settings.