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
requestfixture - Route mocking with
page.route()for controlled scenarios - Accessibility testing with axe-playwright
- Mobile viewport configuration via device presets
Deep Dive
How It Works
storageStatesaves and restores cookies and localStorage, so authenticated tests do not repeat login- The setup project runs before dependent projects -- Playwright respects the
dependenciesarray page.route()intercepts network requests matching a URL pattern and lets you fulfill, abort, or modify themrequestis a Playwright fixture for making HTTP requests directly -- useful for API testing without a browser pageAxeBuilderruns 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
-
storageStatefile in git -- The auth state file contains session tokens. Fix: Adde2e/.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.routemust be called before navigation -- Routes set afterpage.goto()do not intercept the initial page load requests. Fix: Callpage.route()beforepage.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
requestfixture does not usestorageStateby default. Fix: Userequest.newContext({ storageState: "e2e/.auth/user.json" })or set it in the project config.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Cypress intercept | You use Cypress and need route mocking | You use Playwright |
| Lighthouse CI | You need performance and a11y audits in CI | You want granular a11y violation assertions |
| jest-axe | You want a11y testing in unit tests (jsdom) | You need real browser rendering for accurate results |
| Supertest | You want API integration tests without a browser | You 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.
Related
- Playwright E2E Setup -- installation and basic configuration
- Playwright E2E Patterns -- Page Object Model and locators
- Testing Strategy & Best Practices -- test pyramid and CI pipeline
- Mocking in Tests -- unit test mocking patterns