# Network Mocking

> **When to use**: Isolating your frontend from external services, simulating error states, testing loading/empty/error UI, speeding up tests by avoiding real network calls, and testing against APIs that don't exist yet.
> **Prerequisites**: [core/locators.md](locators.md), [core/assertions-and-waiting.md](assertions-and-waiting.md)

## Quick Reference

```typescript
// Intercept and return fake data
await page.route("**/api/users", (route) => route.fulfill({ json: [{ id: 1, name: "Jane" }] }))

// Modify a real response before it reaches the browser
await page.route("**/api/users", async (route) => {
  const response = await route.fetch()
  const json = await response.json()
  json.push({ id: 999, name: "Injected" })
  await route.fulfill({ response, json })
})

// Block third-party scripts
await page.route("**/*.{png,jpg,svg}", (route) => route.abort())

// Wait for a specific request/response
const responsePromise = page.waitForResponse("**/api/users")
await page.getByRole("button", { name: "Load" }).click()
await responsePromise

// HAR replay — serve recorded responses
await page.routeFromHAR("tests/data/api.har", { url: "**/api/**" })
```

## Patterns

### Route Interception Basics

**Use when**: You need to intercept any HTTP request made by the page to fulfill, modify, or block it.
**Avoid when**: You want to test the real integration between frontend and backend (use real API calls instead).

`page.route()` registers a handler that runs for every request matching a URL pattern. Each handler must call exactly one of `route.fulfill()`, `route.continue()`, or `route.abort()`.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("route interception basics", async ({ page }) => {
  // Intercept before navigating — routes must be set up first
  await page.route("**/api/users", (route) => {
    route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify([{ id: 1, name: "Alice" }]),
    })
  })

  await page.goto("/dashboard")
  await expect(page.getByText("Alice")).toBeVisible()

  // Remove the route when done (important for cleanup)
  await page.unroute("**/api/users")
})

test("context-level routes apply to all pages", async ({ context, page }) => {
  // Routes on the context apply to every page in that context
  await context.route("**/api/config", (route) =>
    route.fulfill({ json: { theme: "dark", locale: "en" } })
  )

  await page.goto("/settings")
  await expect(page.getByText("Dark")).toBeVisible()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("route interception basics", async ({ page }) => {
  await page.route("**/api/users", (route) => {
    route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify([{ id: 1, name: "Alice" }]),
    })
  })

  await page.goto("/dashboard")
  await expect(page.getByText("Alice")).toBeVisible()

  await page.unroute("**/api/users")
})

test("context-level routes apply to all pages", async ({ context, page }) => {
  await context.route("**/api/config", (route) =>
    route.fulfill({ json: { theme: "dark", locale: "en" } })
  )

  await page.goto("/settings")
  await expect(page.getByText("Dark")).toBeVisible()
})
```

### Mocking REST Responses

**Use when**: Your frontend depends on a REST API and you want deterministic, instant responses with controlled data.
**Avoid when**: You need to verify that your frontend sends the correct request body or headers to the real API (use `route.continue()` with `waitForRequest` instead).

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

const mockUsers = [
  { id: 1, name: "Alice", email: "alice@example.com", role: "admin" },
  { id: 2, name: "Bob", email: "bob@example.com", role: "user" },
]

test("mock a GET endpoint with JSON", async ({ page }) => {
  await page.route("**/api/users", (route) => route.fulfill({ json: mockUsers }))

  await page.goto("/users")
  await expect(page.getByRole("row")).toHaveCount(3) // header + 2 data rows
})

test("mock a POST endpoint and verify the request", async ({ page }) => {
  await page.route("**/api/users", (route) => {
    if (route.request().method() === "POST") {
      return route.fulfill({
        status: 201,
        json: { id: 3, name: "Charlie", email: "charlie@example.com" },
      })
    }
    // Let other methods through
    return route.continue()
  })

  await page.goto("/users/new")
  await page.getByLabel("Name").fill("Charlie")
  await page.getByLabel("Email").fill("charlie@example.com")
  await page.getByRole("button", { name: "Create" }).click()

  await expect(page.getByText("Charlie")).toBeVisible()
})

test("mock with custom headers and status", async ({ page }) => {
  await page.route("**/api/users", (route) =>
    route.fulfill({
      status: 200,
      headers: {
        "content-type": "application/json",
        "x-total-count": "42",
        "x-request-id": "test-abc-123",
      },
      body: JSON.stringify(mockUsers),
    })
  )

  await page.goto("/users")
  await expect(page.getByText("42 total")).toBeVisible()
})

test("mock empty state", async ({ page }) => {
  await page.route("**/api/users", (route) => route.fulfill({ json: [] }))

  await page.goto("/users")
  await expect(page.getByText("No users found")).toBeVisible()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

const mockUsers = [
  { id: 1, name: "Alice", email: "alice@example.com", role: "admin" },
  { id: 2, name: "Bob", email: "bob@example.com", role: "user" },
]

test("mock a GET endpoint with JSON", async ({ page }) => {
  await page.route("**/api/users", (route) => route.fulfill({ json: mockUsers }))

  await page.goto("/users")
  await expect(page.getByRole("row")).toHaveCount(3)
})

test("mock a POST endpoint and verify the request", async ({ page }) => {
  await page.route("**/api/users", (route) => {
    if (route.request().method() === "POST") {
      return route.fulfill({
        status: 201,
        json: { id: 3, name: "Charlie", email: "charlie@example.com" },
      })
    }
    return route.continue()
  })

  await page.goto("/users/new")
  await page.getByLabel("Name").fill("Charlie")
  await page.getByLabel("Email").fill("charlie@example.com")
  await page.getByRole("button", { name: "Create" }).click()

  await expect(page.getByText("Charlie")).toBeVisible()
})

test("mock empty state", async ({ page }) => {
  await page.route("**/api/users", (route) => route.fulfill({ json: [] }))

  await page.goto("/users")
  await expect(page.getByText("No users found")).toBeVisible()
})
```

### Mocking GraphQL

**Use when**: Your frontend uses GraphQL and you want to mock specific queries or mutations by operation name.
**Avoid when**: The GraphQL endpoint is part of your own backend and you want full integration coverage.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("mock a GraphQL query by operation name", async ({ page }) => {
  await page.route("**/graphql", async (route) => {
    const request = route.request()
    const postData = request.postDataJSON()

    if (postData.operationName === "GetUsers") {
      return route.fulfill({
        json: {
          data: {
            users: [
              { id: "1", name: "Alice", email: "alice@example.com" },
              { id: "2", name: "Bob", email: "bob@example.com" },
            ],
          },
        },
      })
    }

    if (postData.operationName === "GetUser") {
      return route.fulfill({
        json: {
          data: {
            user: { id: "1", name: "Alice", email: "alice@example.com" },
          },
        },
      })
    }

    // Let unmocked operations through to the real server
    return route.continue()
  })

  await page.goto("/users")
  await expect(page.getByText("Alice")).toBeVisible()
  await expect(page.getByText("Bob")).toBeVisible()
})

test("mock a GraphQL mutation", async ({ page }) => {
  await page.route("**/graphql", async (route) => {
    const { operationName, variables } = route.request().postDataJSON()

    if (operationName === "CreateUser") {
      return route.fulfill({
        json: {
          data: {
            createUser: {
              id: "99",
              name: variables.input.name,
              email: variables.input.email,
            },
          },
        },
      })
    }

    return route.continue()
  })

  await page.goto("/users/new")
  await page.getByLabel("Name").fill("Charlie")
  await page.getByLabel("Email").fill("charlie@example.com")
  await page.getByRole("button", { name: "Create" }).click()

  await expect(page.getByText("Charlie")).toBeVisible()
})

test("mock GraphQL errors", async ({ page }) => {
  await page.route("**/graphql", async (route) => {
    const { operationName } = route.request().postDataJSON()

    if (operationName === "GetUsers") {
      return route.fulfill({
        json: {
          data: null,
          errors: [
            {
              message: "Not authorized",
              extensions: { code: "UNAUTHORIZED" },
            },
          ],
        },
      })
    }

    return route.continue()
  })

  await page.goto("/users")
  await expect(page.getByText("Not authorized")).toBeVisible()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("mock a GraphQL query by operation name", async ({ page }) => {
  await page.route("**/graphql", async (route) => {
    const postData = route.request().postDataJSON()

    if (postData.operationName === "GetUsers") {
      return route.fulfill({
        json: {
          data: {
            users: [
              { id: "1", name: "Alice", email: "alice@example.com" },
              { id: "2", name: "Bob", email: "bob@example.com" },
            ],
          },
        },
      })
    }

    return route.continue()
  })

  await page.goto("/users")
  await expect(page.getByText("Alice")).toBeVisible()
  await expect(page.getByText("Bob")).toBeVisible()
})

test("mock a GraphQL mutation", async ({ page }) => {
  await page.route("**/graphql", async (route) => {
    const { operationName, variables } = route.request().postDataJSON()

    if (operationName === "CreateUser") {
      return route.fulfill({
        json: {
          data: {
            createUser: {
              id: "99",
              name: variables.input.name,
              email: variables.input.email,
            },
          },
        },
      })
    }

    return route.continue()
  })

  await page.goto("/users/new")
  await page.getByLabel("Name").fill("Charlie")
  await page.getByLabel("Email").fill("charlie@example.com")
  await page.getByRole("button", { name: "Create" }).click()

  await expect(page.getByText("Charlie")).toBeVisible()
})
```

### Modifying Responses

**Use when**: You need the real API response but want to tweak specific fields -- inject test data, override feature flags, simulate edge cases in real data.
**Avoid when**: You can fully mock the response. `route.fetch()` adds a real network round-trip, so it is slower.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("modify a real API response", async ({ page }) => {
  await page.route("**/api/users", async (route) => {
    // Fetch the real response from the server
    const response = await route.fetch()
    const users = await response.json()

    // Inject a test user into the real data
    users.push({ id: 999, name: "Test User", email: "test@example.com" })

    await route.fulfill({ response, json: users })
  })

  await page.goto("/users")
  await expect(page.getByText("Test User")).toBeVisible()
})

test("override feature flags from real config", async ({ page }) => {
  await page.route("**/api/config", async (route) => {
    const response = await route.fetch()
    const config = await response.json()

    // Enable a feature flag for testing
    config.featureFlags = {
      ...config.featureFlags,
      newCheckout: true,
      darkMode: true,
    }

    await route.fulfill({ response, json: config })
  })

  await page.goto("/settings")
  await expect(page.getByRole("switch", { name: "Dark mode" })).toBeVisible()
})

test("modify response headers", async ({ page }) => {
  await page.route("**/api/data", async (route) => {
    const response = await route.fetch()

    await route.fulfill({
      response,
      headers: {
        ...response.headers(),
        "cache-control": "no-cache",
        "x-test-header": "injected",
      },
    })
  })

  await page.goto("/data")
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("modify a real API response", async ({ page }) => {
  await page.route("**/api/users", async (route) => {
    const response = await route.fetch()
    const users = await response.json()

    users.push({ id: 999, name: "Test User", email: "test@example.com" })

    await route.fulfill({ response, json: users })
  })

  await page.goto("/users")
  await expect(page.getByText("Test User")).toBeVisible()
})

test("override feature flags from real config", async ({ page }) => {
  await page.route("**/api/config", async (route) => {
    const response = await route.fetch()
    const config = await response.json()

    config.featureFlags = {
      ...config.featureFlags,
      newCheckout: true,
      darkMode: true,
    }

    await route.fulfill({ response, json: config })
  })

  await page.goto("/settings")
  await expect(page.getByRole("switch", { name: "Dark mode" })).toBeVisible()
})
```

### Request Blocking

**Use when**: Blocking analytics, ads, third-party scripts, images, or fonts to speed up tests and eliminate flakiness from external dependencies.
**Avoid when**: The blocked resource is required for the feature under test.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("block analytics and tracking scripts", async ({ page }) => {
  await page.route(/(google-analytics|segment|hotjar|mixpanel)/, (route) => route.abort())

  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})

test("block images to speed up tests", async ({ page }) => {
  await page.route("**/*.{png,jpg,jpeg,gif,svg,webp}", (route) => route.abort())

  await page.goto("/gallery")
  // Test interactions without waiting for image downloads
  await page.getByRole("button", { name: "Next" }).click()
})

test("block specific third-party domains", async ({ page }) => {
  const blockedDomains = ["ads.example.com", "tracker.example.com", "cdn.slow-service.com"]

  await page.route("**/*", (route) => {
    const url = new URL(route.request().url())
    if (blockedDomains.includes(url.hostname)) {
      return route.abort()
    }
    return route.continue()
  })

  await page.goto("/home")
})

test("block by resource type", async ({ context }) => {
  // Context-level blocking affects all pages
  await context.route("**/*", (route) => {
    const resourceType = route.request().resourceType()
    if (["image", "font", "media"].includes(resourceType)) {
      return route.abort()
    }
    return route.continue()
  })
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("block analytics and tracking scripts", async ({ page }) => {
  await page.route(/(google-analytics|segment|hotjar|mixpanel)/, (route) => route.abort())

  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})

test("block images to speed up tests", async ({ page }) => {
  await page.route("**/*.{png,jpg,jpeg,gif,svg,webp}", (route) => route.abort())

  await page.goto("/gallery")
  await page.getByRole("button", { name: "Next" }).click()
})

test("block by resource type", async ({ context }) => {
  await context.route("**/*", (route) => {
    const resourceType = route.request().resourceType()
    if (["image", "font", "media"].includes(resourceType)) {
      return route.abort()
    }
    return route.continue()
  })
})
```

### HAR Recording and Replay

**Use when**: You want to capture real network traffic once and replay it in tests for speed and determinism. Great for complex APIs with many endpoints or when API access is limited.
**Avoid when**: API responses change frequently and stale recordings would cause false passes. Keep HAR files in version control and update them regularly.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

// Record a HAR file — run once to capture traffic, then replay
test("record HAR for later replay", async ({ page }) => {
  // This records all matching network traffic to the HAR file.
  // If the file already exists and responses match, it serves from HAR.
  // If a request has no match in the HAR, it falls through to the network
  // and the new response is appended to the HAR file.
  await page.routeFromHAR("tests/data/users-api.har", {
    url: "**/api/**",
    update: true, // set to true to record, false (or omit) to replay
  })

  await page.goto("/users")
  await expect(page.getByRole("row")).toHaveCount(6)
})

// Replay from a previously recorded HAR file
test("replay from HAR", async ({ page }) => {
  await page.routeFromHAR("tests/data/users-api.har", {
    url: "**/api/**",
    // update: false is the default — serves from the HAR file
  })

  await page.goto("/users")
  await expect(page.getByRole("row")).toHaveCount(6)
})

// HAR with notFound option — control what happens for unmatched requests
test("HAR replay with fallback behavior", async ({ page }) => {
  await page.routeFromHAR("tests/data/users-api.har", {
    url: "**/api/**",
    notFound: "abort", // 'abort' fails unmatched requests; 'fallback' lets them through
  })

  await page.goto("/users")
  await expect(page.getByText("Alice")).toBeVisible()
})

// Context-level HAR replay
test("HAR replay at context level", async ({ context, page }) => {
  await context.routeFromHAR("tests/data/full-app.har", {
    url: "**/api/**",
  })

  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("record HAR for later replay", async ({ page }) => {
  await page.routeFromHAR("tests/data/users-api.har", {
    url: "**/api/**",
    update: true,
  })

  await page.goto("/users")
  await expect(page.getByRole("row")).toHaveCount(6)
})

test("replay from HAR", async ({ page }) => {
  await page.routeFromHAR("tests/data/users-api.har", {
    url: "**/api/**",
  })

  await page.goto("/users")
  await expect(page.getByRole("row")).toHaveCount(6)
})

test("HAR replay with fallback behavior", async ({ page }) => {
  await page.routeFromHAR("tests/data/users-api.har", {
    url: "**/api/**",
    notFound: "abort",
  })

  await page.goto("/users")
  await expect(page.getByText("Alice")).toBeVisible()
})
```

### Conditional Mocking

**Use when**: You need different responses based on the request method, body, headers, or query parameters. Common for paginated APIs, search endpoints, and role-based access.
**Avoid when**: Simple static mocking suffices. Don't over-engineer route handlers.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("respond based on request method", async ({ page }) => {
  await page.route("**/api/users", (route) => {
    const method = route.request().method()

    switch (method) {
      case "GET":
        return route.fulfill({
          json: [{ id: 1, name: "Alice" }],
        })
      case "POST":
        return route.fulfill({
          status: 201,
          json: { id: 2, name: "Bob" },
        })
      case "DELETE":
        return route.fulfill({ status: 204, body: "" })
      default:
        return route.continue()
    }
  })

  await page.goto("/users")
  await expect(page.getByText("Alice")).toBeVisible()
})

test("respond based on query parameters", async ({ page }) => {
  await page.route("**/api/users*", (route) => {
    const url = new URL(route.request().url())
    const page_num = parseInt(url.searchParams.get("page") || "1")
    const role = url.searchParams.get("role")

    const allUsers = [
      { id: 1, name: "Alice", role: "admin" },
      { id: 2, name: "Bob", role: "user" },
      { id: 3, name: "Charlie", role: "user" },
      { id: 4, name: "Diana", role: "admin" },
    ]

    let filtered = allUsers
    if (role) {
      filtered = allUsers.filter((u) => u.role === role)
    }

    const perPage = 2
    const start = (page_num - 1) * perPage
    const paginated = filtered.slice(start, start + perPage)

    return route.fulfill({
      json: paginated,
      headers: {
        "content-type": "application/json",
        "x-total-count": String(filtered.length),
      },
    })
  })

  await page.goto("/users")
  await expect(page.getByRole("row")).toHaveCount(3) // header + 2 rows
})

test("respond based on request body", async ({ page }) => {
  await page.route("**/api/search", (route) => {
    const body = route.request().postDataJSON()
    const query = body?.query?.toLowerCase() || ""

    const results = {
      playwright: [{ title: "Playwright Docs" }, { title: "Playwright GitHub" }],
      cypress: [{ title: "Cypress Docs" }],
    }

    return route.fulfill({
      json: results[query] || [],
    })
  })

  await page.goto("/search")
  await page.getByLabel("Search").fill("playwright")
  await page.getByRole("button", { name: "Search" }).click()
  await expect(page.getByRole("listitem")).toHaveCount(2)
})

test("respond based on request headers", async ({ page }) => {
  await page.route("**/api/users", (route) => {
    const authHeader = route.request().headers()["authorization"]

    if (!authHeader || !authHeader.startsWith("Bearer ")) {
      return route.fulfill({
        status: 401,
        json: { error: "Unauthorized" },
      })
    }

    return route.fulfill({
      json: [{ id: 1, name: "Alice" }],
    })
  })

  await page.goto("/login")
  await expect(page.getByText("Unauthorized")).toBeVisible()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("respond based on request method", async ({ page }) => {
  await page.route("**/api/users", (route) => {
    const method = route.request().method()

    switch (method) {
      case "GET":
        return route.fulfill({ json: [{ id: 1, name: "Alice" }] })
      case "POST":
        return route.fulfill({ status: 201, json: { id: 2, name: "Bob" } })
      case "DELETE":
        return route.fulfill({ status: 204, body: "" })
      default:
        return route.continue()
    }
  })

  await page.goto("/users")
  await expect(page.getByText("Alice")).toBeVisible()
})

test("respond based on query parameters", async ({ page }) => {
  await page.route("**/api/users*", (route) => {
    const url = new URL(route.request().url())
    const role = url.searchParams.get("role")

    const allUsers = [
      { id: 1, name: "Alice", role: "admin" },
      { id: 2, name: "Bob", role: "user" },
      { id: 3, name: "Charlie", role: "user" },
    ]

    const filtered = role ? allUsers.filter((u) => u.role === role) : allUsers

    return route.fulfill({ json: filtered })
  })

  await page.goto("/users?role=admin")
  await expect(page.getByText("Alice")).toBeVisible()
})

test("respond based on request body", async ({ page }) => {
  await page.route("**/api/search", (route) => {
    const body = route.request().postDataJSON()
    const query = body?.query?.toLowerCase() || ""

    const results = {
      playwright: [{ title: "Playwright Docs" }, { title: "Playwright GitHub" }],
      cypress: [{ title: "Cypress Docs" }],
    }

    return route.fulfill({ json: results[query] || [] })
  })

  await page.goto("/search")
  await page.getByLabel("Search").fill("playwright")
  await page.getByRole("button", { name: "Search" }).click()
  await expect(page.getByRole("listitem")).toHaveCount(2)
})
```

### Network Error Simulation

**Use when**: Testing how your UI handles server errors, timeouts, and connection failures. Essential for verifying error boundaries, retry logic, and degraded-mode UX.
**Avoid when**: The test is about the happy path. Only introduce errors when testing error handling.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("simulate a 500 server error", async ({ page }) => {
  await page.route("**/api/users", (route) =>
    route.fulfill({
      status: 500,
      json: { error: "Internal Server Error" },
    })
  )

  await page.goto("/users")
  await expect(page.getByText("Something went wrong")).toBeVisible()
  await expect(page.getByRole("button", { name: "Retry" })).toBeVisible()
})

test("simulate a 403 forbidden", async ({ page }) => {
  await page.route("**/api/admin/**", (route) =>
    route.fulfill({
      status: 403,
      json: { error: "Forbidden", message: "Admin access required" },
    })
  )

  await page.goto("/admin/settings")
  await expect(page.getByText("Admin access required")).toBeVisible()
})

test("simulate a 404 not found", async ({ page }) => {
  await page.route("**/api/users/999", (route) =>
    route.fulfill({
      status: 404,
      json: { error: "User not found" },
    })
  )

  await page.goto("/users/999")
  await expect(page.getByText("User not found")).toBeVisible()
})

test("simulate a network error (connection refused)", async ({ page }) => {
  await page.route("**/api/users", (route) => route.abort("connectionrefused"))

  await page.goto("/users")
  await expect(page.getByText("Network error")).toBeVisible()
})

test("simulate a timeout", async ({ page }) => {
  await page.route("**/api/users", async (route) => {
    // Delay longer than the app's fetch timeout to trigger a timeout error
    await new Promise((resolve) => setTimeout(resolve, 30_000))
    await route.fulfill({ json: [] })
  })

  await page.goto("/users")
  // The app should show a timeout message before the route resolves
  await expect(page.getByText("Request timed out")).toBeVisible({
    timeout: 15_000,
  })
})

test("simulate intermittent failures then recovery", async ({ page }) => {
  let requestCount = 0

  await page.route("**/api/users", (route) => {
    requestCount++
    if (requestCount <= 2) {
      return route.fulfill({
        status: 503,
        json: { error: "Service Unavailable" },
      })
    }
    return route.fulfill({
      json: [{ id: 1, name: "Alice" }],
    })
  })

  await page.goto("/users")
  await expect(page.getByText("Something went wrong")).toBeVisible()

  // Simulate user clicking retry (which makes the 3rd request)
  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByText("Something went wrong")).toBeVisible()

  // Third attempt succeeds
  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByText("Alice")).toBeVisible()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("simulate a 500 server error", async ({ page }) => {
  await page.route("**/api/users", (route) =>
    route.fulfill({
      status: 500,
      json: { error: "Internal Server Error" },
    })
  )

  await page.goto("/users")
  await expect(page.getByText("Something went wrong")).toBeVisible()
  await expect(page.getByRole("button", { name: "Retry" })).toBeVisible()
})

test("simulate a network error (connection refused)", async ({ page }) => {
  await page.route("**/api/users", (route) => route.abort("connectionrefused"))

  await page.goto("/users")
  await expect(page.getByText("Network error")).toBeVisible()
})

test("simulate intermittent failures then recovery", async ({ page }) => {
  let requestCount = 0

  await page.route("**/api/users", (route) => {
    requestCount++
    if (requestCount <= 2) {
      return route.fulfill({
        status: 503,
        json: { error: "Service Unavailable" },
      })
    }
    return route.fulfill({
      json: [{ id: 1, name: "Alice" }],
    })
  })

  await page.goto("/users")
  await expect(page.getByText("Something went wrong")).toBeVisible()

  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByText("Something went wrong")).toBeVisible()

  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByText("Alice")).toBeVisible()
})
```

**Abort reasons**: `'aborted'`, `'accessdenied'`, `'addressunreachable'`, `'blockedbyclient'`, `'blockedbyresponse'`, `'connectionaborted'`, `'connectionclosed'`, `'connectionfailed'`, `'connectionrefused'`, `'connectionreset'`, `'internetdisconnected'`, `'namenotresolved'`, `'timedout'`, `'failed'`.

### Request Waiting

**Use when**: Synchronizing your test with network activity -- waiting for a request to be sent or a response to arrive before asserting on the UI.
**Avoid when**: A web-first assertion on a locator is sufficient. Only use request waiting when you need to inspect the request/response itself.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("wait for response and verify UI updates", async ({ page }) => {
  await page.goto("/users")

  // CRITICAL: set up the wait BEFORE triggering the action
  const responsePromise = page.waitForResponse("**/api/users")
  await page.getByRole("button", { name: "Refresh" }).click()
  const response = await responsePromise

  expect(response.status()).toBe(200)
  const users = await response.json()
  expect(users).toHaveLength(5)
})

test("wait for request and verify payload", async ({ page }) => {
  await page.goto("/users/new")

  const requestPromise = page.waitForRequest("**/api/users")
  await page.getByLabel("Name").fill("Alice")
  await page.getByLabel("Email").fill("alice@example.com")
  await page.getByRole("button", { name: "Create" }).click()
  const request = await requestPromise

  expect(request.method()).toBe("POST")
  expect(request.postDataJSON()).toMatchObject({
    name: "Alice",
    email: "alice@example.com",
  })
})

test("wait for response with predicate function", async ({ page }) => {
  await page.goto("/dashboard")

  // Wait for a specific response matching custom criteria
  const responsePromise = page.waitForResponse(
    (response) =>
      response.url().includes("/api/users") &&
      response.status() === 200 &&
      response.request().method() === "GET"
  )
  await page.getByRole("button", { name: "Load users" }).click()
  const response = await responsePromise

  const data = await response.json()
  expect(data.length).toBeGreaterThan(0)
})

test("wait for multiple requests in sequence", async ({ page }) => {
  await page.goto("/checkout")

  // Wait for multiple API calls that happen during a flow
  const [validateResponse, submitResponse] = await Promise.all([
    page.waitForResponse("**/api/cart/validate"),
    page.waitForResponse("**/api/orders"),
    page.getByRole("button", { name: "Place order" }).click(),
  ])

  expect(validateResponse.status()).toBe(200)
  expect(submitResponse.status()).toBe(201)
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("wait for response and verify UI updates", async ({ page }) => {
  await page.goto("/users")

  const responsePromise = page.waitForResponse("**/api/users")
  await page.getByRole("button", { name: "Refresh" }).click()
  const response = await responsePromise

  expect(response.status()).toBe(200)
  const users = await response.json()
  expect(users).toHaveLength(5)
})

test("wait for request and verify payload", async ({ page }) => {
  await page.goto("/users/new")

  const requestPromise = page.waitForRequest("**/api/users")
  await page.getByLabel("Name").fill("Alice")
  await page.getByLabel("Email").fill("alice@example.com")
  await page.getByRole("button", { name: "Create" }).click()
  const request = await requestPromise

  expect(request.method()).toBe("POST")
  expect(request.postDataJSON()).toMatchObject({
    name: "Alice",
    email: "alice@example.com",
  })
})

test("wait for multiple requests in sequence", async ({ page }) => {
  await page.goto("/checkout")

  const [validateResponse, submitResponse] = await Promise.all([
    page.waitForResponse("**/api/cart/validate"),
    page.waitForResponse("**/api/orders"),
    page.getByRole("button", { name: "Place order" }).click(),
  ])

  expect(validateResponse.status()).toBe(200)
  expect(submitResponse.status()).toBe(201)
})
```

### Glob Patterns and URL Matching

**Use when**: You need to match URLs with wildcards, partial paths, or regex. Every `page.route()`, `waitForRequest()`, and `waitForResponse()` accepts glob patterns, strings, or regex.
**Avoid when**: The URL is known and static. Use the exact string.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("glob pattern examples", async ({ page }) => {
  // ** matches any path segments (including nested)
  await page.route("**/api/users", (route) => route.fulfill({ json: [] }))
  // Matches: https://example.com/api/users
  // Matches: https://example.com/v2/api/users

  // * matches any single path segment
  await page.route("**/api/users/*/orders", (route) => route.fulfill({ json: [] }))
  // Matches: /api/users/123/orders
  // Matches: /api/users/abc/orders
  // Does NOT match: /api/users/123/456/orders

  // Match file extensions
  await page.route("**/*.{png,jpg,svg}", (route) => route.abort())
  // Matches: /images/logo.png, /assets/photo.jpg

  // Match query strings with *
  await page.route("**/api/search?q=*", (route) => route.fulfill({ json: [] }))
  // Matches: /api/search?q=anything

  // Use regex for complex patterns
  await page.route(/\/api\/users\/\d+$/, (route) =>
    route.fulfill({ json: { id: 1, name: "Alice" } })
  )
  // Matches: /api/users/123, /api/users/456
  // Does NOT match: /api/users/abc, /api/users/123/orders

  // Regex with captured groups for dynamic responses
  await page.route(/\/api\/users\/(\d+)/, (route) => {
    const match = route
      .request()
      .url()
      .match(/\/api\/users\/(\d+)/)
    const userId = match ? match[1] : "0"
    return route.fulfill({
      json: { id: parseInt(userId), name: `User ${userId}` },
    })
  })

  await page.goto("/users")
})

test("match all requests on a domain", async ({ page }) => {
  // Block everything from a specific domain
  await page.route("https://analytics.example.com/**", (route) => route.abort())

  await page.goto("/dashboard")
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("glob pattern examples", async ({ page }) => {
  // ** matches any path segments
  await page.route("**/api/users", (route) => route.fulfill({ json: [] }))

  // * matches a single path segment
  await page.route("**/api/users/*/orders", (route) => route.fulfill({ json: [] }))

  // Match file extensions
  await page.route("**/*.{png,jpg,svg}", (route) => route.abort())

  // Regex for complex patterns
  await page.route(/\/api\/users\/\d+$/, (route) =>
    route.fulfill({ json: { id: 1, name: "Alice" } })
  )

  // Regex with dynamic responses
  await page.route(/\/api\/users\/(\d+)/, (route) => {
    const match = route
      .request()
      .url()
      .match(/\/api\/users\/(\d+)/)
    const userId = match ? match[1] : "0"
    return route.fulfill({
      json: { id: parseInt(userId), name: `User ${userId}` },
    })
  })

  await page.goto("/users")
})

test("match all requests on a domain", async ({ page }) => {
  await page.route("https://analytics.example.com/**", (route) => route.abort())

  await page.goto("/dashboard")
})
```

**Pattern reference**:

| Pattern                        | Matches                               | Does Not Match          |
| ------------------------------ | ------------------------------------- | ----------------------- |
| `**/api/users`                 | `/api/users`, `/v2/api/users`         | `/api/users/1`          |
| `**/api/users*`                | `/api/users`, `/api/users?page=1`     | `/api/users/1`          |
| `**/api/users/**`              | `/api/users/1`, `/api/users/1/orders` | `/api/users`            |
| `**/api/users/*/orders`        | `/api/users/1/orders`                 | `/api/users/1/2/orders` |
| `**/*.{png,jpg}`               | `/logo.png`, `/deep/path/img.jpg`     | `/file.svg`             |
| `/\/api\/users\/\d+$/` (regex) | `/api/users/123`                      | `/api/users/abc`        |

## Decision Guide

| Scenario                                  | Use                                                           | Why                                                      |
| ----------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------- |
| Frontend depends on an external API       | `route.fulfill()`                                             | Deterministic data, no external dependency, fast         |
| Need to test real API but tweak one field | `route.fetch()` + modify + `route.fulfill()`                  | Uses real data as baseline, only overrides what you need |
| Testing happy path with real backend      | `route.continue()` (or no route)                              | Full integration coverage                                |
| Block analytics/ads/third-party noise     | `route.abort()`                                               | Faster tests, no flakiness from external services        |
| Complex API with many endpoints           | `page.routeFromHAR()` with `update: true`                     | Record once, replay forever; minimal test code           |
| Testing error handling (500, timeout)     | `route.fulfill({ status: 500 })` or `route.abort('timedout')` | Simulate errors deterministically                        |
| Verify request payload sent by frontend   | `page.waitForRequest()` + assertions                          | Confirms frontend sends correct data                     |
| Verify response data before UI check      | `page.waitForResponse()` + assertions                         | Confirms data arrives before asserting on DOM            |
| Multiple tests need the same mock         | `context.route()` or fixture                                  | Share routes across tests without repetition             |
| Testing loading spinners / skeleton UI    | `route.fulfill()` with a delay (via `setTimeout`)             | Control exact timing of response                         |
| Paginated or search-based API             | Conditional mock (check query params / body)                  | Dynamic responses based on request content               |

## Anti-Patterns

| Don't Do This                                                                | Problem                                                               | Do This Instead                                                                                                         |
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Mocking your own app's pages and static assets                               | You end up testing a fake app, not your real one                      | Only mock API/data endpoints, never HTML/JS/CSS served by your app                                                      |
| Hardcoding mock data inline in every test                                    | Duplicated data, hard to update when API changes                      | Extract mock data into shared fixtures (`tests/data/users.json`)                                                        |
| Never updating mocks when the API changes                                    | Tests pass against stale data; real app breaks                        | Use HAR recording with periodic `update: true` runs, or validate mock shapes against OpenAPI schemas                    |
| Mocking in production-like E2E tests                                         | You lose integration confidence                                       | Keep a separate test suite with real backends for smoke/integration tests; mock only in component and isolated UI tests |
| Forgetting to call `route.fulfill()`, `route.continue()`, or `route.abort()` | Request hangs, test times out with a confusing error                  | Every route handler must call exactly one of the three                                                                  |
| Setting up routes after `page.goto()`                                        | Requests fire during navigation before the route is registered        | Always call `page.route()` before `page.goto()`                                                                         |
| Using `waitForResponse` after the triggering action                          | Race condition: response may arrive before the wait is registered     | Always set up the promise before the action: `const p = page.waitForResponse(...); await click(); await p;`             |
| Mocking with `route.continue()` and thinking it mocks                        | `route.continue()` passes the request to the real server              | Use `route.fulfill()` to return fake data                                                                               |
| Over-broad glob patterns (`**/*`) without filtering                          | Catches all requests including HTML, JS, CSS; breaks the app          | Be specific: `**/api/**` or filter by `resourceType()`                                                                  |
| Forgetting `await` on route setup or fulfillment                             | Route may not be active when navigation starts                        | Always `await page.route(...)` and `await route.fulfill(...)`                                                           |
| Not calling `page.unroute()` when swapping mocks mid-test                    | Old route handler still fires, new one is ignored or both fire        | Call `page.unroute()` before registering a new handler for the same pattern                                             |
| Using `page.on('request')` for mocking                                       | Event listeners are read-only; they cannot modify or fulfill requests | Use `page.route()` for interception; `page.on('request')` only for logging                                              |

## Troubleshooting

### Route handler never fires

**Cause**: The URL pattern does not match the actual request URL. Common when the base URL includes a port number, path prefix, or the request uses a different protocol.

**Fix**:

- Log all requests to find the exact URL:

```typescript
page.on("request", (req) => console.log(req.method(), req.url()))
```

- Ensure glob pattern matches. `**/api/users` does not match `http://localhost:3000/api/users?page=1` -- use `**/api/users*` to include query strings.
- Check that `page.route()` is called before `page.goto()`.

### Route handler fires but test still times out

**Cause**: The handler throws an error or never calls `fulfill`/`continue`/`abort`.

**Fix**:

- Add error handling inside the route handler.
- Ensure every code path in the handler ends with one of the three resolution methods.

```typescript
await page.route("**/api/users", async (route) => {
  try {
    const data = getTestData() // this might throw
    await route.fulfill({ json: data })
  } catch (error) {
    console.error("Route handler error:", error)
    await route.abort()
  }
})
```

### Mocked response is ignored -- app shows real data

**Cause**: The route is registered at the page level but the request is made by a service worker or a different browser context.

**Fix**:

- Use `context.route()` instead of `page.route()` to cover all pages and service workers.
- Disable service workers in the config if they interfere:

```typescript
// playwright.config.ts
export default defineConfig({
  use: {
    serviceWorkers: "block",
  },
})
```

### `route.fetch()` causes infinite loop

**Cause**: `route.fetch()` re-issues the request, which can re-trigger the same route handler if the URL pattern matches.

**Fix**: Playwright handles this correctly for the same route handler -- `route.fetch()` will not re-enter the handler that called it. But if you have multiple overlapping route handlers, they can interfere. Simplify to a single handler per URL pattern, or use `route.fetch({ url: 'different-url' })` to redirect.

### HAR replay returns wrong responses

**Cause**: HAR files match requests by URL and sometimes by POST body. If the request body changes (e.g., timestamps, CSRF tokens), the match fails.

**Fix**:

- Re-record the HAR file with `update: true`.
- Use `notFound: 'fallback'` to let unmatched requests hit the real server.
- For POST requests with dynamic bodies, consider using `page.route()` with manual matching instead of HAR.

## Related

- [core/when-to-mock.md](when-to-mock.md) -- decision framework for when to mock vs use real services
- [core/api-testing.md](api-testing.md) -- testing REST and GraphQL APIs directly (without a browser)
- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- web-first assertions and `waitForResponse` patterns
- [core/authentication.md](authentication.md) -- mocking auth tokens and session state
- [core/error-and-edge-cases.md](error-and-edge-cases.md) -- error state testing patterns beyond network errors
- [core/service-workers-and-pwa.md](service-workers-and-pwa.md) -- handling service worker caching that interferes with mocks
