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

1366 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:过度 MockMock 自己的 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