# 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