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

25 KiB
Raw Permalink Blame History

断言与等待

使用时机:每当你编写 expect() 调用、等待某个条件,或疑惑测试因时序问题而不稳定时。 前置知识core/locators.md 中使用了定位器策略,请先阅读。

快速参考

// Web 优先(自动重试)——始终优先选择这些
await expect(page.getByRole("button", { name: "Submit" })).toBeVisible()
await expect(page.getByRole("heading")).toHaveText("Dashboard")
await expect(page.getByRole("listitem")).toHaveCount(5)

// 否定断言——自动重试直到条件满足
await expect(page.getByRole("dialog")).not.toBeVisible()

// 软断言——收集失败,不停止测试
await expect.soft(page.getByRole("heading")).toHaveText("Title")

// 轮询断言——非 DOM 的异步条件
await expect.poll(() => getUserCount()).toBe(10)

// 重试块——必须同时通过的多个断言
await expect(async () => {
  /* 断言 */
}).toPass()

模式

Web 优先断言(自动重试)

使用时机:对定位器进行任何断言——可见性、文本、属性、CSS、数量、值。 避免时机:对已解析的 JavaScript 值进行断言(改用非重试断言)。

Web 优先断言会自动重试,直到条件满足或超时。它们是可靠的 Playwright 测试的基石。

TypeScript

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

test("web-first assertions demo", async ({ page }) => {
  await page.goto("/products")

  // 可见性
  await expect(page.getByRole("heading", { name: "Products" })).toBeVisible()

  // 文本——精确匹配
  await expect(page.getByTestId("total")).toHaveText("Total: $99.00")

  // 文本——部分匹配(子串或正则)
  await expect(page.getByTestId("total")).toContainText("$99")
  await expect(page.getByTestId("total")).toHaveText(/Total: \$\d+\.\d{2}/)

  // 元素数量
  await expect(page.getByRole("listitem")).toHaveCount(5)

  // 属性
  await expect(page.getByRole("link", { name: "Docs" })).toHaveAttribute("href", "/docs")

  // CSS 属性
  await expect(page.getByTestId("alert")).toHaveCSS("background-color", "rgb(255, 0, 0)")

  // 输入值
  await expect(page.getByLabel("Email")).toHaveValue("user@example.com")

  // 类名(使用 toHaveClass 进行完整匹配,正则进行部分匹配)
  await expect(page.getByTestId("card")).toHaveClass(/active/)

  // 启用 / 禁用 / 选中
  await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled()
  await expect(page.getByRole("checkbox")).toBeChecked()

  // 可编辑 / 聚焦 / 已附加
  await expect(page.getByLabel("Name")).toBeEditable()
  await expect(page.getByLabel("Name")).toBeFocused()
})

JavaScript

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

test("web-first assertions demo", async ({ page }) => {
  await page.goto("/products")

  await expect(page.getByRole("heading", { name: "Products" })).toBeVisible()
  await expect(page.getByTestId("total")).toHaveText("Total: $99.00")
  await expect(page.getByTestId("total")).toContainText("$99")
  await expect(page.getByTestId("total")).toHaveText(/Total: \$\d+\.\d{2}/)
  await expect(page.getByRole("listitem")).toHaveCount(5)
  await expect(page.getByRole("link", { name: "Docs" })).toHaveAttribute("href", "/docs")
  await expect(page.getByTestId("alert")).toHaveCSS("background-color", "rgb(255, 0, 0)")
  await expect(page.getByLabel("Email")).toHaveValue("user@example.com")
  await expect(page.getByTestId("card")).toHaveClass(/active/)
  await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled()
  await expect(page.getByRole("checkbox")).toBeChecked()
  await expect(page.getByLabel("Name")).toBeEditable()
  await expect(page.getByLabel("Name")).toBeFocused()
})

非重试断言

使用时机:值已经解析完毕——JavaScript 变量、API 响应体、page.title() 的页面标题、或 page.url() 的 URL。 避免时机:对可能在 DOM 中异步变化的任何内容进行断言。改用 Web 优先断言。

非重试断言只执行一次。如果失败,它们立即失败。

TypeScript

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

test("non-retrying assertions for resolved values", async ({ page }) => {
  await page.goto("/api/health")

  // 已解析的值——无需重试
  const title = await page.title()
  expect(title).toBe("Health Check")

  const url = page.url()
  expect(url).toContain("/api/health")

  // API 响应体
  const response = await page.request.get("/api/users")
  const body = await response.json()
  expect(body.users).toHaveLength(3)
  expect(response.status()).toBe(200)

  // 快照(值比较,不重试)
  expect(body).toMatchObject({ status: "healthy", users: expect.any(Array) })
})

JavaScript

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

test("non-retrying assertions for resolved values", async ({ page }) => {
  await page.goto("/api/health")

  const title = await page.title()
  expect(title).toBe("Health Check")

  const url = page.url()
  expect(url).toContain("/api/health")

  const response = await page.request.get("/api/users")
  const body = await response.json()
  expect(body.users).toHaveLength(3)
  expect(response.status()).toBe(200)

  expect(body).toMatchObject({ status: "healthy", users: expect.any(Array) })
})

否定断言

使用时机:验证某事物已消失、被移除或不存在。 避免时机:永远不要避免。

否定 Web 优先断言会自动重试,直到条件满足。这一点至关重要:expect(locator).not.toBeVisible() 会正确等待元素消失。它不仅仅是检查一次。

TypeScript

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

test("verify element disappears after action", async ({ page }) => {
  await page.goto("/notifications")

  // 关闭一条通知
  await page.getByRole("button", { name: "Dismiss" }).click()

  // 自动重试直到通知消失——正确
  await expect(page.getByRole("alert")).not.toBeVisible()

  // 验证文本不存在
  await expect(page.getByText("Error occurred")).not.toBeVisible()

  // 验证元素已完全从 DOM 中分离
  await expect(page.getByTestId("modal")).not.toBeAttached()

  // 验证数量降为零
  await expect(page.getByRole("alert")).toHaveCount(0)
})

JavaScript

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

test("verify element disappears after action", async ({ page }) => {
  await page.goto("/notifications")

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

  await expect(page.getByRole("alert")).not.toBeVisible()
  await expect(page.getByText("Error occurred")).not.toBeVisible()
  await expect(page.getByTestId("modal")).not.toBeAttached()
  await expect(page.getByRole("alert")).toHaveCount(0)
})

陷阱not.toBeVisible() 对于存在但隐藏的元素以及不在 DOM 中的元素都会通过。如果你需要明确断言元素已从 DOM 中完全移除(而不仅仅是隐藏),请使用 not.toBeAttached()

软断言

使用时机:你希望在一个测试中收集多个失败,而不在第一个失败时停止。常见于表单验证检查、仪表板内容审计或视觉检查清单。 避免时机:后续断言依赖于前面的结果(如果第一个失败,后面的断言可能毫无意义)。

TypeScript

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

test("dashboard shows all expected widgets", async ({ page }) => {
  await page.goto("/dashboard")

  // 所有检查都会运行,即使前面的失败
  await expect.soft(page.getByTestId("revenue-widget")).toBeVisible()
  await expect.soft(page.getByTestId("users-widget")).toBeVisible()
  await expect.soft(page.getByTestId("orders-widget")).toBeVisible()
  await expect.soft(page.getByTestId("revenue-widget")).toContainText("$")
  await expect.soft(page.getByTestId("users-widget")).toContainText("active")

  // 如果任何软断言失败,测试仍然会失败,但你会在报告中看到所有失败
})

JavaScript

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

test("dashboard shows all expected widgets", async ({ page }) => {
  await page.goto("/dashboard")

  await expect.soft(page.getByTestId("revenue-widget")).toBeVisible()
  await expect.soft(page.getByTestId("users-widget")).toBeVisible()
  await expect.soft(page.getByTestId("orders-widget")).toBeVisible()
  await expect.soft(page.getByTestId("revenue-widget")).toContainText("$")
  await expect.soft(page.getByTestId("users-widget")).toContainText("active")
})

提示:当后续操作会抛出令人困惑的错误时,应阻止其执行。

await expect.soft(page.getByRole("button", { name: "Next" })).toBeVisible()
if (test.info().errors.length > 0) return // 退出——没有继续的必要
await page.getByRole("button", { name: "Next" }).click()

轮询断言

使用时机:等待非 DOM、非定位器的异步条件:API 就绪、数据库状态、文件存在、轮询服务。 避免时机:条件与 DOM 元素有关。改用定位器的 Web 优先断言。

expect.poll() 重复调用一个函数,直到断言通过。

TypeScript

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

test("wait for background job to complete", async ({ page }) => {
  await page.goto("/jobs")
  await page.getByRole("button", { name: "Start Export" }).click()

  // 轮询 API 端点直到任务完成
  await expect
    .poll(
      async () => {
        const response = await page.request.get("/api/jobs/latest")
        const job = await response.json()
        return job.status
      },
      {
        message: "Expected export job to complete",
        timeout: 30_000,
        intervals: [1_000, 2_000, 5_000], // 退避:1 秒,2 秒,然后每 5 秒
      }
    )
    .toBe("completed")
})

JavaScript

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

test("wait for background job to complete", async ({ page }) => {
  await page.goto("/jobs")
  await page.getByRole("button", { name: "Start Export" }).click()

  await expect
    .poll(
      async () => {
        const response = await page.request.get("/api/jobs/latest")
        const job = await response.json()
        return job.status
      },
      {
        message: "Expected export job to complete",
        timeout: 30_000,
        intervals: [1_000, 2_000, 5_000],
      }
    )
    .toBe("completed")
})

使用 toPass() 的重试断言块

使用时机:多个断言或操作必须作为一个组同时通过,且如果任何部分失败,整个块应重试。常见于数据增量出现时的竞态条件。 避免时机:单个 Web 优先断言就足够时。

TypeScript

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

test("search results update correctly", async ({ page }) => {
  await page.goto("/search")

  await expect(async () => {
    await page.getByLabel("Search").fill("playwright")
    await page.getByRole("button", { name: "Search" }).click()

    // 两者必须同时通过——重试整个块
    await expect(page.getByRole("listitem")).toHaveCount(10)
    await expect(page.getByRole("listitem").first()).toContainText("Playwright")
  }).toPass({
    timeout: 15_000,
    intervals: [1_000, 2_000, 5_000],
  })
})

JavaScript

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

test("search results update correctly", async ({ page }) => {
  await page.goto("/search")

  await expect(async () => {
    await page.getByLabel("Search").fill("playwright")
    await page.getByRole("button", { name: "Search" }).click()

    await expect(page.getByRole("listitem")).toHaveCount(10)
    await expect(page.getByRole("listitem").first()).toContainText("Playwright")
  }).toPass({
    timeout: 15_000,
    intervals: [1_000, 2_000, 5_000],
  })
})

自定义匹配器

使用时机:你在多个测试中重复使用的领域特定断言——有效价格格式、日期范围、可访问表单等。 避免时机:该断言只在一个测试中使用。直接内联。

TypeScript

// fixtures/custom-matchers.ts
import { expect, type Locator } from "@playwright/test"

expect.extend({
  async toHaveValidPrice(locator: Locator) {
    const assertionName = "toHaveValidPrice"
    let pass: boolean
    let matcherResult: any

    try {
      await expect(locator).toHaveText(/^\$\d{1,3}(,\d{3})*\.\d{2}$/)
      pass = true
    } catch (e: any) {
      matcherResult = e.matcherResult
      pass = false
    }

    const message = pass
      ? () =>
          `${this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot })}\n\nLocator: ${locator}\nExpected: not a valid price format\nReceived: ${matcherResult?.actual || "valid price"}`
      : () =>
          `${this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot })}\n\nLocator: ${locator}\nExpected: valid price format ($X,XXX.XX)\nReceived: ${matcherResult?.actual || "no text"}`

    return {
      message,
      pass,
      name: assertionName,
      expected: "valid price format",
      actual: matcherResult?.actual,
    }
  },
})

// 为 TypeScript 声明类型
export {}
declare global {
  namespace PlaywrightTest {
    interface Matchers<R, T> {
      toHaveValidPrice(): R
    }
  }
}
// tests/products.spec.ts
import { test, expect } from "@playwright/test"
import "../fixtures/custom-matchers"

test("product prices are valid", async ({ page }) => {
  await page.goto("/products")
  await expect(page.getByTestId("price-tag").first()).toHaveValidPrice()
})

JavaScript

// fixtures/custom-matchers.js
const { expect } = require("@playwright/test")

expect.extend({
  async toHaveValidPrice(locator) {
    const assertionName = "toHaveValidPrice"
    let pass
    let matcherResult

    try {
      await expect(locator).toHaveText(/^\$\d{1,3}(,\d{3})*\.\d{2}$/)
      pass = true
    } catch (e) {
      matcherResult = e.matcherResult
      pass = false
    }

    const message = pass
      ? () => `Expected locator not to have valid price format`
      : () =>
          `Expected locator to have valid price format ($X,XXX.XX), received: ${matcherResult?.actual || "no text"}`

    return { message, pass, name: assertionName }
  },
})
// tests/products.spec.js
const { test, expect } = require("@playwright/test")
require("../fixtures/custom-matchers")

test("product prices are valid", async ({ page }) => {
  await page.goto("/products")
  await expect(page.getByTestId("price-tag").first()).toHaveValidPrice()
})

自动等待(可操作性)

使用时机:你不需要主动"使用"这个——理解即可。每个 Playwright 操作(clickfillcheckselectOption 等)会自动等待目标元素变为可操作状态后再继续。

Playwright 在执行前会检查:

操作 等待条件
click() 已附加、可见、稳定(无动画)、已启用、不被其他元素遮挡
fill() 已附加、可见、已启用、可编辑
check() 已附加、可见、稳定、已启用
selectOption() 已附加、可见、已启用
hover() 已附加、可见、稳定
type() 已附加、可见、已启用、可编辑

这意味着在操作之前几乎不需要显式等待。不要在 await button.click() 之前写 await expect(button).toBeVisible()——click 已经等待了可见性。

显式等待

使用时机:等待导航、网络响应或不与特定定位器绑定的页面加载状态。 避免时机:定位器上的 Web 优先断言就已足够时。

TypeScript

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

test("explicit waits for non-locator conditions", async ({ page }) => {
  await page.goto("/login")

  // 表单提交后等待导航
  await page.getByLabel("Email").fill("user@test.com")
  await page.getByLabel("Password").fill("password123")
  await page.getByRole("button", { name: "Sign In" }).click()
  await page.waitForURL("/dashboard")
  // 也可以使用 globawait page.waitForURL('**/dashboard');
  // 或正则:await page.waitForURL(/.*dashboard/);

  // 等待特定的 API 响应
  const responsePromise = page.waitForResponse(
    (resp) => resp.url().includes("/api/user") && resp.status() === 200
  )
  await page.getByRole("button", { name: "Refresh" }).click()
  const response = await responsePromise
  const data = await response.json()
  expect(data.name).toBe("Test User")

  // 等待网络请求被发送
  const requestPromise = page.waitForRequest("**/api/analytics")
  await page.getByRole("button", { name: "Track" }).click()
  const request = await requestPromise
  expect(request.method()).toBe("POST")

  // 等待加载状态
  await page.waitForLoadState("networkidle") // 谨慎使用——仅用于遗留应用
})

JavaScript

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

test("explicit waits for non-locator conditions", async ({ page }) => {
  await page.goto("/login")

  await page.getByLabel("Email").fill("user@test.com")
  await page.getByLabel("Password").fill("password123")
  await page.getByRole("button", { name: "Sign In" }).click()
  await page.waitForURL("/dashboard")

  const responsePromise = page.waitForResponse(
    (resp) => resp.url().includes("/api/user") && resp.status() === 200
  )
  await page.getByRole("button", { name: "Refresh" }).click()
  const response = await responsePromise
  const data = await response.json()
  expect(data.name).toBe("Test User")

  const requestPromise = page.waitForRequest("**/api/analytics")
  await page.getByRole("button", { name: "Track" }).click()
  const request = await requestPromise
  expect(request.method()).toBe("POST")

  await page.waitForLoadState("networkidle")
})

关键模式:始终在触发操作之前设置 waitForResponse / waitForRequest。否则就会产生竞态条件。

// 正确——在点击之前注册 promise
const responsePromise = page.waitForResponse("**/api/data")
await page.getByRole("button", { name: "Load" }).click()
const response = await responsePromise

// 错误——在 waitForResponse 注册之前,响应可能已经到达
await page.getByRole("button", { name: "Load" }).click()
const response = await page.waitForResponse("**/api/data") // 竞态条件!

断言超时

使用时机:特定断言需要比全局默认值更多或更少的时间。

TypeScript

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

// 逐断言超时
test("slow element appears eventually", async ({ page }) => {
  await page.goto("/slow-dashboard")

  await expect(page.getByTestId("heavy-chart")).toBeVisible({
    timeout: 30_000, // 为此断言覆盖默认值
  })
})

// 逐测试超时
test("long-running flow", async ({ page }) => {
  test.setTimeout(120_000)
  await page.goto("/import")
  await page.getByRole("button", { name: "Import CSV" }).click()
  await expect(page.getByText("Import complete")).toBeVisible({ timeout: 60_000 })
})

JavaScript

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

test("slow element appears eventually", async ({ page }) => {
  await page.goto("/slow-dashboard")

  await expect(page.getByTestId("heavy-chart")).toBeVisible({
    timeout: 30_000,
  })
})

test("long-running flow", async ({ page }) => {
  test.setTimeout(120_000)
  await page.goto("/import")
  await page.getByRole("button", { name: "Import CSV" }).click()
  await expect(page.getByText("Import complete")).toBeVisible({ timeout: 60_000 })
})

playwright.config.ts 中的全局超时设置:

export default defineConfig({
  expect: {
    timeout: 10_000, // 默认为 5_000——对于慢速应用可增加
  },
  timeout: 30_000, // 逐测试超时(默认 30_000)
})

决策指南

场景 推荐方法 原因
元素可见 / 隐藏 expect(locator).toBeVisible() / .not.toBeVisible() 自动重试,处理时序
文本内容检查 expect(locator).toHaveText().toContainText() 自动重试;子串匹配使用 toContainText
元素数量 expect(locator).toHaveCount(n) 重试直到数量匹配
输入值 expect(locator).toHaveValue('x') 在定位器上自动重试
元素属性 expect(locator).toHaveAttribute('href', '/x') 自动重试
CSS 属性 expect(locator).toHaveCSS('color', 'rgb(0,0,0)') 自动重试;使用计算后的 RGB 值
元素从 DOM 中消失 expect(locator).not.toBeAttached() 区分隐藏与移除
URL 已变更 page.waitForURL('/path')expect(page).toHaveURL('/path') toHaveURL 自动重试;waitForURL 阻塞等待
页面标题 expect(page).toHaveTitle('Title') 自动重试
API 响应状态 expect(response.status()).toBe(200) 已解析——非重试
后台任务 / 轮询 expect.poll(() => fetchStatus()) 重试一个函数,而非定位器
多个断言合为一个 expect(async () => { ... }).toPass() 重试整个块
多个独立检查 expect.soft(locator) 收集所有失败
已解析的 JS 值 expect(value).toBe(x) 无需重试

反模式

不要这样做 问题 改为这样做
await page.waitForTimeout(2000) 任意延迟。快时太慢,慢时太短。不稳定。 使用 Web 优先断言:await expect(locator).toBeVisible()
const visible = await el.isVisible(); expect(visible).toBe(true) isVisible() 只解析一次——无重试。竞态条件。 await expect(el).toBeVisible()
try { await expect(el).toBeVisible() } catch { /* 忽略 */ } 吞掉真正的失败。掩盖缺陷。 使用 expect.soft() 或重构测试
expect(locator).toBeVisible().then(...) 缺少 await。断言脱离运行,测试在它解析之前就通过了。 始终 await expect(locator).toBeVisible()
await expect(btn).toBeVisible(); await btn.click() 冗余。click() 自动等待可见性。 直接 await btn.click()
每次操作前都 await page.waitForLoadState('networkidle') networkidle 很脆弱(长轮询、分析、WebSocket 都会破坏它)。降低测试速度。 改为等待特定元素或 URL
expect(await el.textContent()).toBe('X') 文本只解析一次——无重试。 await expect(el).toHaveText('X')
expect(await page.locator('.item').count()).toBe(5) 数量只解析一次——无重试。 await expect(page.locator('.item')).toHaveCount(5)
对单个断言使用 toPass() 不必要的复杂性。 直接使用 Web 优先断言
每个断言设置过大的超时(>60 秒) 掩盖真正的性能问题。失败时测试变得难以忍受地慢。 修复应用或拆分测试。最多使用 10-30 秒。

故障排查

"Timed out 5000ms waiting for expect(...).toBeVisible()"

原因:元素在断言超时内从未出现。常见原因:

  1. 定位器错误——元素存在但定位器不匹配。
  2. 元素位于加载微调器后面或折叠区域内部。
  3. 填充元素的网络请求很慢。

修复方法

  • 使用 --ui--debug 运行,在失败时直观检查页面状态。
  • 在浏览器控制台中检查定位器是否匹配:playwright.$(selector)
  • 对于确实慢的操作增加超时:{ timeout: 15_000 }
  • 先验证定位器是否指向正确的元素:await expect(locator).toHaveCount(1)

"expect.soft: Test finished with X failed assertions"

原因:软断言收集了失败,但你无法立即看到哪些失败。

修复方法:检查 HTML 报告(npx playwright show-report)。每个软失败都列有其定位器、期望值和实际值。将相关的软断言分组放在 test.step() 下以提高可读性。

"Expected ' Dashboard ' to have text 'Dashboard'"

原因toHaveText() 执行包括规范化在内的完整文本匹配,但当元素具有不寻常的渲染时,空白字符不匹配仍然会困扰人们。

修复方法

  • 使用 toContainText('Dashboard') 进行子串匹配,对空白字符更宽容。
  • 使用正则:toHaveText(/Dashboard/)
  • 使用 --debug 检查零宽度空格或特殊 Unicode 字符。

相关文档