# 断言与等待 > **使用时机**:每当你编写 `expect()` 调用、等待某个条件,或疑惑测试因时序问题而不稳定时。 > **前置知识**:[core/locators.md](locators.md) 中使用了定位器策略,请先阅读。 ## 快速参考 ```typescript // 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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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") }) ``` **提示**:当后续操作会抛出令人困惑的错误时,应阻止其执行。 ```typescript 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** ```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** ```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** ```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** ```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** ```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 { toHaveValidPrice(): R } } } ``` ```typescript // 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** ```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 } }, }) ``` ```javascript // 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 操作(`click`、`fill`、`check`、`selectOption` 等)会自动等待目标元素变为可操作状态后再继续。 Playwright 在执行前会检查: | 操作 | 等待条件 | |---|---| | `click()` | 已附加、可见、稳定(无动画)、已启用、不被其他元素遮挡 | | `fill()` | 已附加、可见、已启用、可编辑 | | `check()` | 已附加、可见、稳定、已启用 | | `selectOption()` | 已附加、可见、已启用 | | `hover()` | 已附加、可见、稳定 | | `type()` | 已附加、可见、已启用、可编辑 | 这意味着在操作之前几乎不需要显式等待。不要在 `await button.click()` 之前写 `await expect(button).toBeVisible()`——click 已经等待了可见性。 ### 显式等待 **使用时机**:等待导航、网络响应或不与特定定位器绑定的页面加载状态。 **避免时机**:定位器上的 Web 优先断言就已足够时。 **TypeScript** ```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") // 也可以使用 glob:await 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** ```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`。否则就会产生竞态条件。 ```typescript // 正确——在点击之前注册 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** ```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** ```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` 中的全局超时设置: ```typescript 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 字符。 ## 相关文档 - [core/locators.md](locators.md) —— 断言目标中使用的定位器策略 - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) —— 用于可重用断言设置的自定义 fixture - [core/debugging.md](debugging.md) —— 使用 UI 模式和追踪调试断言失败 - [core/flaky-tests.md](flaky-tests.md) —— 修复时序相关的不稳定性 - [core/error-index.md](error-index.md) —— 特定错误信息及其修复方法