# Common Pitfalls > **使用场景**:学习 Playwright、审查测试中的常见错误,或让新团队成员上手测试套件时参考。 按在真实代码库中出现频率排序的 20 个最常见 Playwright 错误。每个陷阱包含症状、根因以及完整的修复方案。 --- ## 陷阱 1:使用 `page.waitForTimeout()` 代替断言 **症状**:测试缓慢且不稳定。在快速机器上能通过,但在慢速 CI 运行环境中会失败。 **原因**:开发者将 Selenium 或 Cypress 中的习惯带了过来,那些工具需要显式等待。在 Playwright 中,自动重试断言会自动处理时序问题。 **修复**:将每一个 `waitForTimeout` 替换为 web 优先断言或对特定条件的显式等待。 **TypeScript** ```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** ```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** ```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** ```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: ```json { "rules": { "@typescript-eslint/no-floating-promises": "error" } } ``` --- ## 陷阱 3:使用 CSS 选择器而非基于角色的定位器 **症状**:只要 CSS 类名、DOM 结构或组件库发生变化,测试就会失败。测试难以阅读,因为选择器看起来像 `.btn-primary > span:nth-child(2)`。 **原因**:开发者使用从 jQuery 或 DevTools 中学到的方式。CSS 选择器是频繁变化的实现细节。 **修复**:使用 Playwright 内置的定位器,它们针对无障碍角色、标签、文本和测试 ID 进行定位。 **TypeScript** ```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** ```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** ```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** ```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** ```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** ```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** ```typescript // playwright.config.ts import { defineConfig } from "@playwright/test" export default defineConfig({ use: { baseURL: process.env.BASE_URL || "http://localhost:3000", }, }) ``` ```typescript 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** ```javascript // playwright.config.js const { defineConfig } = require("@playwright/test") module.exports = defineConfig({ use: { baseURL: process.env.BASE_URL || "http://localhost:3000", }, }) ``` ```javascript 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** ```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** ```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** ```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** ```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** ```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** ```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", }, }) ``` 对于多个服务器(前端 + 后端),传入数组: ```typescript 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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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, }) ``` ```yaml # .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** ```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** ```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** ```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** ```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** ```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** ```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](locators.md) —— 定位器策略层级(陷阱 3、7、18) - [core/assertions-and-waiting.md](assertions-and-waiting.md) —— web 优先断言(陷阱 1、4、10、20) - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) —— fixture 与钩子(陷阱 5、13、14) - [core/configuration.md](configuration.md) —— baseURL、webServer、重试(陷阱 6、9、16、17) - [core/test-organization.md](test-organization.md) —— describe 块、嵌套、标签(陷阱 12、15) - [core/flaky-tests.md](flaky-tests.md) —— 诊断与修复不稳定测试(陷阱 1、2、5) - [core/debugging.md](debugging.md) —— trace、UI 模式、page.pause()(陷阱 16、19)