Files
2026-07-13 21:36:47 +08:00

1324 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 网络模拟(Mocking
> **使用时机**:将前端与外部服务隔离、模拟错误状态、测试加载/空状态/错误 UI、通过避免真实网络调用来加速测试,以及针对尚不存在的 API 进行测试。
> **前置知识**[core/locators.md](locators.md)[core/assertions-and-waiting.md](assertions-and-waiting.md)
## 快速参考
```typescript
// 拦截并返回伪造数据
await page.route("**/api/users", (route) => route.fulfill({ json: [{ id: 1, name: "Jane" }] }))
// 在真实响应到达浏览器之前修改它
await page.route("**/api/users", async (route) => {
const response = await route.fetch()
const json = await response.json()
json.push({ id: 999, name: "Injected" })
await route.fulfill({ response, json })
})
// 拦截第三方脚本
await page.route("**/*.{png,jpg,svg}", (route) => route.abort())
// 等待特定的请求/响应
const responsePromise = page.waitForResponse("**/api/users")
await page.getByRole("button", { name: "Load" }).click()
await responsePromise
// HAR 回放——使用录制的响应
await page.routeFromHAR("tests/data/api.har", { url: "**/api/**" })
```
## 模式
### 路由拦截基础
**使用时机**:需要拦截页面发起的任何 HTTP 请求,以便填充、修改或阻止它。
**避免时机**:需要测试前端与后端之间的真实集成(请使用真实 API 调用)。
`page.route()` 注册一个处理程序,该处理程序会为每个匹配 URL 模式的请求执行。每个处理程序必须恰好调用 `route.fulfill()``route.continue()``route.abort()` 之一。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("route interception basics", async ({ page }) => {
// 在导航前拦截——必须先设置路由
await page.route("**/api/users", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Alice" }]),
})
})
await page.goto("/dashboard")
await expect(page.getByText("Alice")).toBeVisible()
// 完成后移除该路由(清理很重要)
await page.unroute("**/api/users")
})
test("context-level routes apply to all pages", async ({ context, page }) => {
// 在 context 上设置的路由适用于该 context 中的每个页面
await context.route("**/api/config", (route) =>
route.fulfill({ json: { theme: "dark", locale: "en" } })
)
await page.goto("/settings")
await expect(page.getByText("Dark")).toBeVisible()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("route interception basics", async ({ page }) => {
await page.route("**/api/users", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Alice" }]),
})
})
await page.goto("/dashboard")
await expect(page.getByText("Alice")).toBeVisible()
await page.unroute("**/api/users")
})
test("context-level routes apply to all pages", async ({ context, page }) => {
await context.route("**/api/config", (route) =>
route.fulfill({ json: { theme: "dark", locale: "en" } })
)
await page.goto("/settings")
await expect(page.getByText("Dark")).toBeVisible()
})
```
### 模拟 REST 响应
**使用时机**:前端依赖 REST API,并且希望使用受控数据获得确定性的即时响应。
**避免时机**:需要验证前端是否向真实 API 发送了正确的请求体或请求头(应改用 `route.continue()` 配合 `waitForRequest`)。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
const mockUsers = [
{ id: 1, name: "Alice", email: "alice@example.com", role: "admin" },
{ id: 2, name: "Bob", email: "bob@example.com", role: "user" },
]
test("mock a GET endpoint with JSON", async ({ page }) => {
await page.route("**/api/users", (route) => route.fulfill({ json: mockUsers }))
await page.goto("/users")
await expect(page.getByRole("row")).toHaveCount(3) // 表头 + 2 行数据
})
test("mock a POST endpoint and verify the request", async ({ page }) => {
await page.route("**/api/users", (route) => {
if (route.request().method() === "POST") {
return route.fulfill({
status: 201,
json: { id: 3, name: "Charlie", email: "charlie@example.com" },
})
}
// 放行其他方法
return route.continue()
})
await page.goto("/users/new")
await page.getByLabel("Name").fill("Charlie")
await page.getByLabel("Email").fill("charlie@example.com")
await page.getByRole("button", { name: "Create" }).click()
await expect(page.getByText("Charlie")).toBeVisible()
})
test("mock with custom headers and status", async ({ page }) => {
await page.route("**/api/users", (route) =>
route.fulfill({
status: 200,
headers: {
"content-type": "application/json",
"x-total-count": "42",
"x-request-id": "test-abc-123",
},
body: JSON.stringify(mockUsers),
})
)
await page.goto("/users")
await expect(page.getByText("42 total")).toBeVisible()
})
test("mock empty state", async ({ page }) => {
await page.route("**/api/users", (route) => route.fulfill({ json: [] }))
await page.goto("/users")
await expect(page.getByText("No users found")).toBeVisible()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
const mockUsers = [
{ id: 1, name: "Alice", email: "alice@example.com", role: "admin" },
{ id: 2, name: "Bob", email: "bob@example.com", role: "user" },
]
test("mock a GET endpoint with JSON", async ({ page }) => {
await page.route("**/api/users", (route) => route.fulfill({ json: mockUsers }))
await page.goto("/users")
await expect(page.getByRole("row")).toHaveCount(3)
})
test("mock a POST endpoint and verify the request", async ({ page }) => {
await page.route("**/api/users", (route) => {
if (route.request().method() === "POST") {
return route.fulfill({
status: 201,
json: { id: 3, name: "Charlie", email: "charlie@example.com" },
})
}
return route.continue()
})
await page.goto("/users/new")
await page.getByLabel("Name").fill("Charlie")
await page.getByLabel("Email").fill("charlie@example.com")
await page.getByRole("button", { name: "Create" }).click()
await expect(page.getByText("Charlie")).toBeVisible()
})
test("mock empty state", async ({ page }) => {
await page.route("**/api/users", (route) => route.fulfill({ json: [] }))
await page.goto("/users")
await expect(page.getByText("No users found")).toBeVisible()
})
```
### 模拟 GraphQL
**使用时机**:前端使用 GraphQL,并且希望按操作名称(operation name)模拟特定的查询或变更(mutation)。
**避免时机**:GraphQL 端点属于你自己的后端,并且希望获得完整的集成覆盖率。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("mock a GraphQL query by operation name", async ({ page }) => {
await page.route("**/graphql", async (route) => {
const request = route.request()
const postData = request.postDataJSON()
if (postData.operationName === "GetUsers") {
return route.fulfill({
json: {
data: {
users: [
{ id: "1", name: "Alice", email: "alice@example.com" },
{ id: "2", name: "Bob", email: "bob@example.com" },
],
},
},
})
}
if (postData.operationName === "GetUser") {
return route.fulfill({
json: {
data: {
user: { id: "1", name: "Alice", email: "alice@example.com" },
},
},
})
}
// 未模拟的操作放行到真实服务器
return route.continue()
})
await page.goto("/users")
await expect(page.getByText("Alice")).toBeVisible()
await expect(page.getByText("Bob")).toBeVisible()
})
test("mock a GraphQL mutation", async ({ page }) => {
await page.route("**/graphql", async (route) => {
const { operationName, variables } = route.request().postDataJSON()
if (operationName === "CreateUser") {
return route.fulfill({
json: {
data: {
createUser: {
id: "99",
name: variables.input.name,
email: variables.input.email,
},
},
},
})
}
return route.continue()
})
await page.goto("/users/new")
await page.getByLabel("Name").fill("Charlie")
await page.getByLabel("Email").fill("charlie@example.com")
await page.getByRole("button", { name: "Create" }).click()
await expect(page.getByText("Charlie")).toBeVisible()
})
test("mock GraphQL errors", async ({ page }) => {
await page.route("**/graphql", async (route) => {
const { operationName } = route.request().postDataJSON()
if (operationName === "GetUsers") {
return route.fulfill({
json: {
data: null,
errors: [
{
message: "Not authorized",
extensions: { code: "UNAUTHORIZED" },
},
],
},
})
}
return route.continue()
})
await page.goto("/users")
await expect(page.getByText("Not authorized")).toBeVisible()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("mock a GraphQL query by operation name", async ({ page }) => {
await page.route("**/graphql", async (route) => {
const postData = route.request().postDataJSON()
if (postData.operationName === "GetUsers") {
return route.fulfill({
json: {
data: {
users: [
{ id: "1", name: "Alice", email: "alice@example.com" },
{ id: "2", name: "Bob", email: "bob@example.com" },
],
},
},
})
}
return route.continue()
})
await page.goto("/users")
await expect(page.getByText("Alice")).toBeVisible()
await expect(page.getByText("Bob")).toBeVisible()
})
test("mock a GraphQL mutation", async ({ page }) => {
await page.route("**/graphql", async (route) => {
const { operationName, variables } = route.request().postDataJSON()
if (operationName === "CreateUser") {
return route.fulfill({
json: {
data: {
createUser: {
id: "99",
name: variables.input.name,
email: variables.input.email,
},
},
},
})
}
return route.continue()
})
await page.goto("/users/new")
await page.getByLabel("Name").fill("Charlie")
await page.getByLabel("Email").fill("charlie@example.com")
await page.getByRole("button", { name: "Create" }).click()
await expect(page.getByText("Charlie")).toBeVisible()
})
```
### 修改响应
**使用时机**:需要真实的 API 响应,但想调整特定字段——注入测试数据、覆盖功能开关、在真实数据中模拟边界情况。
**避免时机**:可以完全模拟响应时。`route.fetch()` 会增加一次真实的网络往返,因此速度较慢。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("modify a real API response", async ({ page }) => {
await page.route("**/api/users", async (route) => {
// 从服务器获取真实响应
const response = await route.fetch()
const users = await response.json()
// 在真实数据中注入一个测试用户
users.push({ id: 999, name: "Test User", email: "test@example.com" })
await route.fulfill({ response, json: users })
})
await page.goto("/users")
await expect(page.getByText("Test User")).toBeVisible()
})
test("override feature flags from real config", async ({ page }) => {
await page.route("**/api/config", async (route) => {
const response = await route.fetch()
const config = await response.json()
// 为测试启用某个功能开关
config.featureFlags = {
...config.featureFlags,
newCheckout: true,
darkMode: true,
}
await route.fulfill({ response, json: config })
})
await page.goto("/settings")
await expect(page.getByRole("switch", { name: "Dark mode" })).toBeVisible()
})
test("modify response headers", async ({ page }) => {
await page.route("**/api/data", async (route) => {
const response = await route.fetch()
await route.fulfill({
response,
headers: {
...response.headers(),
"cache-control": "no-cache",
"x-test-header": "injected",
},
})
})
await page.goto("/data")
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("modify a real API response", async ({ page }) => {
await page.route("**/api/users", async (route) => {
const response = await route.fetch()
const users = await response.json()
users.push({ id: 999, name: "Test User", email: "test@example.com" })
await route.fulfill({ response, json: users })
})
await page.goto("/users")
await expect(page.getByText("Test User")).toBeVisible()
})
test("override feature flags from real config", async ({ page }) => {
await page.route("**/api/config", async (route) => {
const response = await route.fetch()
const config = await response.json()
config.featureFlags = {
...config.featureFlags,
newCheckout: true,
darkMode: true,
}
await route.fulfill({ response, json: config })
})
await page.goto("/settings")
await expect(page.getByRole("switch", { name: "Dark mode" })).toBeVisible()
})
```
### 请求拦截
**使用时机**:拦截分析脚本、广告、第三方脚本、图片或字体,以加速测试并消除外部依赖导致的不稳定性。
**避免时机**:被拦截的资源是被测功能所必需的。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("block analytics and tracking scripts", async ({ page }) => {
await page.route(/(google-analytics|segment|hotjar|mixpanel)/, (route) => route.abort())
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
test("block images to speed up tests", async ({ page }) => {
await page.route("**/*.{png,jpg,jpeg,gif,svg,webp}", (route) => route.abort())
await page.goto("/gallery")
// 测试交互,无需等待图片下载
await page.getByRole("button", { name: "Next" }).click()
})
test("block specific third-party domains", async ({ page }) => {
const blockedDomains = ["ads.example.com", "tracker.example.com", "cdn.slow-service.com"]
await page.route("**/*", (route) => {
const url = new URL(route.request().url())
if (blockedDomains.includes(url.hostname)) {
return route.abort()
}
return route.continue()
})
await page.goto("/home")
})
test("block by resource type", async ({ context }) => {
// 在 context 级别拦截,影响所有页面
await context.route("**/*", (route) => {
const resourceType = route.request().resourceType()
if (["image", "font", "media"].includes(resourceType)) {
return route.abort()
}
return route.continue()
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("block analytics and tracking scripts", async ({ page }) => {
await page.route(/(google-analytics|segment|hotjar|mixpanel)/, (route) => route.abort())
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
test("block images to speed up tests", async ({ page }) => {
await page.route("**/*.{png,jpg,jpeg,gif,svg,webp}", (route) => route.abort())
await page.goto("/gallery")
await page.getByRole("button", { name: "Next" }).click()
})
test("block by resource type", async ({ context }) => {
await context.route("**/*", (route) => {
const resourceType = route.request().resourceType()
if (["image", "font", "media"].includes(resourceType)) {
return route.abort()
}
return route.continue()
})
})
```
### HAR 录制与回放
**使用时机**:希望捕获一次真实的网络流量,然后在测试中回放,以获得速度和确定性。适用于具有大量端点的复杂 API,或 API 访问受限的场景。
**避免时机**:API 响应频繁变化,过时的录制文件会导致误通过。请将 HAR 文件纳入版本控制并定期更新。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
// 录制 HAR 文件——运行一次以捕获流量,然后回放
test("record HAR for later replay", async ({ page }) => {
// 这将把所有匹配的网络流量录制到 HAR 文件中。
// 如果文件已存在且响应匹配,则从 HAR 提供响应。
// 如果请求在 HAR 中没有匹配项,则会回退到网络请求,
// 新的响应会追加到 HAR 文件中。
await page.routeFromHAR("tests/data/users-api.har", {
url: "**/api/**",
update: true, // 设为 true 进行录制,设为 false(或省略)进行回放
})
await page.goto("/users")
await expect(page.getByRole("row")).toHaveCount(6)
})
// 从之前录制的 HAR 文件回放
test("replay from HAR", async ({ page }) => {
await page.routeFromHAR("tests/data/users-api.har", {
url: "**/api/**",
// update: false 是默认值——从 HAR 文件提供响应
})
await page.goto("/users")
await expect(page.getByRole("row")).toHaveCount(6)
})
// HAR 的 notFound 选项——控制未匹配请求的行为
test("HAR replay with fallback behavior", async ({ page }) => {
await page.routeFromHAR("tests/data/users-api.har", {
url: "**/api/**",
notFound: "abort", // 'abort' 使未匹配请求失败;'fallback' 放行它们
})
await page.goto("/users")
await expect(page.getByText("Alice")).toBeVisible()
})
// 在 context 级别进行 HAR 回放
test("HAR replay at context level", async ({ context, page }) => {
await context.routeFromHAR("tests/data/full-app.har", {
url: "**/api/**",
})
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("record HAR for later replay", async ({ page }) => {
await page.routeFromHAR("tests/data/users-api.har", {
url: "**/api/**",
update: true,
})
await page.goto("/users")
await expect(page.getByRole("row")).toHaveCount(6)
})
test("replay from HAR", async ({ page }) => {
await page.routeFromHAR("tests/data/users-api.har", {
url: "**/api/**",
})
await page.goto("/users")
await expect(page.getByRole("row")).toHaveCount(6)
})
test("HAR replay with fallback behavior", async ({ page }) => {
await page.routeFromHAR("tests/data/users-api.har", {
url: "**/api/**",
notFound: "abort",
})
await page.goto("/users")
await expect(page.getByText("Alice")).toBeVisible()
})
```
### 条件模拟
**使用时机**:需要根据请求方法、请求体、请求头或查询参数返回不同的响应。常用于分页 API、搜索端点和基于角色的访问控制。
**避免时机**:简单的静态模拟即可满足需求。不要过度设计路由处理程序。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("respond based on request method", async ({ page }) => {
await page.route("**/api/users", (route) => {
const method = route.request().method()
switch (method) {
case "GET":
return route.fulfill({
json: [{ id: 1, name: "Alice" }],
})
case "POST":
return route.fulfill({
status: 201,
json: { id: 2, name: "Bob" },
})
case "DELETE":
return route.fulfill({ status: 204, body: "" })
default:
return route.continue()
}
})
await page.goto("/users")
await expect(page.getByText("Alice")).toBeVisible()
})
test("respond based on query parameters", async ({ page }) => {
await page.route("**/api/users*", (route) => {
const url = new URL(route.request().url())
const page_num = parseInt(url.searchParams.get("page") || "1")
const role = url.searchParams.get("role")
const allUsers = [
{ id: 1, name: "Alice", role: "admin" },
{ id: 2, name: "Bob", role: "user" },
{ id: 3, name: "Charlie", role: "user" },
{ id: 4, name: "Diana", role: "admin" },
]
let filtered = allUsers
if (role) {
filtered = allUsers.filter((u) => u.role === role)
}
const perPage = 2
const start = (page_num - 1) * perPage
const paginated = filtered.slice(start, start + perPage)
return route.fulfill({
json: paginated,
headers: {
"content-type": "application/json",
"x-total-count": String(filtered.length),
},
})
})
await page.goto("/users")
await expect(page.getByRole("row")).toHaveCount(3) // 表头 + 2 行
})
test("respond based on request body", async ({ page }) => {
await page.route("**/api/search", (route) => {
const body = route.request().postDataJSON()
const query = body?.query?.toLowerCase() || ""
const results = {
playwright: [{ title: "Playwright Docs" }, { title: "Playwright GitHub" }],
cypress: [{ title: "Cypress Docs" }],
}
return route.fulfill({
json: results[query] || [],
})
})
await page.goto("/search")
await page.getByLabel("Search").fill("playwright")
await page.getByRole("button", { name: "Search" }).click()
await expect(page.getByRole("listitem")).toHaveCount(2)
})
test("respond based on request headers", async ({ page }) => {
await page.route("**/api/users", (route) => {
const authHeader = route.request().headers()["authorization"]
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return route.fulfill({
status: 401,
json: { error: "Unauthorized" },
})
}
return route.fulfill({
json: [{ id: 1, name: "Alice" }],
})
})
await page.goto("/login")
await expect(page.getByText("Unauthorized")).toBeVisible()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("respond based on request method", async ({ page }) => {
await page.route("**/api/users", (route) => {
const method = route.request().method()
switch (method) {
case "GET":
return route.fulfill({ json: [{ id: 1, name: "Alice" }] })
case "POST":
return route.fulfill({ status: 201, json: { id: 2, name: "Bob" } })
case "DELETE":
return route.fulfill({ status: 204, body: "" })
default:
return route.continue()
}
})
await page.goto("/users")
await expect(page.getByText("Alice")).toBeVisible()
})
test("respond based on query parameters", async ({ page }) => {
await page.route("**/api/users*", (route) => {
const url = new URL(route.request().url())
const role = url.searchParams.get("role")
const allUsers = [
{ id: 1, name: "Alice", role: "admin" },
{ id: 2, name: "Bob", role: "user" },
{ id: 3, name: "Charlie", role: "user" },
]
const filtered = role ? allUsers.filter((u) => u.role === role) : allUsers
return route.fulfill({ json: filtered })
})
await page.goto("/users?role=admin")
await expect(page.getByText("Alice")).toBeVisible()
})
test("respond based on request body", async ({ page }) => {
await page.route("**/api/search", (route) => {
const body = route.request().postDataJSON()
const query = body?.query?.toLowerCase() || ""
const results = {
playwright: [{ title: "Playwright Docs" }, { title: "Playwright GitHub" }],
cypress: [{ title: "Cypress Docs" }],
}
return route.fulfill({ json: results[query] || [] })
})
await page.goto("/search")
await page.getByLabel("Search").fill("playwright")
await page.getByRole("button", { name: "Search" }).click()
await expect(page.getByRole("listitem")).toHaveCount(2)
})
```
### 网络错误模拟
**使用时机**:测试 UI 如何处理服务器错误、超时和连接失败。对于验证错误边界、重试逻辑和降级模式下的用户体验至关重要。
**避免时机**:测试针对的是正常路径。仅在测试错误处理时才引入错误。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("simulate a 500 server error", async ({ page }) => {
await page.route("**/api/users", (route) =>
route.fulfill({
status: 500,
json: { error: "Internal Server Error" },
})
)
await page.goto("/users")
await expect(page.getByText("Something went wrong")).toBeVisible()
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible()
})
test("simulate a 403 forbidden", async ({ page }) => {
await page.route("**/api/admin/**", (route) =>
route.fulfill({
status: 403,
json: { error: "Forbidden", message: "Admin access required" },
})
)
await page.goto("/admin/settings")
await expect(page.getByText("Admin access required")).toBeVisible()
})
test("simulate a 404 not found", async ({ page }) => {
await page.route("**/api/users/999", (route) =>
route.fulfill({
status: 404,
json: { error: "User not found" },
})
)
await page.goto("/users/999")
await expect(page.getByText("User not found")).toBeVisible()
})
test("simulate a network error (connection refused)", async ({ page }) => {
await page.route("**/api/users", (route) => route.abort("connectionrefused"))
await page.goto("/users")
await expect(page.getByText("Network error")).toBeVisible()
})
test("simulate a timeout", async ({ page }) => {
await page.route("**/api/users", async (route) => {
// 延迟时间超过应用的 fetch 超时时间,以触发超时错误
await new Promise((resolve) => setTimeout(resolve, 30_000))
await route.fulfill({ json: [] })
})
await page.goto("/users")
// 应用应在路由解决之前显示超时信息
await expect(page.getByText("Request timed out")).toBeVisible({
timeout: 15_000,
})
})
test("simulate intermittent failures then recovery", async ({ page }) => {
let requestCount = 0
await page.route("**/api/users", (route) => {
requestCount++
if (requestCount <= 2) {
return route.fulfill({
status: 503,
json: { error: "Service Unavailable" },
})
}
return route.fulfill({
json: [{ id: 1, name: "Alice" }],
})
})
await page.goto("/users")
await expect(page.getByText("Something went wrong")).toBeVisible()
// 模拟用户点击重试(触发第 3 次请求)
await page.getByRole("button", { name: "Retry" }).click()
await expect(page.getByText("Something went wrong")).toBeVisible()
// 第三次尝试成功
await page.getByRole("button", { name: "Retry" }).click()
await expect(page.getByText("Alice")).toBeVisible()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("simulate a 500 server error", async ({ page }) => {
await page.route("**/api/users", (route) =>
route.fulfill({
status: 500,
json: { error: "Internal Server Error" },
})
)
await page.goto("/users")
await expect(page.getByText("Something went wrong")).toBeVisible()
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible()
})
test("simulate a network error (connection refused)", async ({ page }) => {
await page.route("**/api/users", (route) => route.abort("connectionrefused"))
await page.goto("/users")
await expect(page.getByText("Network error")).toBeVisible()
})
test("simulate intermittent failures then recovery", async ({ page }) => {
let requestCount = 0
await page.route("**/api/users", (route) => {
requestCount++
if (requestCount <= 2) {
return route.fulfill({
status: 503,
json: { error: "Service Unavailable" },
})
}
return route.fulfill({
json: [{ id: 1, name: "Alice" }],
})
})
await page.goto("/users")
await expect(page.getByText("Something went wrong")).toBeVisible()
await page.getByRole("button", { name: "Retry" }).click()
await expect(page.getByText("Something went wrong")).toBeVisible()
await page.getByRole("button", { name: "Retry" }).click()
await expect(page.getByText("Alice")).toBeVisible()
})
```
**中断原因**`'aborted'``'accessdenied'``'addressunreachable'``'blockedbyclient'``'blockedbyresponse'``'connectionaborted'``'connectionclosed'``'connectionfailed'``'connectionrefused'``'connectionreset'``'internetdisconnected'``'namenotresolved'``'timedout'``'failed'`
### 请求等待
**使用时机**:使测试与网络活动同步——在断言 UI 之前等待请求发出或响应到达。
**避免时机**:基于定位器的 web-first 断言已足够。仅当需要检查请求/响应本身时才使用请求等待。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("wait for response and verify UI updates", async ({ page }) => {
await page.goto("/users")
// 关键:在触发操作之前先设置等待
const responsePromise = page.waitForResponse("**/api/users")
await page.getByRole("button", { name: "Refresh" }).click()
const response = await responsePromise
expect(response.status()).toBe(200)
const users = await response.json()
expect(users).toHaveLength(5)
})
test("wait for request and verify payload", async ({ page }) => {
await page.goto("/users/new")
const requestPromise = page.waitForRequest("**/api/users")
await page.getByLabel("Name").fill("Alice")
await page.getByLabel("Email").fill("alice@example.com")
await page.getByRole("button", { name: "Create" }).click()
const request = await requestPromise
expect(request.method()).toBe("POST")
expect(request.postDataJSON()).toMatchObject({
name: "Alice",
email: "alice@example.com",
})
})
test("wait for response with predicate function", async ({ page }) => {
await page.goto("/dashboard")
// 等待匹配自定义条件的特定响应
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes("/api/users") &&
response.status() === 200 &&
response.request().method() === "GET"
)
await page.getByRole("button", { name: "Load users" }).click()
const response = await responsePromise
const data = await response.json()
expect(data.length).toBeGreaterThan(0)
})
test("wait for multiple requests in sequence", async ({ page }) => {
await page.goto("/checkout")
// 等待流程中发生的多个 API 调用
const [validateResponse, submitResponse] = await Promise.all([
page.waitForResponse("**/api/cart/validate"),
page.waitForResponse("**/api/orders"),
page.getByRole("button", { name: "Place order" }).click(),
])
expect(validateResponse.status()).toBe(200)
expect(submitResponse.status()).toBe(201)
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("wait for response and verify UI updates", async ({ page }) => {
await page.goto("/users")
const responsePromise = page.waitForResponse("**/api/users")
await page.getByRole("button", { name: "Refresh" }).click()
const response = await responsePromise
expect(response.status()).toBe(200)
const users = await response.json()
expect(users).toHaveLength(5)
})
test("wait for request and verify payload", async ({ page }) => {
await page.goto("/users/new")
const requestPromise = page.waitForRequest("**/api/users")
await page.getByLabel("Name").fill("Alice")
await page.getByLabel("Email").fill("alice@example.com")
await page.getByRole("button", { name: "Create" }).click()
const request = await requestPromise
expect(request.method()).toBe("POST")
expect(request.postDataJSON()).toMatchObject({
name: "Alice",
email: "alice@example.com",
})
})
test("wait for multiple requests in sequence", async ({ page }) => {
await page.goto("/checkout")
const [validateResponse, submitResponse] = await Promise.all([
page.waitForResponse("**/api/cart/validate"),
page.waitForResponse("**/api/orders"),
page.getByRole("button", { name: "Place order" }).click(),
])
expect(validateResponse.status()).toBe(200)
expect(submitResponse.status()).toBe(201)
})
```
### Glob 模式与 URL 匹配
**使用时机**:需要使用通配符、部分路径或正则表达式来匹配 URL。每个 `page.route()``waitForRequest()``waitForResponse()` 都接受 glob 模式、字符串或正则表达式。
**避免时机**:URL 已知且固定。请使用精确字符串。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("glob pattern examples", async ({ page }) => {
// ** 匹配任意路径段(包括嵌套)
await page.route("**/api/users", (route) => route.fulfill({ json: [] }))
// 匹配:https://example.com/api/users
// 匹配:https://example.com/v2/api/users
// * 匹配单个路径段
await page.route("**/api/users/*/orders", (route) => route.fulfill({ json: [] }))
// 匹配:/api/users/123/orders
// 匹配:/api/users/abc/orders
// 不匹配:/api/users/123/456/orders
// 匹配文件扩展名
await page.route("**/*.{png,jpg,svg}", (route) => route.abort())
// 匹配:/images/logo.png/assets/photo.jpg
// 使用 * 匹配查询字符串
await page.route("**/api/search?q=*", (route) => route.fulfill({ json: [] }))
// 匹配:/api/search?q=anything
// 使用正则表达式处理复杂模式
await page.route(/\/api\/users\/\d+$/, (route) =>
route.fulfill({ json: { id: 1, name: "Alice" } })
)
// 匹配:/api/users/123/api/users/456
// 不匹配:/api/users/abc/api/users/123/orders
// 使用带捕获组的正则表达式实现动态响应
await page.route(/\/api\/users\/(\d+)/, (route) => {
const match = route
.request()
.url()
.match(/\/api\/users\/(\d+)/)
const userId = match ? match[1] : "0"
return route.fulfill({
json: { id: parseInt(userId), name: `User ${userId}` },
})
})
await page.goto("/users")
})
test("match all requests on a domain", async ({ page }) => {
// 拦截来自特定域名的所有请求
await page.route("https://analytics.example.com/**", (route) => route.abort())
await page.goto("/dashboard")
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("glob pattern examples", async ({ page }) => {
// ** 匹配任意路径段
await page.route("**/api/users", (route) => route.fulfill({ json: [] }))
// * 匹配单个路径段
await page.route("**/api/users/*/orders", (route) => route.fulfill({ json: [] }))
// 匹配文件扩展名
await page.route("**/*.{png,jpg,svg}", (route) => route.abort())
// 使用正则表达式处理复杂模式
await page.route(/\/api\/users\/\d+$/, (route) =>
route.fulfill({ json: { id: 1, name: "Alice" } })
)
// 使用带动态响应的正则表达式
await page.route(/\/api\/users\/(\d+)/, (route) => {
const match = route
.request()
.url()
.match(/\/api\/users\/(\d+)/)
const userId = match ? match[1] : "0"
return route.fulfill({
json: { id: parseInt(userId), name: `User ${userId}` },
})
})
await page.goto("/users")
})
test("match all requests on a domain", async ({ page }) => {
await page.route("https://analytics.example.com/**", (route) => route.abort())
await page.goto("/dashboard")
})
```
**模式参考**
| 模式 | 匹配 | 不匹配 |
| -------------------------------- | ---------------------------------------- | ------------------------ |
| `**/api/users` | `/api/users``/v2/api/users` | `/api/users/1` |
| `**/api/users*` | `/api/users``/api/users?page=1` | `/api/users/1` |
| `**/api/users/**` | `/api/users/1``/api/users/1/orders` | `/api/users` |
| `**/api/users/*/orders` | `/api/users/1/orders` | `/api/users/1/2/orders` |
| `**/*.{png,jpg}` | `/logo.png``/deep/path/img.jpg` | `/file.svg` |
| `/\/api\/users\/\d+$/`(正则) | `/api/users/123` | `/api/users/abc` |
## 决策指南
| 场景 | 使用 | 原因 |
| -------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------ |
| 前端依赖外部 API | `route.fulfill()` | 确定性数据,无外部依赖,速度快 |
| 需要测试真实 API 但调整某个字段 | `route.fetch()` + 修改 + `route.fulfill()` | 以真实数据为基线,仅覆盖需要修改的部分 |
| 使用真实后端测试正常路径 | `route.continue()`(或不设路由) | 完整的集成覆盖率 |
| 拦截分析/广告/第三方干扰 | `route.abort()` | 测试更快,消除外部服务导致的不稳定性 |
| 具有许多端点的复杂 API | `page.routeFromHAR()` 配合 `update: true` | 录制一次,永久回放;测试代码最少 |
| 测试错误处理(500、超时) | `route.fulfill({ status: 500 })``route.abort('timedout')` | 确定性地模拟错误 |
| 验证前端发送的请求载荷 | `page.waitForRequest()` + 断言 | 确认前端发送了正确的数据 |
| 在检查 UI 之前验证响应数据 | `page.waitForResponse()` + 断言 | 在断言 DOM 之前确认数据已到达 |
| 多个测试需要相同的模拟 | `context.route()` 或 fixture | 跨测试共享路由,避免重复 |
| 测试加载中的 spinner / 骨架屏 UI | `route.fulfill()` 加延迟(通过 `setTimeout`) | 精确控制响应时间 |
| 分页或基于搜索的 API | 条件模拟(检查查询参数/请求体) | 根据请求内容返回动态响应 |
## 反模式
| 不要这样做 | 问题 | 应该这样做 |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| 模拟自己应用的页面和静态资源 | 你测试的是一个伪造的应用,而不是真实的应用 | 仅模拟 API/数据端点,绝不模拟应用提供的 HTML/JS/CSS |
| 在每个测试中硬编码模拟数据 | 数据重复,API 变更时难以更新 | 将模拟数据提取到共享 fixture 中(`tests/data/users.json`) |
| API 变更后从不更新模拟数据 | 测试针对过时数据通过,真实应用却崩溃 | 使用 HAR 录制并定期运行 `update: true`,或根据 OpenAPI 模式验证模拟数据形状 |
| 在类似生产环境的 E2E 测试中进行模拟 | 失去集成信心 | 为冒烟/集成测试保留一个使用真实后端的独立测试套件;仅在组件和隔离的 UI 测试中使用模拟 |
| 忘记调用 `route.fulfill()``route.continue()``route.abort()` | 请求挂起,测试超时并报出令人困惑的错误 | 每个路由处理程序必须恰好调用这三种方法之一 |
| 在 `page.goto()` 之后才设置路由 | 在路由注册之前,导航期间的请求已经发出 | 始终在 `page.goto()` 之前调用 `page.route()` |
| 在触发操作之后才使用 `waitForResponse` | 竞态条件:响应可能在等待注册之前就已到达 | 始终在操作之前设置 promise:`const p = page.waitForResponse(...); await click(); await p;` |
| 使用 `route.continue()` 并以为它在模拟 | `route.continue()` 将请求传递给真实服务器 | 使用 `route.fulfill()` 返回伪造数据 |
| 不加过滤地使用过于宽泛的 glob 模式(`**/*`) | 捕获所有请求(包括 HTML、JS、CSS),导致应用崩溃 | 指定具体范围:`**/api/**` 或按 `resourceType()` 过滤 |
| 忘记对路由设置或 fulfill 使用 `await` | 路由在导航开始时可能尚未激活 | 始终 `await page.route(...)``await route.fulfill(...)` |
| 在测试中途切换模拟时未调用 `page.unroute()` | 旧的路由处理程序仍然触发,新的被忽略,或两者都触发 | 在注册同一模式的新处理程序之前调用 `page.unroute()` |
| 使用 `page.on('request')` 进行模拟 | 事件监听器是只读的,无法修改或完成请求 | 使用 `page.route()` 进行拦截;`page.on('request')` 仅用于日志记录 |
## 故障排查
### 路由处理程序从未触发
**原因**:URL 模式与实际请求 URL 不匹配。常见于基础 URL 包含端口号、路径前缀,或请求使用了不同的协议。
**修复**
- 记录所有请求以找到确切的 URL
```typescript
page.on("request", (req) => console.log(req.method(), req.url()))
```
- 确保 glob 模式正确匹配。`**/api/users` 不匹配 `http://localhost:3000/api/users?page=1`——请使用 `**/api/users*` 以包含查询字符串。
- 检查 `page.route()` 是否在 `page.goto()` 之前调用。
### 路由处理程序触发了,但测试仍然超时
**原因**:处理程序抛出错误,或从未调用 `fulfill`/`continue`/`abort`
**修复**
- 在路由处理程序内部添加错误处理。
- 确保处理程序中的每个代码路径都以三种解决方法之一结束。
```typescript
await page.route("**/api/users", async (route) => {
try {
const data = getTestData() // 这里可能抛出异常
await route.fulfill({ json: data })
} catch (error) {
console.error("Route handler error:", error)
await route.abort()
}
})
```
### 模拟的响应被忽略——应用显示真实数据
**原因**:路由在页面级别注册,但请求是由 Service Worker 或不同的浏览器 context 发出的。
**修复**
- 使用 `context.route()` 而不是 `page.route()` 来覆盖所有页面和 Service Worker。
- 在配置中禁用 Service Worker(如果它们造成干扰):
```typescript
// playwright.config.ts
export default defineConfig({
use: {
serviceWorkers: "block",
},
})
```
### `route.fetch()` 导致无限循环
**原因**`route.fetch()` 重新发出请求,如果 URL 模式匹配,可能会重新触发同一个路由处理程序。
**修复**:对于同一个路由处理程序,Playwright 会正确处理——`route.fetch()` 不会重新进入调用它的处理程序。但如果有多个重叠的路由处理程序,它们可能会相互干扰。请简化为每个 URL 模式使用一个处理程序,或使用 `route.fetch({ url: 'different-url' })` 进行重定向。
### HAR 回放返回错误的响应
**原因**:HAR 文件通过 URL 匹配请求,有时也通过 POST 请求体匹配。如果请求体发生变化(例如时间戳、CSRF token),匹配会失败。
**修复**
- 使用 `update: true` 重新录制 HAR 文件。
- 使用 `notFound: 'fallback'` 让未匹配的请求发送到真实服务器。
- 对于具有动态请求体的 POST 请求,考虑使用 `page.route()` 配合手动匹配,而不是 HAR。
## 相关
- [core/when-to-mock.md](when-to-mock.md)——何时模拟与使用真实服务的决策框架
- [core/api-testing.md](api-testing.md)——直接测试 REST 和 GraphQL API(无需浏览器)
- [core/assertions-and-waiting.md](assertions-and-waiting.md)——web-first 断言和 `waitForResponse` 模式
- [core/authentication.md](authentication.md)——模拟认证 token 和会话状态
- [core/error-and-edge-cases.md](error-and-edge-cases.md)——超出网络错误范围的错误状态测试模式
- [core/service-workers-and-pwa.md](service-workers-and-pwa.md)——处理干扰模拟的 Service Worker 缓存