skillhub-147-playwright-skill
1126 行
39 KiB
Markdown
1126 行
39 KiB
Markdown
# 错误状态与边界情况
|
||
|
||
> **使用场景**:测试应用程序如何处理错误、失败、边界条件和异常用户行为。这些测试能捕获快乐路径测试遗漏的缺陷。
|
||
> **前置条件**:[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 = [
|
||
'<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**
|
||
|
||
```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**
|
||
|
||
```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**
|
||
|
||
```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) —— 测试并发浏览器上下文
|