1366 lines
45 KiB
Markdown
1366 lines
45 KiB
Markdown
# 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)
|