# 错误状态与边界情况 > **使用场景**:测试应用程序如何处理错误、失败、边界条件和异常用户行为。这些测试能捕获快乐路径测试遗漏的缺陷。 > **前置条件**:[core/assertions-and-waiting.md](assertions-and-waiting.md)、[core/network-mocking.md](network-mocking.md)(用于路由拦截) ## 快速参考 ```typescript // 模拟 500 服务器错误 await page.route("**/api/data", (route) => route.fulfill({ status: 500 })) // 模拟离线模式 await page.context().setOffline(true) // 测试空状态 await page.route("**/api/items", (route) => route.fulfill({ status: 200, json: [] })) // 浏览器前进/后退 await page.goBack() await page.goForward() // 中止网络请求(模拟网络故障) await page.route("**/api/save", (route) => route.abort("connectionfailed")) ``` ## 模式 ### HTTP 错误状态码 **使用场景**:测试应用程序是否为 4xx 和 5xx 响应正确显示错误页面或提示信息。 **避免场景**:错误被静默处理(没有面向用户的反馈)。改为通过 API 或日志测试。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("显示 404 页面(资源不存在)", async ({ page }) => { // 直接导航到不存在的 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("优雅处理 500 服务器错误", async ({ page }) => { // 拦截 API 调用并返回 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("处理 403 禁止访问并重定向到登录页", 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("处理 429 请求限流", 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("显示 404 页面(资源不存在)", 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("优雅处理 500 服务器错误", 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("处理 403 禁止访问并重定向到登录页", 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("处理 429 请求限流", 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() }) ``` ### 网络故障与离线模式 **使用场景**:测试应用在网络断开、请求失败或连接不稳定时的行为。 **避免场景**:应用没有离线或错误处理行为需要测试。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("离线模式显示离线横幅", 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("网络请求失败显示错误状态", 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("请求超时显示超时消息", async ({ page }) => { // 延迟响应,超过应用的超时阈值 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") // 应用应在 Playwright 自身超时之前显示超时消息 await expect(page.getByText(/timed out|taking too long/i)).toBeVisible({ timeout: 20_000, }) }) test("间歇性连接——请求先失败后成功", 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) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("离线模式显示离线横幅", 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("网络请求失败显示错误状态", 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("间歇性连接——请求先失败后成功", 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) }) ``` ### 空状态与边界测试 **使用场景**:测试 UI 在无数据、输入为最小值或最大值、或输入包含特殊字符时的显示情况。 **避免场景**:永远不需要避免。每个功能都应有空状态和边界测试。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("无项目时显示空状态", 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("处理最大长度输入", 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("处理输入中的特殊字符", 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() // 应用不应崩溃——要么显示结果,要么显示"无结果" await expect(page.getByText(/results|no results|no matches/i)).toBeVisible() } }) test("处理零个、一个和多个项目(0-1-N 模式)", 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 expect(page.getByText("No notifications")).not.toBeVisible() // 多个项目——验证分页或"加载更多" 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("无项目时显示空状态", 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("处理最大长度输入", 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("处理输入中的特殊字符", 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("处理零个、一个和多个项目(0-1-N 模式)", 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() }) ``` ### 加载状态与骨架屏 **使用场景**:验证加载指示器、骨架屏或加载动画在数据获取期间出现,并在数据到达后消失。 **避免场景**:应用同步渲染,没有加载指示器(无客户端数据请求的 SSR)。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("骨架屏出现并解析完成", async ({ page }) => { // 延迟 API 响应以观察加载状态 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") // 加载时骨架屏应可见 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("表单提交时显示加载动画", 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() // 提交期间的加载动画/状态 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() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("骨架屏出现并解析完成", 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("表单提交时显示加载动画", 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() }) ``` ### 重试行为测试 **使用场景**:测试应用是否自动重试失败的请求,或是否通过用户触发的"重试"按钮进行重试。 **避免场景**:应用没有重试机制。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("重试按钮从失败的 API 调用中恢复", 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("带指数退避的自动重试", 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") // 等待自动重试最终成功 await expect(page.getByText("System operational")).toBeVisible({ timeout: 30_000, }) // 验证进行了多次重试尝试 expect(callTimestamps.length).toBeGreaterThanOrEqual(4) // 验证退避:重试间隔应递增 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("重试按钮从失败的 API 调用中恢复", 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("带指数退避的自动重试", 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) }) ``` ### 浏览器前进/后退导航 **使用场景**:测试应用程序是否正确处理浏览器历史导航——后退或前进后保持状态、URL 更新和内容。 **避免场景**:应用是单页应用且不使用浏览器历史 API。应专注于客户端路由测试。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("浏览器后退保留导航上下文", 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("浏览器前进返回到上一页", 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("浏览器后退后的表单状态", async ({ page }) => { await page.goto("/checkout") // 填写步骤 1 的表单 await page.getByLabel("Address").fill("123 Main St") await page.getByRole("button", { name: "Continue" }).click() // 到达步骤 2 await expect(page.getByRole("heading", { name: "Payment" })).toBeVisible() // 后退到步骤 1 await page.goBack() // 数据应保留(取决于应用实现) await expect(page.getByLabel("Address")).toHaveValue("123 Main St") }) test("表单提交后点击后退按钮不会重新提交", 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() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("浏览器后退保留导航上下文", 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("浏览器前进返回到上一页", 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("浏览器后退后的表单状态", 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("表单提交后点击后退按钮不会重新提交", 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() }) ``` ### 并发用户操作 **使用场景**:测试快速用户交互是否导致竞态条件——双击提交、数据加载时打字、异步操作期间导航。 **避免场景**:UI 没有可能与用户操作冲突的异步操作。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("双击提交不会创建重复条目", 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" }) // 快速双击 await submitButton.dblclick() // 等待结果 await expect(page.getByText("Order confirmed")).toBeVisible() // 应用应阻止重复提交 //(第一次点击后按钮禁用,或服务端去重) expect(requests.filter((m) => m === "POST").length).toBeLessThanOrEqual(1) }) test("导航期间打字不会崩溃", 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("快速筛选变更使用最新值", 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") // 快速切换筛选条件 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() // UI 应显示"books"的结果,而不是之前的选项 // 某些应用会防抖;某些会取消正在进行的请求 }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("双击提交不会创建重复条目", 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("导航期间打字不会崩溃", 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("快速筛选变更使用最新值", 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() }) ``` ### 优雅降级 **使用场景**:测试非关键服务(分析、聊天组件、推荐)失败时应用是否仍能正常运行。 **避免场景**:失败的服务对核心工作流程至关重要。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("分析服务失败时页面仍正常工作", 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("推荐引擎失败时页面仍正常工作", 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() // 推荐区域显示后备内容 await expect(page.getByText(/recommendations unavailable|you may also like/i)).toBeVisible() }) test("第三方聊天组件加载失败时页面仍正常工作", async ({ page }) => { // 拦截聊天组件脚本 await page.route("**/chat-widget.js", (route) => route.abort()) await page.goto("/support") // 核心支持页面加载 await expect(page.getByRole("heading", { name: "Help Center" })).toBeVisible() // 被拦截组件不应导致页面崩溃 const errors: string[] = [] page.on("pageerror", (error) => errors.push(error.message)) // 导航以确认没有级联故障 await page.getByRole("link", { name: "FAQ" }).click() await expect(page.getByRole("heading", { name: "FAQ" })).toBeVisible() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("分析服务失败时页面仍正常工作", 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("推荐引擎失败时页面仍正常工作", 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("第三方聊天组件加载失败时页面仍正常工作", 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() }) ``` ## 决策指南 | 场景 | 方法 | 关键 API | | ------------------------ | -------------------------------------------------------------- | --------------------------------------------------------------- | | 404 页面 | 导航到不存在的 URL,断言错误页面 | `page.goto('/nonexistent')` | | 500 服务器错误 | 使用 `status: 500` 模拟路由 | `page.route(url, route => route.fulfill({ status: 500 }))` | | 网络故障 | 中止路由 | `route.abort('connectionfailed')` | | 离线模式 | 在浏览器上下文中切换离线状态 | `page.context().setOffline(true)` | | 慢响应 | 使用 Promise 延迟路由完成 | 路由处理器中的 `await new Promise(r => setTimeout(r, delay))` | | 空状态 | 模拟 API 返回空数组 | `route.fulfill({ json: [] })` | | 边界值 | 用最小/最大/特殊值填充输入 | `locator.fill('A'.repeat(255))` | | 骨架屏 | 延迟路由,断言骨架屏可见,释放响应,断言内容 | 基于 Promise 的路由处理器 | | 重试行为 | 跟踪路由调用次数,前 N 次失败,之后成功 | 路由处理器中的计数器 | | 浏览器历史 | 使用 `page.goBack()` 和 `page.goForward()` | 导航后断言 URL 和内容 | | 双击提交 | 在提交按钮上 `dblclick()`,验证单个 POST 请求 | 在路由处理器中跟踪请求 | | 第三方服务失败 | 中止非关键路由,验证核心功能正常 | 对可选服务使用 `route.abort()` | | 控制台错误监控 | 监听 `pageerror` 事件 | `page.on('pageerror', handler)` | ## 反模式 | 不要这样做 | 问题 | 应该这样做 | | ---------------------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | | 只测试快乐路径 | 真实用户会遇到错误。生产环境中的错误比测试时间更昂贵。 | 为每个功能添加错误/边界情况测试 | | `page.route('**/*', route => route.abort())` | 会拦截所有请求,包括页面本身 | 指定目标 URL:`page.route('**/api/specific', ...)` | | 使用 `page.waitForTimeout()` 模拟慢加载 | 随意性、不稳定、拖慢测试速度 | 使用基于 Promise 的路由处理器精确控制时序 | | 在断言中硬编码错误消息 | 消息会变更。测试因文案修改而失败。 | 使用正则或部分匹配:`getByText(/error/i)` | | 在一个巨型测试中测试所有错误码 | 难以调试、速度慢,第一个失败会掩盖后续问题 | 每个错误场景一个测试,或使用 `test.describe` 分组 | | 跳过空状态测试 | 空状态是新用户最先看到的内容;往往最容易出问题 | 始终在测试 0 项状态的同时测试 1 项和 N 项 | | 通过断开真实网络来测试离线 | 不稳定、影响并行测试、CI 可能不支持 | 使用 `context.setOffline(true)`——确定性强且作用域隔离 | | 测试间未清理路由模拟 | 路由在页面上持续存在。测试 B 继承了测试 A 的模拟。 | 每个测试设置自己的路由;Playwright 默认在测试间重置 | | 对来自服务器的精确错误字符串进行断言 | 将测试与后端实现细节耦合 | 断言 UI 消息,而非原始 API 响应体 | | 使用 `try/catch` 来"处理"预期错误 | 吞掉真正的缺陷;测试本应失败时却通过 | 让错误自然传播;使用路由模拟来控制响应 | ## 故障排除 ### 路由处理器未拦截请求 **原因**:URL 模式不匹配,或路由在触发请求的导航之后才注册。 ```typescript // 始终在导航之前注册路由 await page.route("**/api/data", (route) => route.fulfill({ status: 500 })) await page.goto("/dashboard") // 页面加载前路由已激活 // 调试:记录所有请求以查找实际 URL 模式 page.on("request", (req) => console.log(req.url())) ``` ### `context.setOffline(true)` 不影响 Service Worker **原因**:Service Worker 有自己的网络处理机制。`setOffline` 在浏览器层面模拟离线,但 Service Worker 可能提供缓存响应。 ```typescript // 先注销 service worker await page.evaluate(async () => { const registrations = await navigator.serviceWorker.getRegistrations() for (const registration of registrations) { await registration.unregister() } }) await page.context().setOffline(true) ``` ### 延迟的路由处理器导致测试超时 **原因**:路由处理器中的 Promise 从未解析,或延迟超过了测试超时时间。 ```typescript // 始终确保延迟小于断言超时时间 await page.route("**/api/slow", async (route) => { await new Promise((resolve) => setTimeout(resolve, 5_000)) // 5 秒延迟 await route.fulfill({ status: 200, json: {} }) }) // 增大断言超时时间以容纳人为延迟 await expect(page.getByText("Data loaded")).toBeVisible({ timeout: 10_000 }) ``` ### `goBack()` 未导航 **原因**:没有历史记录可返回。`goBack()` 需要至少一次先前的导航。 ```typescript // 确保在调用 goBack 之前有历史记录 await page.goto("/page-a") await page.goto("/page-b") await page.goBack() // 返回到 /page-a // 如果使用 SPA 客户端路由,goBack 可能无法工作(如果路由器未推送到浏览器历史) // 改为使用应用自身的后退按钮 await page.getByRole("button", { name: "Back" }).click() ``` ## 相关文档 - [core/assertions-and-waiting.md](assertions-and-waiting.md) —— 错误状态的断言策略 - [core/network-mocking.md](network-mocking.md) —— 详细的网络拦截与模拟模式 - [core/forms-and-validation.md](forms-and-validation.md) —— 表单验证错误测试 - [core/flaky-tests.md](flaky-tests.md) —— 修复错误/边界情况测试中的时序问题 - [core/service-workers-and-pwa.md](service-workers-and-pwa.md) —— 离线优先和 PWA 测试模式 - [core/multi-context-and-popups.md](multi-context-and-popups.md) —— 测试并发浏览器上下文