# Error States and Edge Cases

> **When to use**: Testing how your application handles errors, failures, boundary conditions, and unusual user behavior. These tests catch bugs that happy-path tests miss.
> **Prerequisites**: [core/assertions-and-waiting.md](assertions-and-waiting.md), [core/network-mocking.md](network-mocking.md) for route interception

## Quick Reference

```typescript
// Mock a 500 server error
await page.route("**/api/data", (route) => route.fulfill({ status: 500 }))

// Simulate offline mode
await page.context().setOffline(true)

// Test empty state
await page.route("**/api/items", (route) => route.fulfill({ status: 200, json: [] }))

// Browser back/forward
await page.goBack()
await page.goForward()

// Abort a network request (simulate network failure)
await page.route("**/api/save", (route) => route.abort("connectionfailed"))
```

## Patterns

### HTTP Error Status Codes

**Use when**: Testing that your application displays appropriate error pages or messages for 4xx and 5xx responses.
**Avoid when**: The error is handled silently (no user-facing feedback). Test via API or logs instead.

**TypeScript**

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

test("displays 404 page for missing resources", async ({ page }) => {
  // Navigate directly to a non-existent URL
  await page.goto("/this-page-does-not-exist")

  await expect(page.getByRole("heading", { name: /not found/i })).toBeVisible()
  await expect(page.getByRole("link", { name: "Go home" })).toBeVisible()
})

test("handles 500 server error gracefully", async ({ page }) => {
  // Intercept the API call and return a 500
  await page.route("**/api/dashboard", (route) =>
    route.fulfill({
      status: 500,
      contentType: "application/json",
      body: JSON.stringify({ error: "Internal server error" }),
    })
  )

  await page.goto("/dashboard")

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

test("handles 403 forbidden with redirect to login", async ({ page }) => {
  await page.route("**/api/admin/**", (route) => route.fulfill({ status: 403 }))

  await page.goto("/admin/settings")

  // Should redirect to login or show access denied
  await expect(page.getByText(/access denied|not authorized/i)).toBeVisible()
})

test("handles 429 rate limiting", async ({ page }) => {
  await page.route("**/api/search*", (route) =>
    route.fulfill({
      status: 429,
      headers: { "Retry-After": "30" },
      body: JSON.stringify({ error: "Too many requests" }),
    })
  )

  await page.goto("/search")
  await page.getByLabel("Search").fill("test")
  await page.getByRole("button", { name: "Search" }).click()

  await expect(page.getByText(/too many requests|try again later/i)).toBeVisible()
})
```

**JavaScript**

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

test("displays 404 page for missing resources", async ({ page }) => {
  await page.goto("/this-page-does-not-exist")

  await expect(page.getByRole("heading", { name: /not found/i })).toBeVisible()
  await expect(page.getByRole("link", { name: "Go home" })).toBeVisible()
})

test("handles 500 server error gracefully", async ({ page }) => {
  await page.route("**/api/dashboard", (route) =>
    route.fulfill({
      status: 500,
      contentType: "application/json",
      body: JSON.stringify({ error: "Internal server error" }),
    })
  )

  await page.goto("/dashboard")

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

test("handles 403 forbidden with redirect to login", async ({ page }) => {
  await page.route("**/api/admin/**", (route) => route.fulfill({ status: 403 }))

  await page.goto("/admin/settings")
  await expect(page.getByText(/access denied|not authorized/i)).toBeVisible()
})

test("handles 429 rate limiting", async ({ page }) => {
  await page.route("**/api/search*", (route) =>
    route.fulfill({
      status: 429,
      headers: { "Retry-After": "30" },
      body: JSON.stringify({ error: "Too many requests" }),
    })
  )

  await page.goto("/search")
  await page.getByLabel("Search").fill("test")
  await page.getByRole("button", { name: "Search" }).click()

  await expect(page.getByText(/too many requests|try again later/i)).toBeVisible()
})
```

### Network Failure and Offline Mode

**Use when**: Testing how the app behaves when the network is down, requests fail, or the connection is intermittent.
**Avoid when**: The app has no offline or error handling behavior to test.

**TypeScript**

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

test("offline mode shows offline banner", async ({ page }) => {
  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()

  // Go offline
  await page.context().setOffline(true)

  // Trigger a network-dependent action
  await page.getByRole("button", { name: "Refresh" }).click()

  await expect(page.getByText(/offline|no connection/i)).toBeVisible()

  // Go back online
  await page.context().setOffline(false)

  await page.getByRole("button", { name: "Refresh" }).click()
  await expect(page.getByText(/offline|no connection/i)).not.toBeVisible()
})

test("network request failure shows error state", async ({ page }) => {
  // Abort specific requests to simulate network failure
  await page.route("**/api/user/profile", (route) => route.abort("connectionfailed"))

  await page.goto("/profile")

  await expect(page.getByText("Failed to load profile")).toBeVisible()
  await expect(page.getByRole("button", { name: "Retry" })).toBeVisible()
})

test("request timeout shows timeout message", async ({ page }) => {
  // Delay the response beyond the app's timeout threshold
  await page.route("**/api/reports", async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 30_000))
    await route.fulfill({ status: 200, json: { data: [] } })
  })

  await page.goto("/reports")

  // App should show timeout message before Playwright's own timeout
  await expect(page.getByText(/timed out|taking too long/i)).toBeVisible({
    timeout: 20_000,
  })
})

test("intermittent connectivity — request fails then succeeds", async ({ page }) => {
  let requestCount = 0

  await page.route("**/api/data", (route) => {
    requestCount++
    if (requestCount <= 2) {
      return route.abort("connectionfailed")
    }
    return route.fulfill({ status: 200, json: { items: ["a", "b", "c"] } })
  })

  await page.goto("/data")

  // First load fails
  await expect(page.getByText(/failed|error/i)).toBeVisible()

  // User retries — still fails
  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByText(/failed|error/i)).toBeVisible()

  // Third attempt succeeds
  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByRole("listitem")).toHaveCount(3)
})
```

**JavaScript**

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

test("offline mode shows offline banner", async ({ page }) => {
  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()

  await page.context().setOffline(true)

  await page.getByRole("button", { name: "Refresh" }).click()
  await expect(page.getByText(/offline|no connection/i)).toBeVisible()

  await page.context().setOffline(false)

  await page.getByRole("button", { name: "Refresh" }).click()
  await expect(page.getByText(/offline|no connection/i)).not.toBeVisible()
})

test("network request failure shows error state", async ({ page }) => {
  await page.route("**/api/user/profile", (route) => route.abort("connectionfailed"))

  await page.goto("/profile")

  await expect(page.getByText("Failed to load profile")).toBeVisible()
  await expect(page.getByRole("button", { name: "Retry" })).toBeVisible()
})

test("intermittent connectivity — request fails then succeeds", async ({ page }) => {
  let requestCount = 0

  await page.route("**/api/data", (route) => {
    requestCount++
    if (requestCount <= 2) {
      return route.abort("connectionfailed")
    }
    return route.fulfill({ status: 200, json: { items: ["a", "b", "c"] } })
  })

  await page.goto("/data")

  await expect(page.getByText(/failed|error/i)).toBeVisible()

  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByText(/failed|error/i)).toBeVisible()

  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByRole("listitem")).toHaveCount(3)
})
```

### Empty States and Boundary Testing

**Use when**: Testing what the UI shows when there is no data, when inputs are at their minimum or maximum values, or when inputs contain special characters.
**Avoid when**: Never. Every feature should have empty state and boundary tests.

**TypeScript**

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

test("empty state is shown when no items exist", async ({ page }) => {
  // Mock an empty response
  await page.route("**/api/tasks", (route) => route.fulfill({ status: 200, json: [] }))

  await page.goto("/tasks")

  await expect(page.getByText("No tasks yet")).toBeVisible()
  await expect(page.getByRole("link", { name: "Create your first task" })).toBeVisible()

  // List elements should not be present
  await expect(page.getByRole("listitem")).toHaveCount(0)
})

test("handles maximum length input", async ({ page }) => {
  await page.goto("/profile")

  // Fill with max-length string
  const maxLengthName = "A".repeat(255)
  await page.getByLabel("Display name").fill(maxLengthName)
  await page.getByRole("button", { name: "Save" }).click()

  // Verify the name was saved (or truncated, depending on app behavior)
  await expect(page.getByText("Profile updated")).toBeVisible()
})

test("handles special characters in input", async ({ page }) => {
  await page.goto("/search")

  const specialInputs = [
    '<script>alert("xss")</script>',
    '"; DROP TABLE users; --',
    "unicode: \u00e9\u00e0\u00fc\u00f1 \u4f60\u597d \ud83d\ude80",
    "null bytes: \x00\x01\x02",
    "path traversal: ../../etc/passwd",
  ]

  for (const input of specialInputs) {
    await page.getByLabel("Search").fill(input)
    await page.getByRole("button", { name: "Search" }).click()

    // App should not crash — either show results or "no results"
    await expect(page.getByText(/results|no results|no matches/i)).toBeVisible()
  }
})

test("handles zero, one, and many items (0-1-N pattern)", async ({ page }) => {
  // Zero items
  await page.route("**/api/notifications", (route) => route.fulfill({ status: 200, json: [] }))
  await page.goto("/notifications")
  await expect(page.getByText("No notifications")).toBeVisible()

  // One item
  await page.route("**/api/notifications", (route) =>
    route.fulfill({
      status: 200,
      json: [{ id: 1, message: "Welcome!" }],
    })
  )
  await page.reload()
  await expect(page.getByRole("listitem")).toHaveCount(1)
  await expect(page.getByText("No notifications")).not.toBeVisible()

  // Many items — verify pagination or "load more"
  await page.route("**/api/notifications", (route) =>
    route.fulfill({
      status: 200,
      json: Array.from({ length: 50 }, (_, i) => ({
        id: i + 1,
        message: `Notification ${i + 1}`,
      })),
    })
  )
  await page.reload()
  await expect(page.getByRole("listitem").first()).toBeVisible()
  await expect(page.getByRole("button", { name: /load more|show all/i })).toBeVisible()
})
```

**JavaScript**

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

test("empty state is shown when no items exist", async ({ page }) => {
  await page.route("**/api/tasks", (route) => route.fulfill({ status: 200, json: [] }))

  await page.goto("/tasks")

  await expect(page.getByText("No tasks yet")).toBeVisible()
  await expect(page.getByRole("link", { name: "Create your first task" })).toBeVisible()
  await expect(page.getByRole("listitem")).toHaveCount(0)
})

test("handles maximum length input", async ({ page }) => {
  await page.goto("/profile")

  const maxLengthName = "A".repeat(255)
  await page.getByLabel("Display name").fill(maxLengthName)
  await page.getByRole("button", { name: "Save" }).click()

  await expect(page.getByText("Profile updated")).toBeVisible()
})

test("handles special characters in input", async ({ page }) => {
  await page.goto("/search")

  const specialInputs = [
    '<script>alert("xss")</script>',
    '"; DROP TABLE users; --',
    "unicode: \u00e9\u00e0\u00fc\u00f1 \u4f60\u597d \ud83d\ude80",
    "path traversal: ../../etc/passwd",
  ]

  for (const input of specialInputs) {
    await page.getByLabel("Search").fill(input)
    await page.getByRole("button", { name: "Search" }).click()

    await expect(page.getByText(/results|no results|no matches/i)).toBeVisible()
  }
})

test("handles zero, one, and many items (0-1-N pattern)", async ({ page }) => {
  await page.route("**/api/notifications", (route) => route.fulfill({ status: 200, json: [] }))
  await page.goto("/notifications")
  await expect(page.getByText("No notifications")).toBeVisible()

  await page.route("**/api/notifications", (route) =>
    route.fulfill({
      status: 200,
      json: [{ id: 1, message: "Welcome!" }],
    })
  )
  await page.reload()
  await expect(page.getByRole("listitem")).toHaveCount(1)

  await page.route("**/api/notifications", (route) =>
    route.fulfill({
      status: 200,
      json: Array.from({ length: 50 }, (_, i) => ({
        id: i + 1,
        message: `Notification ${i + 1}`,
      })),
    })
  )
  await page.reload()
  await expect(page.getByRole("listitem").first()).toBeVisible()
  await expect(page.getByRole("button", { name: /load more|show all/i })).toBeVisible()
})
```

### Loading States and Skeletons

**Use when**: Verifying that loading indicators, skeleton screens, or spinners appear during data fetching and disappear when data arrives.
**Avoid when**: The application renders synchronously with no loading indicators (SSR without client-side fetching).

**TypeScript**

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

test("loading skeleton appears and resolves", async ({ page }) => {
  // Delay the API response to observe the loading state
  let resolveResponse: () => void
  const responseReady = new Promise<void>((resolve) => {
    resolveResponse = resolve
  })

  await page.route("**/api/dashboard", async (route) => {
    await responseReady
    await route.fulfill({
      status: 200,
      json: { revenue: 12400, users: 350 },
    })
  })

  await page.goto("/dashboard")

  // Skeleton should be visible while loading
  await expect(page.getByTestId("skeleton-revenue")).toBeVisible()
  await expect(page.getByTestId("skeleton-users")).toBeVisible()

  // Real content should not be visible yet
  await expect(page.getByText("$12,400")).not.toBeVisible()

  // Release the response
  resolveResponse!()

  // Skeleton disappears, real content appears
  await expect(page.getByTestId("skeleton-revenue")).not.toBeVisible()
  await expect(page.getByText("$12,400")).toBeVisible()
  await expect(page.getByText("350")).toBeVisible()
})

test("spinner shown during form submission", async ({ page }) => {
  let resolveSubmit: () => void
  const submitReady = new Promise<void>((resolve) => {
    resolveSubmit = resolve
  })

  await page.route("**/api/contact", async (route) => {
    await submitReady
    await route.fulfill({ status: 200, json: { success: true } })
  })

  await page.goto("/contact")
  await page.getByLabel("Name").fill("Jane")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Test")

  await page.getByRole("button", { name: "Send" }).click()

  // Loading spinner/state during submission
  await expect(page.getByRole("button", { name: /sending/i })).toBeVisible()
  await expect(page.getByRole("button", { name: /sending/i })).toBeDisabled()

  // Complete the submission
  resolveSubmit!()

  await expect(page.getByText("Message sent")).toBeVisible()
})
```

**JavaScript**

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

test("loading skeleton appears and resolves", async ({ page }) => {
  let resolveResponse
  const responseReady = new Promise((resolve) => {
    resolveResponse = resolve
  })

  await page.route("**/api/dashboard", async (route) => {
    await responseReady
    await route.fulfill({
      status: 200,
      json: { revenue: 12400, users: 350 },
    })
  })

  await page.goto("/dashboard")

  await expect(page.getByTestId("skeleton-revenue")).toBeVisible()
  await expect(page.getByTestId("skeleton-users")).toBeVisible()
  await expect(page.getByText("$12,400")).not.toBeVisible()

  resolveResponse()

  await expect(page.getByTestId("skeleton-revenue")).not.toBeVisible()
  await expect(page.getByText("$12,400")).toBeVisible()
  await expect(page.getByText("350")).toBeVisible()
})

test("spinner shown during form submission", async ({ page }) => {
  let resolveSubmit
  const submitReady = new Promise((resolve) => {
    resolveSubmit = resolve
  })

  await page.route("**/api/contact", async (route) => {
    await submitReady
    await route.fulfill({ status: 200, json: { success: true } })
  })

  await page.goto("/contact")
  await page.getByLabel("Name").fill("Jane")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Test")

  await page.getByRole("button", { name: "Send" }).click()

  await expect(page.getByRole("button", { name: /sending/i })).toBeVisible()
  await expect(page.getByRole("button", { name: /sending/i })).toBeDisabled()

  resolveSubmit()

  await expect(page.getByText("Message sent")).toBeVisible()
})
```

### Retry Behavior Testing

**Use when**: Testing that the application retries failed requests automatically or via a user-triggered "retry" button.
**Avoid when**: The application has no retry mechanism.

**TypeScript**

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

test("retry button recovers from a failed API call", async ({ page }) => {
  let callCount = 0

  await page.route("**/api/feed", (route) => {
    callCount++
    if (callCount === 1) {
      return route.fulfill({ status: 500 })
    }
    return route.fulfill({
      status: 200,
      json: { posts: [{ id: 1, title: "Hello World" }] },
    })
  })

  await page.goto("/feed")

  // First load fails
  await expect(page.getByText(/something went wrong/i)).toBeVisible()

  // Click retry — second call succeeds
  await page.getByRole("button", { name: "Try again" }).click()

  await expect(page.getByText("Hello World")).toBeVisible()
  expect(callCount).toBe(2)
})

test("automatic retry with exponential backoff", async ({ page }) => {
  const callTimestamps: number[] = []

  await page.route("**/api/status", (route) => {
    callTimestamps.push(Date.now())
    if (callTimestamps.length <= 3) {
      return route.fulfill({ status: 503 })
    }
    return route.fulfill({ status: 200, json: { status: "ok" } })
  })

  await page.goto("/status")

  // Wait for the auto-retry to eventually succeed
  await expect(page.getByText("System operational")).toBeVisible({
    timeout: 30_000,
  })

  // Verify multiple retry attempts were made
  expect(callTimestamps.length).toBeGreaterThanOrEqual(4)

  // Verify backoff: gaps between retries should increase
  if (callTimestamps.length >= 3) {
    const gap1 = callTimestamps[1] - callTimestamps[0]
    const gap2 = callTimestamps[2] - callTimestamps[1]
    expect(gap2).toBeGreaterThanOrEqual(gap1)
  }
})
```

**JavaScript**

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

test("retry button recovers from a failed API call", async ({ page }) => {
  let callCount = 0

  await page.route("**/api/feed", (route) => {
    callCount++
    if (callCount === 1) {
      return route.fulfill({ status: 500 })
    }
    return route.fulfill({
      status: 200,
      json: { posts: [{ id: 1, title: "Hello World" }] },
    })
  })

  await page.goto("/feed")

  await expect(page.getByText(/something went wrong/i)).toBeVisible()

  await page.getByRole("button", { name: "Try again" }).click()

  await expect(page.getByText("Hello World")).toBeVisible()
  expect(callCount).toBe(2)
})

test("automatic retry with exponential backoff", async ({ page }) => {
  const callTimestamps = []

  await page.route("**/api/status", (route) => {
    callTimestamps.push(Date.now())
    if (callTimestamps.length <= 3) {
      return route.fulfill({ status: 503 })
    }
    return route.fulfill({ status: 200, json: { status: "ok" } })
  })

  await page.goto("/status")

  await expect(page.getByText("System operational")).toBeVisible({
    timeout: 30_000,
  })

  expect(callTimestamps.length).toBeGreaterThanOrEqual(4)
})
```

### Browser Back/Forward Navigation

**Use when**: Testing that the application handles browser history navigation correctly — preserving state, URL updates, and content after going back or forward.
**Avoid when**: The app is a single-page application that does not use the browser history API. Focus on client-side routing tests instead.

**TypeScript**

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

test("browser back preserves navigation context", async ({ page }) => {
  await page.goto("/products")
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await page.waitForURL("**/products/running-shoes")

  // Go back
  await page.goBack()

  await expect(page).toHaveURL(/\/products$/)
  await expect(page.getByRole("heading", { name: "Products" })).toBeVisible()
})

test("browser forward returns to previous page", async ({ page }) => {
  await page.goto("/products")
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await page.waitForURL("**/products/running-shoes")

  await page.goBack()
  await page.goForward()

  await expect(page).toHaveURL(/\/products\/running-shoes/)
  await expect(page.getByRole("heading", { name: "Running Shoes" })).toBeVisible()
})

test("form state after browser back", async ({ page }) => {
  await page.goto("/checkout")

  // Fill form on step 1
  await page.getByLabel("Address").fill("123 Main St")
  await page.getByRole("button", { name: "Continue" }).click()

  // Arrived at step 2
  await expect(page.getByRole("heading", { name: "Payment" })).toBeVisible()

  // Go back to step 1
  await page.goBack()

  // Data should be preserved (depends on app implementation)
  await expect(page.getByLabel("Address")).toHaveValue("123 Main St")
})

test("back button after form submission does not re-submit", async ({ page }) => {
  await page.goto("/contact")

  await page.getByLabel("Name").fill("Jane")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Test")
  await page.getByRole("button", { name: "Send" }).click()

  await expect(page.getByText("Message sent")).toBeVisible()

  // Going back should show the form, not re-submit
  await page.goBack()
  await expect(page.getByLabel("Name")).toBeVisible()
})
```

**JavaScript**

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

test("browser back preserves navigation context", async ({ page }) => {
  await page.goto("/products")
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await page.waitForURL("**/products/running-shoes")

  await page.goBack()

  await expect(page).toHaveURL(/\/products$/)
  await expect(page.getByRole("heading", { name: "Products" })).toBeVisible()
})

test("browser forward returns to previous page", async ({ page }) => {
  await page.goto("/products")
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await page.waitForURL("**/products/running-shoes")

  await page.goBack()
  await page.goForward()

  await expect(page).toHaveURL(/\/products\/running-shoes/)
  await expect(page.getByRole("heading", { name: "Running Shoes" })).toBeVisible()
})

test("form state after browser back", async ({ page }) => {
  await page.goto("/checkout")

  await page.getByLabel("Address").fill("123 Main St")
  await page.getByRole("button", { name: "Continue" }).click()

  await expect(page.getByRole("heading", { name: "Payment" })).toBeVisible()

  await page.goBack()
  await expect(page.getByLabel("Address")).toHaveValue("123 Main St")
})

test("back button after form submission does not re-submit", async ({ page }) => {
  await page.goto("/contact")

  await page.getByLabel("Name").fill("Jane")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Test")
  await page.getByRole("button", { name: "Send" }).click()

  await expect(page.getByText("Message sent")).toBeVisible()

  await page.goBack()
  await expect(page.getByLabel("Name")).toBeVisible()
})
```

### Concurrent User Actions

**Use when**: Testing that rapid user interactions do not cause race conditions — double-clicking submit, typing while data is loading, navigating during an async operation.
**Avoid when**: The UI has no async operations that could conflict with user actions.

**TypeScript**

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

test("double-click submit does not create duplicate entries", async ({ page }) => {
  const requests: string[] = []

  await page.route("**/api/orders", (route) => {
    requests.push(route.request().method())
    return route.fulfill({
      status: 201,
      json: { id: 1, status: "created" },
    })
  })

  await page.goto("/checkout")
  await page.getByLabel("Item").fill("Widget")

  const submitButton = page.getByRole("button", { name: "Place order" })

  // Rapid double-click
  await submitButton.dblclick()

  // Wait for the result
  await expect(page.getByText("Order confirmed")).toBeVisible()

  // The app should prevent duplicate submissions
  // (button disabled after first click, or server deduplication)
  expect(requests.filter((m) => m === "POST").length).toBeLessThanOrEqual(1)
})

test("typing during navigation does not crash", async ({ page }) => {
  await page.goto("/search")

  // Start typing and immediately navigate
  await page.getByLabel("Search").pressSequentially("test query", { delay: 30 })
  await page.getByRole("link", { name: "Home" }).click()

  // Should arrive at home page without errors
  await page.waitForURL("**/")
  await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
})

test("rapid filter changes use latest value", async ({ page }) => {
  const requestUrls: string[] = []

  await page.route("**/api/products*", (route) => {
    requestUrls.push(route.request().url())
    return route.fulfill({
      status: 200,
      json: { products: [{ name: "Latest result" }] },
    })
  })

  await page.goto("/products")

  // Rapidly change the filter
  await page.getByLabel("Category").selectOption("electronics")
  await page.getByLabel("Category").selectOption("clothing")
  await page.getByLabel("Category").selectOption("books")

  // Wait for the final result
  await expect(page.getByText("Latest result")).toBeVisible()

  // The UI should show results for "books", not an earlier selection
  // Some apps debounce; some cancel in-flight requests
})
```

**JavaScript**

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

test("double-click submit does not create duplicate entries", async ({ page }) => {
  const requests = []

  await page.route("**/api/orders", (route) => {
    requests.push(route.request().method())
    return route.fulfill({
      status: 201,
      json: { id: 1, status: "created" },
    })
  })

  await page.goto("/checkout")
  await page.getByLabel("Item").fill("Widget")

  const submitButton = page.getByRole("button", { name: "Place order" })
  await submitButton.dblclick()

  await expect(page.getByText("Order confirmed")).toBeVisible()

  expect(requests.filter((m) => m === "POST").length).toBeLessThanOrEqual(1)
})

test("typing during navigation does not crash", async ({ page }) => {
  await page.goto("/search")

  await page.getByLabel("Search").pressSequentially("test query", { delay: 30 })
  await page.getByRole("link", { name: "Home" }).click()

  await page.waitForURL("**/")
  await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
})

test("rapid filter changes use latest value", async ({ page }) => {
  const requestUrls = []

  await page.route("**/api/products*", (route) => {
    requestUrls.push(route.request().url())
    return route.fulfill({
      status: 200,
      json: { products: [{ name: "Latest result" }] },
    })
  })

  await page.goto("/products")

  await page.getByLabel("Category").selectOption("electronics")
  await page.getByLabel("Category").selectOption("clothing")
  await page.getByLabel("Category").selectOption("books")

  await expect(page.getByText("Latest result")).toBeVisible()
})
```

### Graceful Degradation

**Use when**: Testing that the app continues to function when non-critical services fail (analytics, chat widget, recommendations).
**Avoid when**: The failing service is critical to the core workflow.

**TypeScript**

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

test("page works when analytics service fails", async ({ page }) => {
  // Block analytics and tracking scripts
  await page.route("**/analytics/**", (route) => route.abort())
  await page.route("**/tracking/**", (route) => route.abort())

  await page.goto("/products")

  // Core functionality still works
  await expect(page.getByRole("heading", { name: "Products" })).toBeVisible()
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await expect(page.getByRole("button", { name: "Add to cart" })).toBeEnabled()
})

test("page works when recommendation engine fails", async ({ page }) => {
  await page.route("**/api/recommendations", (route) => route.fulfill({ status: 500 }))

  await page.goto("/products/running-shoes")

  // Main product content loads
  await expect(page.getByRole("heading", { name: "Running Shoes" })).toBeVisible()
  await expect(page.getByRole("button", { name: "Add to cart" })).toBeEnabled()

  // Recommendations section shows fallback
  await expect(page.getByText(/recommendations unavailable|you may also like/i)).toBeVisible()
})

test("page works when third-party chat widget fails to load", async ({ page }) => {
  // Block the chat widget script
  await page.route("**/chat-widget.js", (route) => route.abort())

  await page.goto("/support")

  // Core support page loads
  await expect(page.getByRole("heading", { name: "Help Center" })).toBeVisible()

  // No console errors from the blocked widget should crash the page
  const errors: string[] = []
  page.on("pageerror", (error) => errors.push(error.message))

  // Navigate around to ensure no cascade failures
  await page.getByRole("link", { name: "FAQ" }).click()
  await expect(page.getByRole("heading", { name: "FAQ" })).toBeVisible()
})
```

**JavaScript**

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

test("page works when analytics service fails", async ({ page }) => {
  await page.route("**/analytics/**", (route) => route.abort())
  await page.route("**/tracking/**", (route) => route.abort())

  await page.goto("/products")

  await expect(page.getByRole("heading", { name: "Products" })).toBeVisible()
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await expect(page.getByRole("button", { name: "Add to cart" })).toBeEnabled()
})

test("page works when recommendation engine fails", async ({ page }) => {
  await page.route("**/api/recommendations", (route) => route.fulfill({ status: 500 }))

  await page.goto("/products/running-shoes")

  await expect(page.getByRole("heading", { name: "Running Shoes" })).toBeVisible()
  await expect(page.getByRole("button", { name: "Add to cart" })).toBeEnabled()
})

test("page works when third-party chat widget fails to load", async ({ page }) => {
  await page.route("**/chat-widget.js", (route) => route.abort())

  await page.goto("/support")

  await expect(page.getByRole("heading", { name: "Help Center" })).toBeVisible()

  await page.getByRole("link", { name: "FAQ" }).click()
  await expect(page.getByRole("heading", { name: "FAQ" })).toBeVisible()
})
```

## Decision Guide

| Scenario                 | Approach                                                      | Key API                                                         |
| ------------------------ | ------------------------------------------------------------- | --------------------------------------------------------------- |
| 404 page                 | Navigate to non-existent URL, assert error page               | `page.goto('/nonexistent')`                                     |
| 500 server error         | Mock route with `status: 500`                                 | `page.route(url, route => route.fulfill({ status: 500 }))`      |
| Network failure          | Abort the route                                               | `route.abort('connectionfailed')`                               |
| Offline mode             | Toggle offline on the browser context                         | `page.context().setOffline(true)`                               |
| Slow response            | Delay route fulfillment with a Promise                        | `await new Promise(r => setTimeout(r, delay))` in route handler |
| Empty state              | Mock API to return empty array                                | `route.fulfill({ json: [] })`                                   |
| Boundary values          | Fill inputs with min/max/special values                       | `locator.fill('A'.repeat(255))`                                 |
| Loading skeleton         | Delay route, assert skeleton visible, release, assert content | Promise-based route handler                                     |
| Retry behavior           | Track route call count, fail first N, succeed after           | Counter in route handler                                        |
| Browser history          | Use `page.goBack()` and `page.goForward()`                    | Assert URL and content after navigation                         |
| Double submit            | `dblclick()` on submit, verify single POST                    | Track requests in route handler                                 |
| Third-party failure      | Abort non-critical routes, verify core works                  | `route.abort()` on optional services                            |
| Console error monitoring | Listen for `pageerror` event                                  | `page.on('pageerror', handler)`                                 |

## Anti-Patterns

| Don't Do This                                          | Problem                                                                        | Do This Instead                                                           |
| ------------------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| Only testing the happy path                            | Real users hit errors. Errors in production are more expensive than test time. | Add error/edge case tests for every feature                               |
| `page.route('**/*', route => route.abort())`           | Blocks ALL requests including the page itself                                  | Target specific URLs: `page.route('**/api/specific', ...)`                |
| Using `page.waitForTimeout()` to simulate slow loading | Arbitrary, flaky, slows tests                                                  | Use promise-based route handlers to control timing precisely              |
| Hardcoding error messages in assertions                | Messages change. Tests break on copy edits.                                    | Use regex or partial matches: `getByText(/error/i)`                       |
| Testing every error code in one mega-test              | Hard to debug, slow, first failure hides the rest                              | One test per error scenario, or use `test.describe` groups                |
| Skipping empty state tests                             | Empty states are the first thing new users see; often broken                   | Always test 0-item state alongside 1-item and N-item                      |
| Testing offline by disconnecting real network          | Flaky, affects parallel tests, CI may not support it                           | Use `context.setOffline(true)` — deterministic and scoped                 |
| Not cleaning up route mocks between tests              | Routes persist on the page. Test B inherits test A's mocks.                    | Each test sets its own routes; Playwright resets between tests by default |
| Asserting on exact error strings from the server       | Couples tests to backend implementation details                                | Assert the UI message, not the raw API response body                      |
| Using `try/catch` to "handle" expected errors          | Swallows real bugs; test passes when it shouldn't                              | Let errors propagate; use route mocking to control the response           |

## Troubleshooting

### Route handler is not intercepting requests

**Cause**: The URL pattern does not match, or the route was registered after the navigation that triggers the request.

```typescript
// Always register routes BEFORE navigating
await page.route("**/api/data", (route) => route.fulfill({ status: 500 }))
await page.goto("/dashboard") // route is active before the page loads

// Debug: log all requests to find the actual URL pattern
page.on("request", (req) => console.log(req.url()))
```

### `context.setOffline(true)` does not affect Service Worker

**Cause**: Service Workers have their own network handling. `setOffline` simulates offline at the browser level, but a Service Worker may serve cached responses.

```typescript
// Unregister service workers first
await page.evaluate(async () => {
  const registrations = await navigator.serviceWorker.getRegistrations()
  for (const registration of registrations) {
    await registration.unregister()
  }
})
await page.context().setOffline(true)
```

### Delayed route handler causes test timeout

**Cause**: The promise in the route handler never resolves, or the delay exceeds the test timeout.

```typescript
// Always ensure the delay is less than the assertion timeout
await page.route("**/api/slow", async (route) => {
  await new Promise((resolve) => setTimeout(resolve, 5_000)) // 5s delay
  await route.fulfill({ status: 200, json: {} })
})

// Increase assertion timeout to account for the artificial delay
await expect(page.getByText("Data loaded")).toBeVisible({ timeout: 10_000 })
```

### `goBack()` does not navigate

**Cause**: There is no history entry to go back to. `goBack()` requires at least one previous navigation.

```typescript
// Ensure there is history before calling goBack
await page.goto("/page-a")
await page.goto("/page-b")
await page.goBack() // goes to /page-a

// If using SPA client-side routing, goBack may not work if the router
// does not push to browser history. Use the app's own back button instead.
await page.getByRole("button", { name: "Back" }).click()
```

## Related

- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- assertion strategies for error states
- [core/network-mocking.md](network-mocking.md) -- detailed network interception and mocking patterns
- [core/forms-and-validation.md](forms-and-validation.md) -- form validation error testing
- [core/flaky-tests.md](flaky-tests.md) -- fixing timing issues in error/edge case tests
- [core/service-workers-and-pwa.md](service-workers-and-pwa.md) -- offline-first and PWA testing patterns
- [core/multi-context-and-popups.md](multi-context-and-popups.md) -- testing concurrent browser contexts
