25 KiB
断言与等待
使用时机:每当你编写
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 操作(click、fill、check、selectOption 等)会自动等待目标元素变为可操作状态后再继续。
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")
// 也可以使用 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
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()"
原因:元素在断言超时内从未出现。常见原因:
- 定位器错误——元素存在但定位器不匹配。
- 元素位于加载微调器后面或折叠区域内部。
- 填充元素的网络请求很慢。
修复方法:
- 使用
--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 —— 断言目标中使用的定位器策略
- core/fixtures-and-hooks.md —— 用于可重用断言设置的自定义 fixture
- core/debugging.md —— 使用 UI 模式和追踪调试断言失败
- core/flaky-tests.md —— 修复时序相关的不稳定性
- core/error-index.md —— 特定错误信息及其修复方法