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

39 KiB
Raw Permalink Blame History

错误状态与边界情况

使用场景:测试应用程序如何处理错误、失败、边界条件和异常用户行为。这些测试能捕获快乐路径测试遗漏的缺陷。 前置条件core/assertions-and-waiting.mdcore/network-mocking.md(用于路由拦截)

快速参考

// 模拟 500 服务器错误
await page.route("**/api/data", (route) => route.fulfill({ status: 500 }))

// 模拟离线模式
await page.context().setOffline(true)

// 测试空状态
await page.route("**/api/items", (route) => route.fulfill({ status: 200, json: [] }))

// 浏览器前进/后退
await page.goBack()
await page.goForward()

// 中止网络请求(模拟网络故障)
await page.route("**/api/save", (route) => route.abort("connectionfailed"))

模式

HTTP 错误状态码

使用场景:测试应用程序是否为 4xx 和 5xx 响应正确显示错误页面或提示信息。 避免场景:错误被静默处理(没有面向用户的反馈)。改为通过 API 或日志测试。

TypeScript

import { test, expect } from "@playwright/test"

test("显示 404 页面(资源不存在)", async ({ page }) => {
  // 直接导航到不存在的 URL
  await page.goto("/this-page-does-not-exist")

  await expect(page.getByRole("heading", { name: /not found/i })).toBeVisible()
  await expect(page.getByRole("link", { name: "Go home" })).toBeVisible()
})

test("优雅处理 500 服务器错误", async ({ page }) => {
  // 拦截 API 调用并返回 500
  await page.route("**/api/dashboard", (route) =>
    route.fulfill({
      status: 500,
      contentType: "application/json",
      body: JSON.stringify({ error: "Internal server error" }),
    })
  )

  await page.goto("/dashboard")

  await expect(page.getByText("Something went wrong")).toBeVisible()
  await expect(page.getByRole("button", { name: "Try again" })).toBeVisible()
})

test("处理 403 禁止访问并重定向到登录页", async ({ page }) => {
  await page.route("**/api/admin/**", (route) => route.fulfill({ status: 403 }))

  await page.goto("/admin/settings")

  // 应重定向到登录页或显示拒绝访问
  await expect(page.getByText(/access denied|not authorized/i)).toBeVisible()
})

test("处理 429 请求限流", async ({ page }) => {
  await page.route("**/api/search*", (route) =>
    route.fulfill({
      status: 429,
      headers: { "Retry-After": "30" },
      body: JSON.stringify({ error: "Too many requests" }),
    })
  )

  await page.goto("/search")
  await page.getByLabel("Search").fill("test")
  await page.getByRole("button", { name: "Search" }).click()

  await expect(page.getByText(/too many requests|try again later/i)).toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("显示 404 页面(资源不存在)", async ({ page }) => {
  await page.goto("/this-page-does-not-exist")

  await expect(page.getByRole("heading", { name: /not found/i })).toBeVisible()
  await expect(page.getByRole("link", { name: "Go home" })).toBeVisible()
})

test("优雅处理 500 服务器错误", async ({ page }) => {
  await page.route("**/api/dashboard", (route) =>
    route.fulfill({
      status: 500,
      contentType: "application/json",
      body: JSON.stringify({ error: "Internal server error" }),
    })
  )

  await page.goto("/dashboard")

  await expect(page.getByText("Something went wrong")).toBeVisible()
  await expect(page.getByRole("button", { name: "Try again" })).toBeVisible()
})

test("处理 403 禁止访问并重定向到登录页", async ({ page }) => {
  await page.route("**/api/admin/**", (route) => route.fulfill({ status: 403 }))

  await page.goto("/admin/settings")
  await expect(page.getByText(/access denied|not authorized/i)).toBeVisible()
})

test("处理 429 请求限流", async ({ page }) => {
  await page.route("**/api/search*", (route) =>
    route.fulfill({
      status: 429,
      headers: { "Retry-After": "30" },
      body: JSON.stringify({ error: "Too many requests" }),
    })
  )

  await page.goto("/search")
  await page.getByLabel("Search").fill("test")
  await page.getByRole("button", { name: "Search" }).click()

  await expect(page.getByText(/too many requests|try again later/i)).toBeVisible()
})

网络故障与离线模式

使用场景:测试应用在网络断开、请求失败或连接不稳定时的行为。 避免场景:应用没有离线或错误处理行为需要测试。

TypeScript

import { test, expect } from "@playwright/test"

test("离线模式显示离线横幅", async ({ page }) => {
  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()

  // 切换为离线
  await page.context().setOffline(true)

  // 触发一个依赖网络的操作
  await page.getByRole("button", { name: "Refresh" }).click()

  await expect(page.getByText(/offline|no connection/i)).toBeVisible()

  // 恢复在线
  await page.context().setOffline(false)

  await page.getByRole("button", { name: "Refresh" }).click()
  await expect(page.getByText(/offline|no connection/i)).not.toBeVisible()
})

test("网络请求失败显示错误状态", async ({ page }) => {
  // 中止特定请求以模拟网络故障
  await page.route("**/api/user/profile", (route) => route.abort("connectionfailed"))

  await page.goto("/profile")

  await expect(page.getByText("Failed to load profile")).toBeVisible()
  await expect(page.getByRole("button", { name: "Retry" })).toBeVisible()
})

test("请求超时显示超时消息", async ({ page }) => {
  // 延迟响应,超过应用的超时阈值
  await page.route("**/api/reports", async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 30_000))
    await route.fulfill({ status: 200, json: { data: [] } })
  })

  await page.goto("/reports")

  // 应用应在 Playwright 自身超时之前显示超时消息
  await expect(page.getByText(/timed out|taking too long/i)).toBeVisible({
    timeout: 20_000,
  })
})

test("间歇性连接——请求先失败后成功", async ({ page }) => {
  let requestCount = 0

  await page.route("**/api/data", (route) => {
    requestCount++
    if (requestCount <= 2) {
      return route.abort("connectionfailed")
    }
    return route.fulfill({ status: 200, json: { items: ["a", "b", "c"] } })
  })

  await page.goto("/data")

  // 首次加载失败
  await expect(page.getByText(/failed|error/i)).toBeVisible()

  // 用户重试——仍然失败
  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByText(/failed|error/i)).toBeVisible()

  // 第三次尝试成功
  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByRole("listitem")).toHaveCount(3)
})

JavaScript

const { test, expect } = require("@playwright/test")

test("离线模式显示离线横幅", async ({ page }) => {
  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()

  await page.context().setOffline(true)

  await page.getByRole("button", { name: "Refresh" }).click()
  await expect(page.getByText(/offline|no connection/i)).toBeVisible()

  await page.context().setOffline(false)

  await page.getByRole("button", { name: "Refresh" }).click()
  await expect(page.getByText(/offline|no connection/i)).not.toBeVisible()
})

test("网络请求失败显示错误状态", async ({ page }) => {
  await page.route("**/api/user/profile", (route) => route.abort("connectionfailed"))

  await page.goto("/profile")

  await expect(page.getByText("Failed to load profile")).toBeVisible()
  await expect(page.getByRole("button", { name: "Retry" })).toBeVisible()
})

test("间歇性连接——请求先失败后成功", async ({ page }) => {
  let requestCount = 0

  await page.route("**/api/data", (route) => {
    requestCount++
    if (requestCount <= 2) {
      return route.abort("connectionfailed")
    }
    return route.fulfill({ status: 200, json: { items: ["a", "b", "c"] } })
  })

  await page.goto("/data")

  await expect(page.getByText(/failed|error/i)).toBeVisible()

  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByText(/failed|error/i)).toBeVisible()

  await page.getByRole("button", { name: "Retry" }).click()
  await expect(page.getByRole("listitem")).toHaveCount(3)
})

空状态与边界测试

使用场景:测试 UI 在无数据、输入为最小值或最大值、或输入包含特殊字符时的显示情况。 避免场景:永远不需要避免。每个功能都应有空状态和边界测试。

TypeScript

import { test, expect } from "@playwright/test"

test("无项目时显示空状态", async ({ page }) => {
  // 模拟空响应
  await page.route("**/api/tasks", (route) => route.fulfill({ status: 200, json: [] }))

  await page.goto("/tasks")

  await expect(page.getByText("No tasks yet")).toBeVisible()
  await expect(page.getByRole("link", { name: "Create your first task" })).toBeVisible()

  // 列表元素不应存在
  await expect(page.getByRole("listitem")).toHaveCount(0)
})

test("处理最大长度输入", async ({ page }) => {
  await page.goto("/profile")

  // 使用最大长度字符串填充
  const maxLengthName = "A".repeat(255)
  await page.getByLabel("Display name").fill(maxLengthName)
  await page.getByRole("button", { name: "Save" }).click()

  // 验证名称已保存(或已截断,取决于应用行为)
  await expect(page.getByText("Profile updated")).toBeVisible()
})

test("处理输入中的特殊字符", async ({ page }) => {
  await page.goto("/search")

  const specialInputs = [
    '<script>alert("xss")</script>',
    '"; DROP TABLE users; --',
    "unicode: \u00e9\u00e0\u00fc\u00f1 \u4f60\u597d \ud83d\ude80",
    "null bytes: \x00\x01\x02",
    "path traversal: ../../etc/passwd",
  ]

  for (const input of specialInputs) {
    await page.getByLabel("Search").fill(input)
    await page.getByRole("button", { name: "Search" }).click()

    // 应用不应崩溃——要么显示结果,要么显示"无结果"
    await expect(page.getByText(/results|no results|no matches/i)).toBeVisible()
  }
})

test("处理零个、一个和多个项目(0-1-N 模式)", async ({ page }) => {
  // 零个项目
  await page.route("**/api/notifications", (route) => route.fulfill({ status: 200, json: [] }))
  await page.goto("/notifications")
  await expect(page.getByText("No notifications")).toBeVisible()

  // 一个项目
  await page.route("**/api/notifications", (route) =>
    route.fulfill({
      status: 200,
      json: [{ id: 1, message: "Welcome!" }],
    })
  )
  await page.reload()
  await expect(page.getByRole("listitem")).toHaveCount(1)
  await expect(page.getByText("No notifications")).not.toBeVisible()

  // 多个项目——验证分页或"加载更多"
  await page.route("**/api/notifications", (route) =>
    route.fulfill({
      status: 200,
      json: Array.from({ length: 50 }, (_, i) => ({
        id: i + 1,
        message: `Notification ${i + 1}`,
      })),
    })
  )
  await page.reload()
  await expect(page.getByRole("listitem").first()).toBeVisible()
  await expect(page.getByRole("button", { name: /load more|show all/i })).toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("无项目时显示空状态", async ({ page }) => {
  await page.route("**/api/tasks", (route) => route.fulfill({ status: 200, json: [] }))

  await page.goto("/tasks")

  await expect(page.getByText("No tasks yet")).toBeVisible()
  await expect(page.getByRole("link", { name: "Create your first task" })).toBeVisible()
  await expect(page.getByRole("listitem")).toHaveCount(0)
})

test("处理最大长度输入", async ({ page }) => {
  await page.goto("/profile")

  const maxLengthName = "A".repeat(255)
  await page.getByLabel("Display name").fill(maxLengthName)
  await page.getByRole("button", { name: "Save" }).click()

  await expect(page.getByText("Profile updated")).toBeVisible()
})

test("处理输入中的特殊字符", async ({ page }) => {
  await page.goto("/search")

  const specialInputs = [
    '<script>alert("xss")</script>',
    '"; DROP TABLE users; --',
    "unicode: \u00e9\u00e0\u00fc\u00f1 \u4f60\u597d \ud83d\ude80",
    "path traversal: ../../etc/passwd",
  ]

  for (const input of specialInputs) {
    await page.getByLabel("Search").fill(input)
    await page.getByRole("button", { name: "Search" }).click()

    await expect(page.getByText(/results|no results|no matches/i)).toBeVisible()
  }
})

test("处理零个、一个和多个项目(0-1-N 模式)", async ({ page }) => {
  await page.route("**/api/notifications", (route) => route.fulfill({ status: 200, json: [] }))
  await page.goto("/notifications")
  await expect(page.getByText("No notifications")).toBeVisible()

  await page.route("**/api/notifications", (route) =>
    route.fulfill({
      status: 200,
      json: [{ id: 1, message: "Welcome!" }],
    })
  )
  await page.reload()
  await expect(page.getByRole("listitem")).toHaveCount(1)

  await page.route("**/api/notifications", (route) =>
    route.fulfill({
      status: 200,
      json: Array.from({ length: 50 }, (_, i) => ({
        id: i + 1,
        message: `Notification ${i + 1}`,
      })),
    })
  )
  await page.reload()
  await expect(page.getByRole("listitem").first()).toBeVisible()
  await expect(page.getByRole("button", { name: /load more|show all/i })).toBeVisible()
})

加载状态与骨架屏

使用场景:验证加载指示器、骨架屏或加载动画在数据获取期间出现,并在数据到达后消失。 避免场景:应用同步渲染,没有加载指示器(无客户端数据请求的 SSR)。

TypeScript

import { test, expect } from "@playwright/test"

test("骨架屏出现并解析完成", async ({ page }) => {
  // 延迟 API 响应以观察加载状态
  let resolveResponse: () => void
  const responseReady = new Promise<void>((resolve) => {
    resolveResponse = resolve
  })

  await page.route("**/api/dashboard", async (route) => {
    await responseReady
    await route.fulfill({
      status: 200,
      json: { revenue: 12400, users: 350 },
    })
  })

  await page.goto("/dashboard")

  // 加载时骨架屏应可见
  await expect(page.getByTestId("skeleton-revenue")).toBeVisible()
  await expect(page.getByTestId("skeleton-users")).toBeVisible()

  // 真实内容此时应不可见
  await expect(page.getByText("$12,400")).not.toBeVisible()

  // 释放响应
  resolveResponse!()

  // 骨架屏消失,真实内容出现
  await expect(page.getByTestId("skeleton-revenue")).not.toBeVisible()
  await expect(page.getByText("$12,400")).toBeVisible()
  await expect(page.getByText("350")).toBeVisible()
})

test("表单提交时显示加载动画", async ({ page }) => {
  let resolveSubmit: () => void
  const submitReady = new Promise<void>((resolve) => {
    resolveSubmit = resolve
  })

  await page.route("**/api/contact", async (route) => {
    await submitReady
    await route.fulfill({ status: 200, json: { success: true } })
  })

  await page.goto("/contact")
  await page.getByLabel("Name").fill("Jane")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Test")

  await page.getByRole("button", { name: "Send" }).click()

  // 提交期间的加载动画/状态
  await expect(page.getByRole("button", { name: /sending/i })).toBeVisible()
  await expect(page.getByRole("button", { name: /sending/i })).toBeDisabled()

  // 完成提交
  resolveSubmit!()

  await expect(page.getByText("Message sent")).toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("骨架屏出现并解析完成", async ({ page }) => {
  let resolveResponse
  const responseReady = new Promise((resolve) => {
    resolveResponse = resolve
  })

  await page.route("**/api/dashboard", async (route) => {
    await responseReady
    await route.fulfill({
      status: 200,
      json: { revenue: 12400, users: 350 },
    })
  })

  await page.goto("/dashboard")

  await expect(page.getByTestId("skeleton-revenue")).toBeVisible()
  await expect(page.getByTestId("skeleton-users")).toBeVisible()
  await expect(page.getByText("$12,400")).not.toBeVisible()

  resolveResponse()

  await expect(page.getByTestId("skeleton-revenue")).not.toBeVisible()
  await expect(page.getByText("$12,400")).toBeVisible()
  await expect(page.getByText("350")).toBeVisible()
})

test("表单提交时显示加载动画", async ({ page }) => {
  let resolveSubmit
  const submitReady = new Promise((resolve) => {
    resolveSubmit = resolve
  })

  await page.route("**/api/contact", async (route) => {
    await submitReady
    await route.fulfill({ status: 200, json: { success: true } })
  })

  await page.goto("/contact")
  await page.getByLabel("Name").fill("Jane")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Test")

  await page.getByRole("button", { name: "Send" }).click()

  await expect(page.getByRole("button", { name: /sending/i })).toBeVisible()
  await expect(page.getByRole("button", { name: /sending/i })).toBeDisabled()

  resolveSubmit()

  await expect(page.getByText("Message sent")).toBeVisible()
})

重试行为测试

使用场景:测试应用是否自动重试失败的请求,或是否通过用户触发的"重试"按钮进行重试。 避免场景:应用没有重试机制。

TypeScript

import { test, expect } from "@playwright/test"

test("重试按钮从失败的 API 调用中恢复", async ({ page }) => {
  let callCount = 0

  await page.route("**/api/feed", (route) => {
    callCount++
    if (callCount === 1) {
      return route.fulfill({ status: 500 })
    }
    return route.fulfill({
      status: 200,
      json: { posts: [{ id: 1, title: "Hello World" }] },
    })
  })

  await page.goto("/feed")

  // 首次加载失败
  await expect(page.getByText(/something went wrong/i)).toBeVisible()

  // 点击重试——第二次调用成功
  await page.getByRole("button", { name: "Try again" }).click()

  await expect(page.getByText("Hello World")).toBeVisible()
  expect(callCount).toBe(2)
})

test("带指数退避的自动重试", async ({ page }) => {
  const callTimestamps: number[] = []

  await page.route("**/api/status", (route) => {
    callTimestamps.push(Date.now())
    if (callTimestamps.length <= 3) {
      return route.fulfill({ status: 503 })
    }
    return route.fulfill({ status: 200, json: { status: "ok" } })
  })

  await page.goto("/status")

  // 等待自动重试最终成功
  await expect(page.getByText("System operational")).toBeVisible({
    timeout: 30_000,
  })

  // 验证进行了多次重试尝试
  expect(callTimestamps.length).toBeGreaterThanOrEqual(4)

  // 验证退避:重试间隔应递增
  if (callTimestamps.length >= 3) {
    const gap1 = callTimestamps[1] - callTimestamps[0]
    const gap2 = callTimestamps[2] - callTimestamps[1]
    expect(gap2).toBeGreaterThanOrEqual(gap1)
  }
})

JavaScript

const { test, expect } = require("@playwright/test")

test("重试按钮从失败的 API 调用中恢复", async ({ page }) => {
  let callCount = 0

  await page.route("**/api/feed", (route) => {
    callCount++
    if (callCount === 1) {
      return route.fulfill({ status: 500 })
    }
    return route.fulfill({
      status: 200,
      json: { posts: [{ id: 1, title: "Hello World" }] },
    })
  })

  await page.goto("/feed")

  await expect(page.getByText(/something went wrong/i)).toBeVisible()

  await page.getByRole("button", { name: "Try again" }).click()

  await expect(page.getByText("Hello World")).toBeVisible()
  expect(callCount).toBe(2)
})

test("带指数退避的自动重试", async ({ page }) => {
  const callTimestamps = []

  await page.route("**/api/status", (route) => {
    callTimestamps.push(Date.now())
    if (callTimestamps.length <= 3) {
      return route.fulfill({ status: 503 })
    }
    return route.fulfill({ status: 200, json: { status: "ok" } })
  })

  await page.goto("/status")

  await expect(page.getByText("System operational")).toBeVisible({
    timeout: 30_000,
  })

  expect(callTimestamps.length).toBeGreaterThanOrEqual(4)
})

浏览器前进/后退导航

使用场景:测试应用程序是否正确处理浏览器历史导航——后退或前进后保持状态、URL 更新和内容。 避免场景:应用是单页应用且不使用浏览器历史 API。应专注于客户端路由测试。

TypeScript

import { test, expect } from "@playwright/test"

test("浏览器后退保留导航上下文", async ({ page }) => {
  await page.goto("/products")
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await page.waitForURL("**/products/running-shoes")

  // 后退
  await page.goBack()

  await expect(page).toHaveURL(/\/products$/)
  await expect(page.getByRole("heading", { name: "Products" })).toBeVisible()
})

test("浏览器前进返回到上一页", async ({ page }) => {
  await page.goto("/products")
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await page.waitForURL("**/products/running-shoes")

  await page.goBack()
  await page.goForward()

  await expect(page).toHaveURL(/\/products\/running-shoes/)
  await expect(page.getByRole("heading", { name: "Running Shoes" })).toBeVisible()
})

test("浏览器后退后的表单状态", async ({ page }) => {
  await page.goto("/checkout")

  // 填写步骤 1 的表单
  await page.getByLabel("Address").fill("123 Main St")
  await page.getByRole("button", { name: "Continue" }).click()

  // 到达步骤 2
  await expect(page.getByRole("heading", { name: "Payment" })).toBeVisible()

  // 后退到步骤 1
  await page.goBack()

  // 数据应保留(取决于应用实现)
  await expect(page.getByLabel("Address")).toHaveValue("123 Main St")
})

test("表单提交后点击后退按钮不会重新提交", async ({ page }) => {
  await page.goto("/contact")

  await page.getByLabel("Name").fill("Jane")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Test")
  await page.getByRole("button", { name: "Send" }).click()

  await expect(page.getByText("Message sent")).toBeVisible()

  // 后退应显示表单,而不是重新提交
  await page.goBack()
  await expect(page.getByLabel("Name")).toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("浏览器后退保留导航上下文", async ({ page }) => {
  await page.goto("/products")
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await page.waitForURL("**/products/running-shoes")

  await page.goBack()

  await expect(page).toHaveURL(/\/products$/)
  await expect(page.getByRole("heading", { name: "Products" })).toBeVisible()
})

test("浏览器前进返回到上一页", async ({ page }) => {
  await page.goto("/products")
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await page.waitForURL("**/products/running-shoes")

  await page.goBack()
  await page.goForward()

  await expect(page).toHaveURL(/\/products\/running-shoes/)
  await expect(page.getByRole("heading", { name: "Running Shoes" })).toBeVisible()
})

test("浏览器后退后的表单状态", async ({ page }) => {
  await page.goto("/checkout")

  await page.getByLabel("Address").fill("123 Main St")
  await page.getByRole("button", { name: "Continue" }).click()

  await expect(page.getByRole("heading", { name: "Payment" })).toBeVisible()

  await page.goBack()
  await expect(page.getByLabel("Address")).toHaveValue("123 Main St")
})

test("表单提交后点击后退按钮不会重新提交", async ({ page }) => {
  await page.goto("/contact")

  await page.getByLabel("Name").fill("Jane")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Message").fill("Test")
  await page.getByRole("button", { name: "Send" }).click()

  await expect(page.getByText("Message sent")).toBeVisible()

  await page.goBack()
  await expect(page.getByLabel("Name")).toBeVisible()
})

并发用户操作

使用场景:测试快速用户交互是否导致竞态条件——双击提交、数据加载时打字、异步操作期间导航。 避免场景:UI 没有可能与用户操作冲突的异步操作。

TypeScript

import { test, expect } from "@playwright/test"

test("双击提交不会创建重复条目", async ({ page }) => {
  const requests: string[] = []

  await page.route("**/api/orders", (route) => {
    requests.push(route.request().method())
    return route.fulfill({
      status: 201,
      json: { id: 1, status: "created" },
    })
  })

  await page.goto("/checkout")
  await page.getByLabel("Item").fill("Widget")

  const submitButton = page.getByRole("button", { name: "Place order" })

  // 快速双击
  await submitButton.dblclick()

  // 等待结果
  await expect(page.getByText("Order confirmed")).toBeVisible()

  // 应用应阻止重复提交
  //(第一次点击后按钮禁用,或服务端去重)
  expect(requests.filter((m) => m === "POST").length).toBeLessThanOrEqual(1)
})

test("导航期间打字不会崩溃", async ({ page }) => {
  await page.goto("/search")

  // 开始打字并立即导航
  await page.getByLabel("Search").pressSequentially("test query", { delay: 30 })
  await page.getByRole("link", { name: "Home" }).click()

  // 应无错误地到达首页
  await page.waitForURL("**/")
  await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
})

test("快速筛选变更使用最新值", async ({ page }) => {
  const requestUrls: string[] = []

  await page.route("**/api/products*", (route) => {
    requestUrls.push(route.request().url())
    return route.fulfill({
      status: 200,
      json: { products: [{ name: "Latest result" }] },
    })
  })

  await page.goto("/products")

  // 快速切换筛选条件
  await page.getByLabel("Category").selectOption("electronics")
  await page.getByLabel("Category").selectOption("clothing")
  await page.getByLabel("Category").selectOption("books")

  // 等待最终结果
  await expect(page.getByText("Latest result")).toBeVisible()

  // UI 应显示"books"的结果,而不是之前的选项
  // 某些应用会防抖;某些会取消正在进行的请求
})

JavaScript

const { test, expect } = require("@playwright/test")

test("双击提交不会创建重复条目", async ({ page }) => {
  const requests = []

  await page.route("**/api/orders", (route) => {
    requests.push(route.request().method())
    return route.fulfill({
      status: 201,
      json: { id: 1, status: "created" },
    })
  })

  await page.goto("/checkout")
  await page.getByLabel("Item").fill("Widget")

  const submitButton = page.getByRole("button", { name: "Place order" })
  await submitButton.dblclick()

  await expect(page.getByText("Order confirmed")).toBeVisible()

  expect(requests.filter((m) => m === "POST").length).toBeLessThanOrEqual(1)
})

test("导航期间打字不会崩溃", async ({ page }) => {
  await page.goto("/search")

  await page.getByLabel("Search").pressSequentially("test query", { delay: 30 })
  await page.getByRole("link", { name: "Home" }).click()

  await page.waitForURL("**/")
  await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
})

test("快速筛选变更使用最新值", async ({ page }) => {
  const requestUrls = []

  await page.route("**/api/products*", (route) => {
    requestUrls.push(route.request().url())
    return route.fulfill({
      status: 200,
      json: { products: [{ name: "Latest result" }] },
    })
  })

  await page.goto("/products")

  await page.getByLabel("Category").selectOption("electronics")
  await page.getByLabel("Category").selectOption("clothing")
  await page.getByLabel("Category").selectOption("books")

  await expect(page.getByText("Latest result")).toBeVisible()
})

优雅降级

使用场景:测试非关键服务(分析、聊天组件、推荐)失败时应用是否仍能正常运行。 避免场景:失败的服务对核心工作流程至关重要。

TypeScript

import { test, expect } from "@playwright/test"

test("分析服务失败时页面仍正常工作", async ({ page }) => {
  // 拦截分析和跟踪脚本
  await page.route("**/analytics/**", (route) => route.abort())
  await page.route("**/tracking/**", (route) => route.abort())

  await page.goto("/products")

  // 核心功能仍然可用
  await expect(page.getByRole("heading", { name: "Products" })).toBeVisible()
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await expect(page.getByRole("button", { name: "Add to cart" })).toBeEnabled()
})

test("推荐引擎失败时页面仍正常工作", async ({ page }) => {
  await page.route("**/api/recommendations", (route) => route.fulfill({ status: 500 }))

  await page.goto("/products/running-shoes")

  // 主要产品内容加载
  await expect(page.getByRole("heading", { name: "Running Shoes" })).toBeVisible()
  await expect(page.getByRole("button", { name: "Add to cart" })).toBeEnabled()

  // 推荐区域显示后备内容
  await expect(page.getByText(/recommendations unavailable|you may also like/i)).toBeVisible()
})

test("第三方聊天组件加载失败时页面仍正常工作", async ({ page }) => {
  // 拦截聊天组件脚本
  await page.route("**/chat-widget.js", (route) => route.abort())

  await page.goto("/support")

  // 核心支持页面加载
  await expect(page.getByRole("heading", { name: "Help Center" })).toBeVisible()

  // 被拦截组件不应导致页面崩溃
  const errors: string[] = []
  page.on("pageerror", (error) => errors.push(error.message))

  // 导航以确认没有级联故障
  await page.getByRole("link", { name: "FAQ" }).click()
  await expect(page.getByRole("heading", { name: "FAQ" })).toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("分析服务失败时页面仍正常工作", async ({ page }) => {
  await page.route("**/analytics/**", (route) => route.abort())
  await page.route("**/tracking/**", (route) => route.abort())

  await page.goto("/products")

  await expect(page.getByRole("heading", { name: "Products" })).toBeVisible()
  await page.getByRole("link", { name: "Running Shoes" }).click()
  await expect(page.getByRole("button", { name: "Add to cart" })).toBeEnabled()
})

test("推荐引擎失败时页面仍正常工作", async ({ page }) => {
  await page.route("**/api/recommendations", (route) => route.fulfill({ status: 500 }))

  await page.goto("/products/running-shoes")

  await expect(page.getByRole("heading", { name: "Running Shoes" })).toBeVisible()
  await expect(page.getByRole("button", { name: "Add to cart" })).toBeEnabled()
})

test("第三方聊天组件加载失败时页面仍正常工作", async ({ page }) => {
  await page.route("**/chat-widget.js", (route) => route.abort())

  await page.goto("/support")

  await expect(page.getByRole("heading", { name: "Help Center" })).toBeVisible()

  await page.getByRole("link", { name: "FAQ" }).click()
  await expect(page.getByRole("heading", { name: "FAQ" })).toBeVisible()
})

决策指南

场景 方法 关键 API
404 页面 导航到不存在的 URL,断言错误页面 page.goto('/nonexistent')
500 服务器错误 使用 status: 500 模拟路由 page.route(url, route => route.fulfill({ status: 500 }))
网络故障 中止路由 route.abort('connectionfailed')
离线模式 在浏览器上下文中切换离线状态 page.context().setOffline(true)
慢响应 使用 Promise 延迟路由完成 路由处理器中的 await new Promise(r => setTimeout(r, delay))
空状态 模拟 API 返回空数组 route.fulfill({ json: [] })
边界值 用最小/最大/特殊值填充输入 locator.fill('A'.repeat(255))
骨架屏 延迟路由,断言骨架屏可见,释放响应,断言内容 基于 Promise 的路由处理器
重试行为 跟踪路由调用次数,前 N 次失败,之后成功 路由处理器中的计数器
浏览器历史 使用 page.goBack()page.goForward() 导航后断言 URL 和内容
双击提交 在提交按钮上 dblclick(),验证单个 POST 请求 在路由处理器中跟踪请求
第三方服务失败 中止非关键路由,验证核心功能正常 对可选服务使用 route.abort()
控制台错误监控 监听 pageerror 事件 page.on('pageerror', handler)

反模式

不要这样做 问题 应该这样做
只测试快乐路径 真实用户会遇到错误。生产环境中的错误比测试时间更昂贵。 为每个功能添加错误/边界情况测试
page.route('**/*', route => route.abort()) 会拦截所有请求,包括页面本身 指定目标 URLpage.route('**/api/specific', ...)
使用 page.waitForTimeout() 模拟慢加载 随意性、不稳定、拖慢测试速度 使用基于 Promise 的路由处理器精确控制时序
在断言中硬编码错误消息 消息会变更。测试因文案修改而失败。 使用正则或部分匹配:getByText(/error/i)
在一个巨型测试中测试所有错误码 难以调试、速度慢,第一个失败会掩盖后续问题 每个错误场景一个测试,或使用 test.describe 分组
跳过空状态测试 空状态是新用户最先看到的内容;往往最容易出问题 始终在测试 0 项状态的同时测试 1 项和 N 项
通过断开真实网络来测试离线 不稳定、影响并行测试、CI 可能不支持 使用 context.setOffline(true)——确定性强且作用域隔离
测试间未清理路由模拟 路由在页面上持续存在。测试 B 继承了测试 A 的模拟。 每个测试设置自己的路由;Playwright 默认在测试间重置
对来自服务器的精确错误字符串进行断言 将测试与后端实现细节耦合 断言 UI 消息,而非原始 API 响应体
使用 try/catch 来"处理"预期错误 吞掉真正的缺陷;测试本应失败时却通过 让错误自然传播;使用路由模拟来控制响应

故障排除

路由处理器未拦截请求

原因:URL 模式不匹配,或路由在触发请求的导航之后才注册。

// 始终在导航之前注册路由
await page.route("**/api/data", (route) => route.fulfill({ status: 500 }))
await page.goto("/dashboard") // 页面加载前路由已激活

// 调试:记录所有请求以查找实际 URL 模式
page.on("request", (req) => console.log(req.url()))

context.setOffline(true) 不影响 Service Worker

原因Service Worker 有自己的网络处理机制。setOffline 在浏览器层面模拟离线,但 Service Worker 可能提供缓存响应。

// 先注销 service worker
await page.evaluate(async () => {
  const registrations = await navigator.serviceWorker.getRegistrations()
  for (const registration of registrations) {
    await registration.unregister()
  }
})
await page.context().setOffline(true)

延迟的路由处理器导致测试超时

原因:路由处理器中的 Promise 从未解析,或延迟超过了测试超时时间。

// 始终确保延迟小于断言超时时间
await page.route("**/api/slow", async (route) => {
  await new Promise((resolve) => setTimeout(resolve, 5_000)) // 5 秒延迟
  await route.fulfill({ status: 200, json: {} })
})

// 增大断言超时时间以容纳人为延迟
await expect(page.getByText("Data loaded")).toBeVisible({ timeout: 10_000 })

goBack() 未导航

原因:没有历史记录可返回。goBack() 需要至少一次先前的导航。

// 确保在调用 goBack 之前有历史记录
await page.goto("/page-a")
await page.goto("/page-b")
await page.goBack() // 返回到 /page-a

// 如果使用 SPA 客户端路由,goBack 可能无法工作(如果路由器未推送到浏览器历史)
// 改为使用应用自身的后退按钮
await page.getByRole("button", { name: "Back" }).click()

相关文档