45 KiB
Common Pitfalls
使用场景:学习 Playwright、审查测试中的常见错误,或让新团队成员上手测试套件时参考。
按在真实代码库中出现频率排序的 20 个最常见 Playwright 错误。每个陷阱包含症状、根因以及完整的修复方案。
陷阱 1:使用 page.waitForTimeout() 代替断言
症状:测试缓慢且不稳定。在快速机器上能通过,但在慢速 CI 运行环境中会失败。
原因:开发者将 Selenium 或 Cypress 中的习惯带了过来,那些工具需要显式等待。在 Playwright 中,自动重试断言会自动处理时序问题。
修复:将每一个 waitForTimeout 替换为 web 优先断言或对特定条件的显式等待。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法
test("bad: arbitrary wait", async ({ page }) => {
await page.goto("/dashboard")
await page.getByRole("button", { name: "Load" }).click()
await page.waitForTimeout(3000)
await expect(page.getByTestId("chart")).toBeVisible()
})
// 正确做法
test("good: auto-retrying assertion", async ({ page }) => {
await page.goto("/dashboard")
await page.getByRole("button", { name: "Load" }).click()
await expect(page.getByTestId("chart")).toBeVisible()
})
// 正确做法——当需要等待特定网络事件时
test("good: wait for response", async ({ page }) => {
await page.goto("/dashboard")
const responsePromise = page.waitForResponse("**/api/chart-data")
await page.getByRole("button", { name: "Load" }).click()
await responsePromise
await expect(page.getByTestId("chart")).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
// 错误做法
test("bad: arbitrary wait", async ({ page }) => {
await page.goto("/dashboard")
await page.getByRole("button", { name: "Load" }).click()
await page.waitForTimeout(3000)
await expect(page.getByTestId("chart")).toBeVisible()
})
// 正确做法
test("good: auto-retrying assertion", async ({ page }) => {
await page.goto("/dashboard")
await page.getByRole("button", { name: "Load" }).click()
await expect(page.getByTestId("chart")).toBeVisible()
})
waitForTimeout 唯一可接受的用途:调试时无法使用 page.pause(),或在性能测试中模拟用户真实的"思考时间"。绝不应出现在生产测试代码中。
陷阱 2:未对异步操作使用 await
症状:测试结果不可预测。断言在操作完成之前就执行了。错误信息中引用了已分离的 frame 或已关闭的页面。
原因:每个 Playwright API 调用都是异步的。只要漏掉一个 await,下一行代码就会在前一个操作完成之前执行。
修复:始终对每个 Playwright 调用使用 await。启用 @typescript-eslint/no-floating-promises ESLint 规则。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——click 缺少 await,断言在导航完成前执行
test("bad: missing await", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Password").fill("password")
page.getByRole("button", { name: "Sign in" }).click() // 缺少 AWAIT
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
// 正确做法
test("good: all actions awaited", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Password").fill("password")
await page.getByRole("button", { name: "Sign in" }).click()
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
// 错误做法
test("bad: missing await", async ({ page }) => {
await page.goto("/login")
page.getByRole("button", { name: "Submit" }).click() // 缺少 AWAIT
await expect(page.getByText("Success")).toBeVisible()
})
// 正确做法
test("good: all actions awaited", async ({ page }) => {
await page.goto("/login")
await page.getByRole("button", { name: "Submit" }).click()
await expect(page.getByText("Success")).toBeVisible()
})
预防措施:添加以下 ESLint 配置,在编译时捕获悬浮的 Promise:
{
"rules": {
"@typescript-eslint/no-floating-promises": "error"
}
}
陷阱 3:使用 CSS 选择器而非基于角色的定位器
症状:只要 CSS 类名、DOM 结构或组件库发生变化,测试就会失败。测试难以阅读,因为选择器看起来像 .btn-primary > span:nth-child(2)。
原因:开发者使用从 jQuery 或 DevTools 中学到的方式。CSS 选择器是频繁变化的实现细节。
修复:使用 Playwright 内置的定位器,它们针对无障碍角色、标签、文本和测试 ID 进行定位。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——脆弱的 CSS 选择器
test("bad: CSS selectors", async ({ page }) => {
await page.goto("/settings")
await page.locator(".form-group:nth-child(3) input.form-control").fill("new value")
await page.locator("button.btn.btn-primary.submit-btn").click()
await expect(page.locator(".alert.alert-success")).toBeVisible()
})
// 正确做法——基于角色的定位器
test("good: accessible locators", async ({ page }) => {
await page.goto("/settings")
await page.getByLabel("Display name").fill("new value")
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByRole("alert")).toHaveText("Settings saved")
})
JavaScript
const { test, expect } = require("@playwright/test")
// 错误做法
test("bad: CSS selectors", async ({ page }) => {
await page.locator(".form-group:nth-child(3) input").fill("new value")
await page.locator("button.btn-primary").click()
})
// 正确做法
test("good: accessible locators", async ({ page }) => {
await page.getByLabel("Display name").fill("new value")
await page.getByRole("button", { name: "Save" }).click()
})
定位器优先级(从最稳健到最脆弱):
getByRole()—— 无障碍角色 + 名称getByLabel()—— 通过标签文本定位表单字段getByPlaceholder()—— 通过占位符定位输入框getByText()—— 可见文本内容getByTestId()—— 稳定的data-testid属性- CSS/XPath 选择器 —— 仅作为最后手段
陷阱 4:断言 isVisible() 的返回值而非使用 expect().toBeVisible()
症状:元素不可见时测试仍然通过。断言在静默中出错。在时序压力下不稳定。
原因:isVisible() 在某个时间点返回一个布尔值——不会重试。如果元素尚未出现,它返回 false,而 expect(false).toBe(true) 会立即失败,不做任何等待。
修复:始终使用 expect(locator).toBeVisible(),它会自动重试直到元素出现或超时。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——只解析一次,不重试
test("bad: isVisible check", async ({ page }) => {
await page.goto("/dashboard")
const visible = await page.getByTestId("widget").isVisible()
expect(visible).toBe(true) // 如果 widget 尚未渲染,会立即失败
})
// 正确做法——最多自动重试 5 秒
test("good: toBeVisible assertion", async ({ page }) => {
await page.goto("/dashboard")
await expect(page.getByTestId("widget")).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
// 错误做法
test("bad: isVisible check", async ({ page }) => {
const visible = await page.getByTestId("widget").isVisible()
expect(visible).toBe(true)
})
// 正确做法
test("good: toBeVisible assertion", async ({ page }) => {
await expect(page.getByTestId("widget")).toBeVisible()
})
这适用于所有"只解析一次"的方法:isVisible()、isEnabled()、isChecked()、textContent()、getAttribute()、inputValue()。始终使用对应的 expect(locator) web 优先断言。
陷阱 5:在并行测试之间共享可变状态
症状:测试单独运行能通过,但在完整测试套件中失败。存在顺序相关的失败。"重复键"错误。
原因:模块级变量、共享的数据库行或 beforeAll 创建的状态被一个测试修改后,被另一个并行运行的测试读到。
修复:使用测试作用域的 fixture 配合唯一数据。切勿在模块级变量中存储可变状态。
TypeScript
import { test as base, expect } from "@playwright/test"
// 错误做法——模块级可变状态在并行测试间共享
let userId: string
test.beforeAll(async ({ request }) => {
const res = await request.post("/api/users", {
data: { email: "shared@test.com" },
})
userId = (await res.json()).id // 每个并行 worker 都会覆盖这个值
})
test("bad: uses shared state", async ({ page }) => {
await page.goto(`/users/${userId}`) // userId 可能来自另一个 worker
})
// 正确做法——测试作用域的 fixture,每个测试使用唯一数据
export const test = base.extend<{ testUser: { id: string; email: string } }>({
testUser: async ({ request }, use) => {
const email = `user-${Date.now()}-${Math.random().toString(36).slice(2)}@test.com`
const res = await request.post("/api/users", { data: { email } })
const user = await res.json()
await use({ id: user.id, email })
await request.delete(`/api/users/${user.id}`)
},
})
test("good: isolated data per test", async ({ page, testUser }) => {
await page.goto(`/users/${testUser.id}`)
await expect(page.getByText(testUser.email)).toBeVisible()
})
JavaScript
const { test: base, expect } = require("@playwright/test")
// 正确做法
const test = base.extend({
testUser: async ({ request }, use) => {
const email = `user-${Date.now()}-${Math.random().toString(36).slice(2)}@test.com`
const res = await request.post("/api/users", { data: { email } })
const user = await res.json()
await use({ id: user.id, email })
await request.delete(`/api/users/${user.id}`)
},
})
test("good: isolated data per test", async ({ page, testUser }) => {
await page.goto(`/users/${testUser.id}`)
await expect(page.getByText(testUser.email)).toBeVisible()
})
module.exports = { test }
陷阱 6:未使用 baseURL(硬编码完整 URL)
症状:切换环境(本地、预发布、生产)时测试失败。URL 字符串在各处重复。
原因:开发者从 page.goto('http://localhost:3000/login') 开始,之后从未重构。
修复:在 playwright.config 中设置 baseURL,并在所有测试中使用相对路径。
TypeScript
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
},
})
import { test, expect } from "@playwright/test"
// 错误做法
test("bad: hardcoded URL", async ({ page }) => {
await page.goto("http://localhost:3000/login")
})
// 正确做法
test("good: relative URL", async ({ page }) => {
await page.goto("/login")
})
JavaScript
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
},
})
const { test, expect } = require("@playwright/test")
// 错误做法
test("bad: hardcoded URL", async ({ page }) => {
await page.goto("http://localhost:3000/login")
})
// 正确做法
test("good: relative URL", async ({ page }) => {
await page.goto("/login")
})
通过设置环境变量在不同的环境中运行:BASE_URL=https://staging.example.com npx playwright test
陷阱 7:使用 page.$() 而非 page.locator()
症状:元素句柄为 null。出现过期元素错误。没有自动等待。
原因:page.$() 和 page.$$() 是来自 Puppeteer 的 ElementHandle API。它们只解析一次,返回的句柄可能会过期。定位器是 Playwright 的替代方案——它们是惰性的,每次操作时重新求值。
修复:始终使用 page.locator()、page.getByRole() 或其他定位器方法。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——ElementHandle API,只解析一次,可能过期
test("bad: page.$ usage", async ({ page }) => {
await page.goto("/products")
const button = await page.$(".add-to-cart") // 未找到则为 null,DOM 变化则过期
if (button) {
await button.click()
}
const count = await page.$$(".cart-item")
expect(count.length).toBe(1) // 无自动重试
})
// 正确做法——定位器 API,惰性求值,自动等待
test("good: locator usage", async ({ page }) => {
await page.goto("/products")
await page.getByRole("button", { name: "Add to cart" }).first().click()
await expect(page.getByTestId("cart-item")).toHaveCount(1) // 自动重试
})
JavaScript
const { test, expect } = require("@playwright/test")
// 错误做法
test("bad: page.$ usage", async ({ page }) => {
const button = await page.$(".add-to-cart")
if (button) await button.click()
})
// 正确做法
test("good: locator usage", async ({ page }) => {
await page.getByRole("button", { name: "Add to cart" }).first().click()
})
陷阱 8:未处理表单提交后的导航
症状:测试失败,提示"目标页面、上下文或浏览器已关闭",或断言因页面在断言运行前已导航离开而失败。
原因:点击提交按钮会触发完整的页面导航。断言针对的是正在被卸载的旧页面。
修复:在断言新页面之前,等待导航完成。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——断言在导航完成前执行
test("bad: no navigation handling", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Password").fill("password")
await page.getByRole("button", { name: "Sign in" }).click()
// 页面正在导航——这可能会失败,提示"执行上下文已被销毁"
await expect(page.getByRole("heading")).toHaveText("Dashboard")
})
// 正确做法——等待 URL 变化,然后断言
test("good: waitForURL after navigation", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Password").fill("password")
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
await expect(page.getByRole("heading")).toHaveText("Dashboard")
})
// 正确做法——替代方案:使用 expect().toHaveURL(),它会自动重试
test("good: toHaveURL assertion", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Password").fill("password")
await page.getByRole("button", { name: "Sign in" }).click()
await expect(page).toHaveURL(/.*dashboard/)
await expect(page.getByRole("heading")).toHaveText("Dashboard")
})
JavaScript
const { test, expect } = require("@playwright/test")
// 正确做法
test("good: waitForURL after navigation", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Password").fill("password")
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
await expect(page.getByRole("heading")).toHaveText("Dashboard")
})
陷阱 9:在 CI 中未配置 webServer 就测试 localhost
症状:测试在 CI 中失败,报 ECONNREFUSED 连接 localhost:3000。本地能通过,因为开发服务器已经在运行。
原因:CI 运行环境以干净环境启动。除非你显式启动,否则没有开发服务器在运行。
修复:使用 webServer 配置选项自动启动你的应用。
TypeScript
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
use: {
baseURL: "http://localhost:3000",
},
webServer: {
command: "npm run start",
url: "http://localhost:3000",
// 本地复用已有服务器(更快),CI 中全新启动
reuseExistingServer: !process.env.CI,
// 给服务器启动时间
timeout: 120_000,
// 捕获服务器输出以调试启动失败
stdout: "pipe",
stderr: "pipe",
},
})
JavaScript
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
use: {
baseURL: "http://localhost:3000",
},
webServer: {
command: "npm run start",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
},
})
对于多个服务器(前端 + 后端),传入数组:
webServer: [
{ command: 'npm run start:api', url: 'http://localhost:4000/health', reuseExistingServer: !process.env.CI },
{ command: 'npm run start:web', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI },
],
陷阱 10:使用 innerHTML 做文本断言而非 toHaveText()
症状:HTML 结构变化时测试失败。断言脆弱且难以阅读。
原因:开发者使用 innerHTML() 或 textContent() 获取文本,然后在解析后的字符串上做断言。这只解析一次,没有重试。
修复:使用 expect(locator).toHaveText() 或 expect(locator).toContainText() 进行自动重试的文本断言。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——只解析一次,包含 HTML 标签,无重试
test("bad: innerHTML assertion", async ({ page }) => {
await page.goto("/product/123")
const html = await page.getByTestId("price").innerHTML()
expect(html).toContain("$49.99") // 脆弱:依赖于 HTML 结构
})
// 错误做法——textContent 只解析一次,无重试
test("bad: textContent assertion", async ({ page }) => {
await page.goto("/product/123")
const text = await page.getByTestId("price").textContent()
expect(text).toBe("$49.99") // 如果文本尚未加载完成,无重试
})
// 正确做法——自动重试,不依赖 HTML
test("good: toHaveText assertion", async ({ page }) => {
await page.goto("/product/123")
await expect(page.getByTestId("price")).toHaveText("$49.99")
})
// 正确做法——部分匹配,用于灵活断言
test("good: toContainText assertion", async ({ page }) => {
await page.goto("/product/123")
await expect(page.getByTestId("price")).toContainText("$49")
})
JavaScript
const { test, expect } = require("@playwright/test")
// 错误做法
test("bad: textContent assertion", async ({ page }) => {
const text = await page.getByTestId("price").textContent()
expect(text).toBe("$49.99")
})
// 正确做法
test("good: toHaveText assertion", async ({ page }) => {
await expect(page.getByTestId("price")).toHaveText("$49.99")
})
陷阱 11:过度 Mock(Mock 自己的 API)
症状:所有测试都通过,但应用在生产环境中是坏的。Mock 响应与真实 API 产生偏差。产生虚假信心。
原因:开发者为了速度和稳定性 mock 了每一个 API 调用,包括自己的后端。随着真实 API 的演进,mock 变得过时。
修复:只 mock 外部第三方服务。真实测试自己的 API。使用 webServer 在测试期间运行后端。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——mock 自己的 API 会降低信心
test("bad: mocks own API", async ({ page }) => {
await page.route("**/api/users/me", (route) =>
route.fulfill({
status: 200,
body: JSON.stringify({ name: "Test User", role: "admin" }),
})
)
await page.goto("/dashboard")
await expect(page.getByText("Test User")).toBeVisible() // 即使 API 有问题也通过
})
// 正确做法——只 mock 外部服务,真实测试自己的 API
test("good: real API, mocked externals", async ({ page }) => {
// 拦截第三方分析和广告
await page.route(/google-analytics|intercom|segment/, (route) => route.abort())
// 模拟不稳定的外部支付提供商
await page.route("**/api.stripe.com/**", (route) =>
route.fulfill({
status: 200,
body: JSON.stringify({ status: "succeeded" }),
})
)
// 针对真实的应用 API 进行测试
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
// 正确做法——只 mock 外部服务
test("good: real API, mocked externals", async ({ page }) => {
await page.route(/google-analytics|intercom|segment/, (route) => route.abort())
await page.route("**/api.stripe.com/**", (route) =>
route.fulfill({
status: 200,
body: JSON.stringify({ status: "succeeded" }),
})
)
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
何时可以接受 mock 自己的 API:测试特定的错误状态(500、503、网络超时)或难以用真实后端复现的边缘情况。
陷阱 12:未使用 test.describe 进行分组
症状:测试文件是一组无关测试的扁平列表。共享配置(test.use()、test.beforeEach())无法限定作用域。HTML 报告难以导航。
原因:开发者将测试写成扁平列表,从未将相关测试分组在一起。
修复:使用 test.describe 对相关测试进行分组。用于限定共享设置、配置覆盖和逻辑组织的范围。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——扁平列表,无分组,无共享上下文
test("admin can view users", async ({ page }) => {
/* ... */
})
test("admin can delete user", async ({ page }) => {
/* ... */
})
test("viewer cannot delete user", async ({ page }) => {
/* ... */
})
test("viewer can view users", async ({ page }) => {
/* ... */
})
// 正确做法——按角色分组,限定配置范围
test.describe("admin users", () => {
test.use({ storageState: ".auth/admin.json" })
test.beforeEach(async ({ page }) => {
await page.goto("/admin/users")
})
test("can view user list", async ({ page }) => {
await expect(page.getByRole("table")).toBeVisible()
})
test("can delete a user", async ({ page }) => {
await page.getByRole("row").first().getByRole("button", { name: "Delete" }).click()
await expect(page.getByRole("dialog")).toBeVisible()
})
})
test.describe("viewer users", () => {
test.use({ storageState: ".auth/viewer.json" })
test("can view user list", async ({ page }) => {
await page.goto("/admin/users")
await expect(page.getByRole("table")).toBeVisible()
})
test("cannot see delete button", async ({ page }) => {
await page.goto("/admin/users")
await expect(page.getByRole("button", { name: "Delete" })).not.toBeVisible()
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("admin users", () => {
test.use({ storageState: ".auth/admin.json" })
test.beforeEach(async ({ page }) => {
await page.goto("/admin/users")
})
test("can view user list", async ({ page }) => {
await expect(page.getByRole("table")).toBeVisible()
})
test("can delete a user", async ({ page }) => {
await page.getByRole("row").first().getByRole("button", { name: "Delete" }).click()
await expect(page.getByRole("dialog")).toBeVisible()
})
})
陷阱 13:使用 beforeAll 进行逐测试设置
症状:本应相互独立的测试共享了 beforeAll 中的状态。一个测试修改状态后,后续测试失败。
原因:开发者认为 beforeAll "更高效",因为它只运行一次。但 beforeAll 创建的是 worker 作用域的状态,文件中所有测试共享。
修复:对逐测试设置使用 beforeEach,或对需要清理的设置使用测试作用域的 fixture。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——beforeAll 为所有测试创建一个用户;测试会修改共享状态
test.beforeAll(async ({ request }) => {
// 此用户在此文件的所有测试间共享
await request.post("/api/users", { data: { email: "shared@test.com", name: "Original" } })
})
test("updates user name", async ({ page }) => {
await page.goto("/users/shared@test.com")
await page.getByLabel("Name").fill("Updated")
await page.getByRole("button", { name: "Save" }).click()
// 现在共享用户的 name 变为 "Updated"——其他测试会看到这个值
})
test("checks user name is Original", async ({ page }) => {
await page.goto("/users/shared@test.com")
// 失败——前一个测试改了名称
await expect(page.getByLabel("Name")).toHaveValue("Original")
})
// 正确做法——每个测试创建自己的用户
test.describe("user profile", () => {
test("updates user name", async ({ page, request }) => {
const email = `user-${Date.now()}@test.com`
await request.post("/api/users", { data: { email, name: "Original" } })
await page.goto(`/users/${email}`)
await page.getByLabel("Name").fill("Updated")
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByLabel("Name")).toHaveValue("Updated")
})
})
JavaScript
const { test, expect } = require("@playwright/test")
// 正确做法——每个测试独立创建数据
test("updates user name", async ({ page, request }) => {
const email = `user-${Date.now()}@test.com`
await request.post("/api/users", { data: { email, name: "Original" } })
await page.goto(`/users/${email}`)
await page.getByLabel("Name").fill("Updated")
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByLabel("Name")).toHaveValue("Updated")
})
陷阱 14:在测试间共享的变量中存储测试数据
症状:测试 B 依赖于测试 A 创建的数据。重新排序或并行运行测试会破坏测试套件。
原因:开发者在模块级别声明 let 变量,在一个测试中赋值,并期望另一个测试能读取它们。
修复:每个测试必须创建自己的数据。使用 fixture 实现共享的设置逻辑。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——测试 B 依赖于测试 A 创建 product
let productId: string
test("test A: creates product", async ({ request }) => {
const res = await request.post("/api/products", { data: { name: "Widget" } })
productId = (await res.json()).id
})
test("test B: edits product", async ({ page }) => {
await page.goto(`/products/${productId}/edit`) // 如果测试 A 未先运行,则为 undefined
})
// 正确做法——每个测试自包含
test("creates and edits product", async ({ page, request }) => {
const res = await request.post("/api/products", { data: { name: `Widget-${Date.now()}` } })
const { id } = await res.json()
await page.goto(`/products/${id}/edit`)
await page.getByLabel("Name").fill("Updated Widget")
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Updated Widget")).toBeVisible()
// 清理
await request.delete(`/api/products/${id}`)
})
JavaScript
const { test, expect } = require("@playwright/test")
// 正确做法——自包含测试
test("creates and edits product", async ({ page, request }) => {
const res = await request.post("/api/products", { data: { name: `Widget-${Date.now()}` } })
const { id } = await res.json()
await page.goto(`/products/${id}/edit`)
await page.getByLabel("Name").fill("Updated Widget")
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Updated Widget")).toBeVisible()
await request.delete(`/api/products/${id}`)
})
陷阱 15:test.describe 块嵌套过深
症状:测试结构超过 3 层深度。难以阅读。外层块中的 beforeEach 钩子在测试级别不可见。HTML 报告杂乱。
原因:开发者像组织代码一样组织测试——深层次嵌套结构。但测试应该是扁平且易于浏览的。
修复:嵌套限制最多 2 层。使用独立的文件代替深层嵌套。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——4 层深度,难以追踪
test.describe("admin", () => {
test.describe("settings", () => {
test.describe("security", () => {
test.describe("two-factor auth", () => {
test("enables TOTP", async ({ page }) => {
// 这个测试之前运行了哪些 beforeEach 钩子?难以得知。
})
})
})
})
})
// 正确做法——最多 2 层,清晰扁平
test.describe("admin security settings", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/admin/settings/security")
})
test("enables two-factor auth via TOTP", async ({ page }) => {
await page.getByRole("button", { name: "Enable 2FA" }).click()
await expect(page.getByText("Scan QR code")).toBeVisible()
})
test("disables two-factor auth", async ({ page }) => {
await page.getByRole("button", { name: "Disable 2FA" }).click()
await expect(page.getByRole("dialog")).toBeVisible()
})
})
JavaScript
const { test, expect } = require("@playwright/test")
// 正确做法——扁平结构,最多 2 层
test.describe("admin security settings", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/admin/settings/security")
})
test("enables two-factor auth via TOTP", async ({ page }) => {
await page.getByRole("button", { name: "Enable 2FA" }).click()
await expect(page.getByText("Scan QR code")).toBeVisible()
})
})
如果需要组织大量测试,拆分为独立文件:security-2fa.spec.ts、security-passwords.spec.ts、security-sessions.spec.ts。
陷阱 16:本地与 CI 未配置不同的重试策略
症状:开发者在本地使用重试(掩盖了开发过程中的缺陷),或在 CI 中没有重试(因非自身原因的间歇性问题而失败)。
原因:设置了一个统一的 retries 值,未考虑环境差异。
修复:本地零重试(快速失败),CI 中 1-2 次重试(捕获基础设施的微小故障)。
TypeScript
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
// 正确做法——不同环境使用不同的重试策略
retries: process.env.CI ? 2 : 0,
use: {
// 仅在重试时捕获 trace——节省 CI 时间和存储
trace: "on-first-retry",
},
})
JavaScript
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
retries: process.env.CI ? 2 : 0,
use: {
trace: "on-first-retry",
},
})
重要:一个始终需要重试才能通过的测试,并不是"通过"——它是不稳定的。追踪重试次数并修复根本原因。
陷阱 17:每次 CI 运行都跑所有浏览器
症状:CI 耗时是不必要的 3 倍。大多数缺陷被单个浏览器引擎就能捕获。
原因:开发者在配置中同时启用了 Chromium + Firefox + WebKit,之后从未重新考虑。
修复:每个 PR 只运行 Chromium。在夜间或预发布任务中运行所有浏览器。
TypeScript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
const allBrowsers = [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
]
const chromeOnly = [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }]
export default defineConfig({
// PR 运行:仅 Chromium。夜间运行:所有浏览器。
projects: process.env.ALL_BROWSERS ? allBrowsers : chromeOnly,
})
JavaScript
// playwright.config.js
const { defineConfig, devices } = require("@playwright/test")
const allBrowsers = [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
]
const chromeOnly = [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }]
module.exports = defineConfig({
projects: process.env.ALL_BROWSERS ? allBrowsers : chromeOnly,
})
# .github/workflows/tests.yml
jobs:
pr-tests:
# 快速:仅 Chromium
runs-on: ubuntu-latest
steps:
- run: npx playwright test
nightly-full:
# 全面:所有浏览器
schedule:
- cron: "0 3 * * *"
steps:
- run: ALL_BROWSERS=1 npx playwright test
陷阱 18:使用 page.evaluate() 做定位器能做的事
症状:测试冗长且脆弱。直接 DOM 操作绕过了 Playwright 的自动等待和可操作性检查。
原因:具有原生 JS 或 Puppeteer 背景的开发者习惯用 evaluate() 处理一切,因为感觉熟悉。
修复:对所有 DOM 交互使用定位器方法。将 page.evaluate() 留给定位器确实无法完成的任务(读取计算样式、调用应用特定的 JS API、设置测试钩子)。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——使用 evaluate 做定位器更擅长的事
test("bad: evaluate for DOM interaction", async ({ page }) => {
await page.goto("/settings")
// 获取文本
const text = await page.evaluate(
() => document.querySelector('[data-testid="username"]')?.textContent
)
expect(text).toBe("John")
// 点击
await page.evaluate(() => {
;(document.querySelector("button.save-btn") as HTMLButtonElement)?.click()
})
// 检查可见性
const visible = await page.evaluate(() => {
const el = document.querySelector(".success-message")
return el ? window.getComputedStyle(el).display !== "none" : false
})
expect(visible).toBe(true)
})
// 正确做法——具有自动等待和重试的定位器
test("good: locator methods", async ({ page }) => {
await page.goto("/settings")
await expect(page.getByTestId("username")).toHaveText("John")
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Settings saved")).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
// 错误做法
test("bad: evaluate for DOM interaction", async ({ page }) => {
const text = await page.evaluate(
() => document.querySelector('[data-testid="username"]')?.textContent
)
expect(text).toBe("John")
})
// 正确做法
test("good: locator methods", async ({ page }) => {
await expect(page.getByTestId("username")).toHaveText("John")
})
何时 evaluate() 是合适的:读取 window.__APP_STATE__、调用应用暴露的测试专用设置函数、操作 localStorage/sessionStorage,或读取没有定位器等价方法的计算样式。
陷阱 19:未对复杂流程使用 test.step()
症状:长测试难以调试。失败时,trace 显示 50+ 个操作,没有逻辑分组。HTML 报告没有提供任何结构。
原因:开发者将测试写成线性的操作序列,没有标注阶段。
修复:将逻辑阶段包裹在 test.step() 中。步骤会出现在 trace、报告和错误信息中。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——30 行操作,没有结构
test("bad: flat checkout flow", async ({ page }) => {
await page.goto("/products")
await page.getByRole("button", { name: "Add Widget" }).click()
await page.getByRole("link", { name: "Cart" }).click()
await page.getByRole("button", { name: "Checkout" }).click()
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Address").fill("123 Test St")
await page.getByRole("button", { name: "Continue" }).click()
await page.getByLabel("Card number").fill("4242424242424242")
await page.getByRole("button", { name: "Pay" }).click()
await expect(page.getByText("Order confirmed")).toBeVisible()
})
// 正确做法——逻辑步骤,在 trace 和报告中清晰可见
test("good: structured checkout flow", async ({ page }) => {
await test.step("add item to cart", async () => {
await page.goto("/products")
await page.getByRole("button", { name: "Add Widget" }).click()
await expect(page.getByTestId("cart-count")).toHaveText("1")
})
await test.step("proceed to checkout", async () => {
await page.getByRole("link", { name: "Cart" }).click()
await page.getByRole("button", { name: "Checkout" }).click()
await expect(page).toHaveURL(/.*checkout/)
})
await test.step("fill shipping details", async () => {
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Address").fill("123 Test St")
await page.getByRole("button", { name: "Continue" }).click()
})
await test.step("complete payment", async () => {
await page.getByLabel("Card number").fill("4242424242424242")
await page.getByRole("button", { name: "Pay" }).click()
await expect(page.getByText("Order confirmed")).toBeVisible()
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test("good: structured checkout flow", async ({ page }) => {
await test.step("add item to cart", async () => {
await page.goto("/products")
await page.getByRole("button", { name: "Add Widget" }).click()
await expect(page.getByTestId("cart-count")).toHaveText("1")
})
await test.step("proceed to checkout", async () => {
await page.getByRole("link", { name: "Cart" }).click()
await page.getByRole("button", { name: "Checkout" }).click()
})
await test.step("fill shipping details", async () => {
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Address").fill("123 Test St")
await page.getByRole("button", { name: "Continue" }).click()
})
await test.step("complete payment", async () => {
await page.getByLabel("Card number").fill("4242424242424242")
await page.getByRole("button", { name: "Pay" }).click()
await expect(page.getByText("Order confirmed")).toBeVisible()
})
})
当某一步失败时,错误信息会包含步骤名称:Error in step "complete payment": ...。这比只有行号要有用得多。
陷阱 20:捕获断言错误(在 expect 外围使用 try/catch)
症状:本应失败的测试却通过了。断言错误被静默吞掉。真正的缺陷未被发现。
原因:开发者将断言包裹在 try/catch 中,用于处理"可选"元素或实现条件逻辑。这违背了断言的目的。
修复:对非关键检查使用 expect.soft(),对不存在元素使用 .not 断言,或重构测试以避免条件逻辑。
TypeScript
import { test, expect } from "@playwright/test"
// 错误做法——吞掉了真正的失败
test("bad: try/catch around assertion", async ({ page }) => {
await page.goto("/dashboard")
try {
await expect(page.getByRole("alert")).toBeVisible({ timeout: 2_000 })
// 如果 alert 存在则关闭它
await page.getByRole("button", { name: "Dismiss" }).click()
} catch {
// Alert 没有出现——没关系
}
await expect(page.getByRole("heading")).toHaveText("Dashboard")
})
// 正确做法——通过计数判断元素是否存在,无需 try/catch
test("good: conditional without try/catch", async ({ page }) => {
await page.goto("/dashboard")
// 如果存在 alert 横幅,则关闭它
const alertCount = await page.getByRole("alert").count()
if (alertCount > 0) {
await page.getByRole("button", { name: "Dismiss" }).click()
await expect(page.getByRole("alert")).not.toBeVisible()
}
await expect(page.getByRole("heading")).toHaveText("Dashboard")
})
// 正确做法——对非关键检查使用软断言
test("good: soft assertions for nice-to-have checks", async ({ page }) => {
await page.goto("/dashboard")
// 这些是参考信息——即使失败,测试也会继续
await expect.soft(page.getByTestId("revenue")).toContainText("$")
await expect.soft(page.getByTestId("users")).toContainText("active")
// 这才是真正的断言——如果失败,测试会失败
await expect(page.getByRole("heading")).toHaveText("Dashboard")
})
// 正确做法——断言元素不存在(无需 try/catch)
test("good: assert absence directly", async ({ page }) => {
await page.goto("/dashboard")
// 这会自动重试直到错误消失(或超时)
await expect(page.getByRole("alert")).not.toBeVisible()
await expect(page.getByRole("heading")).toHaveText("Dashboard")
})
JavaScript
const { test, expect } = require("@playwright/test")
// 正确做法——无需 try/catch 的条件判断
test("good: conditional without try/catch", async ({ page }) => {
await page.goto("/dashboard")
const alertCount = await page.getByRole("alert").count()
if (alertCount > 0) {
await page.getByRole("button", { name: "Dismiss" }).click()
await expect(page.getByRole("alert")).not.toBeVisible()
}
await expect(page.getByRole("heading")).toHaveText("Dashboard")
})
// 正确做法——对非关键检查使用软断言
test("good: soft assertions for nice-to-have checks", async ({ page }) => {
await page.goto("/dashboard")
await expect.soft(page.getByTestId("revenue")).toContainText("$")
await expect.soft(page.getByTestId("users")).toContainText("active")
await expect(page.getByRole("heading")).toHaveText("Dashboard")
})
规则:如果你发现自己在 expect() 外围写 try/catch,说明测试设计有问题。重新考虑断言方式。
快速查阅表
| # | 陷阱 | 一句话修复 |
|---|---|---|
| 1 | waitForTimeout() |
替换为 expect(locator).toBeVisible() |
| 2 | 缺少 await |
添加 await + 启用 no-floating-promises ESLint 规则 |
| 3 | CSS 选择器 | 使用 getByRole()、getByLabel()、getByTestId() |
| 4 | isVisible() 检查 |
使用 expect(locator).toBeVisible() |
| 5 | 共享可变状态 | 使用测试作用域的 fixture 配合唯一数据 |
| 6 | 硬编码 URL | 在配置中设置 baseURL,使用相对路径 |
| 7 | page.$() / page.$$() |
使用 page.locator() 或 page.getByRole() |
| 8 | 未处理导航 | 表单提交后添加 page.waitForURL() |
| 9 | CI 中未配置 webServer |
添加 webServer 配置,设置 reuseExistingServer: !process.env.CI |
| 10 | innerHTML / textContent |
使用 expect(locator).toHaveText() |
| 11 | 过度 mock 自己的 API | 只 mock 外部服务 |
| 12 | 未使用 test.describe |
对相关测试分组,限定 beforeEach 和 test.use() 的作用域 |
| 13 | 使用 beforeAll 做逐测试设置 |
使用 beforeEach 或测试作用域的 fixture |
| 14 | 模块级测试数据变量 | 在每个测试内部或通过 fixture 创建数据 |
| 15 | describe 嵌套过深(3 层以上) |
最多 2 层;拆分为独立文件 |
| 16 | 所有环境使用相同重试次数 | retries: process.env.CI ? 2 : 0 |
| 17 | 每次 PR 都跑所有浏览器 | PR 中只跑 Chromium,全矩阵在夜间运行 |
| 18 | 过度使用 page.evaluate() |
使用定位器方法;将 evaluate 留给 JS API |
| 19 | 未使用 test.step() |
将逻辑阶段包裹在命名步骤中 |
| 20 | 在 expect 外围使用 try/catch |
使用 expect.soft()、.not 或 locator.count() |
相关文档
- core/locators.md —— 定位器策略层级(陷阱 3、7、18)
- core/assertions-and-waiting.md —— web 优先断言(陷阱 1、4、10、20)
- core/fixtures-and-hooks.md —— fixture 与钩子(陷阱 5、13、14)
- core/configuration.md —— baseURL、webServer、重试(陷阱 6、9、16、17)
- core/test-organization.md —— describe 块、嵌套、标签(陷阱 12、15)
- core/flaky-tests.md —— 诊断与修复不稳定测试(陷阱 1、2、5)
- core/debugging.md —— trace、UI 模式、page.pause()(陷阱 16、19)