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

45 KiB
Raw Permalink Blame History

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()
})

定位器优先级(从最稳健到最脆弱):

  1. getByRole() —— 无障碍角色 + 名称
  2. getByLabel() —— 通过标签文本定位表单字段
  3. getByPlaceholder() —— 通过占位符定位输入框
  4. getByText() —— 可见文本内容
  5. getByTestId() —— 稳定的 data-testid 属性
  6. 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:过度 MockMock 自己的 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}`)
})

陷阱 15test.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.tssecurity-passwords.spec.tssecurity-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 对相关测试分组,限定 beforeEachtest.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().notlocator.count()

相关文档