39 KiB
错误状态与边界情况
使用场景:测试应用程序如何处理错误、失败、边界条件和异常用户行为。这些测试能捕获快乐路径测试遗漏的缺陷。 前置条件:core/assertions-and-waiting.md、core/network-mocking.md(用于路由拦截)
快速参考
// 模拟 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
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
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
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
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
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 = [
'<script>alert("xss")</script>',
'"; DROP TABLE users; --',
"unicode: \u00e9\u00e0\u00fc\u00f1 \u4f60\u597d \ud83d\ude80",
"null bytes: \x00\x01\x02",
"path traversal: ../../etc/passwd",
]
for (const input of specialInputs) {
await page.getByLabel("Search").fill(input)
await page.getByRole("button", { name: "Search" }).click()
// 应用不应崩溃——要么显示结果,要么显示"无结果"
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
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 = [
'<script>alert("xss")</script>',
'"; DROP TABLE users; --',
"unicode: \u00e9\u00e0\u00fc\u00f1 \u4f60\u597d \ud83d\ude80",
"path traversal: ../../etc/passwd",
]
for (const input of specialInputs) {
await page.getByLabel("Search").fill(input)
await page.getByRole("button", { name: "Search" }).click()
await expect(page.getByText(/results|no results|no matches/i)).toBeVisible()
}
})
test("处理零个、一个和多个项目(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
import { test, expect } from "@playwright/test"
test("骨架屏出现并解析完成", async ({ page }) => {
// 延迟 API 响应以观察加载状态
let resolveResponse: () => void
const responseReady = new Promise<void>((resolve) => {
resolveResponse = resolve
})
await page.route("**/api/dashboard", async (route) => {
await responseReady
await route.fulfill({
status: 200,
json: { revenue: 12400, users: 350 },
})
})
await page.goto("/dashboard")
// 加载时骨架屏应可见
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<void>((resolve) => {
resolveSubmit = resolve
})
await page.route("**/api/contact", async (route) => {
await submitReady
await route.fulfill({ status: 200, json: { success: true } })
})
await page.goto("/contact")
await page.getByLabel("Name").fill("Jane")
await page.getByLabel("Email").fill("jane@example.com")
await page.getByLabel("Message").fill("Test")
await page.getByRole("button", { name: "Send" }).click()
// 提交期间的加载动画/状态
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
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
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
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
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
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
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
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
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
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 模式不匹配,或路由在触发请求的导航之后才注册。
// 始终在导航之前注册路由
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 可能提供缓存响应。
// 先注销 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 从未解析,或延迟超过了测试超时时间。
// 始终确保延迟小于断言超时时间
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() 需要至少一次先前的导航。
// 确保在调用 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 —— 错误状态的断言策略
- core/network-mocking.md —— 详细的网络拦截与模拟模式
- core/forms-and-validation.md —— 表单验证错误测试
- core/flaky-tests.md —— 修复错误/边界情况测试中的时序问题
- core/service-workers-and-pwa.md —— 离线优先和 PWA 测试模式
- core/multi-context-and-popups.md —— 测试并发浏览器上下文