1324 lines
46 KiB
Markdown
1324 lines
46 KiB
Markdown
# 网络模拟(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 缓存
|