# 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 = [
'',
'"; 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 = [
'',
'"; 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((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((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