857 lines
30 KiB
Markdown
857 lines
30 KiB
Markdown
> > 译文开始。
|
||
|
||
# 不稳定测试
|
||
|
||
> **使用时机**:测试有时通过、有时失败,需要诊断根本原因、修复并防止再次发生。
|
||
> **前置知识**:[core/assertions-and-waiting.md](assertions-and-waiting.md)、[core/fixtures-and-hooks.md](fixtures-and-hooks.md)
|
||
|
||
## 快速参考
|
||
|
||
```bash
|
||
# 压力测试——运行 10 次以暴露不稳定性
|
||
npx playwright test tests/checkout.spec.ts --repeat-each=10
|
||
|
||
# 带重试运行以捕获间歇性失败
|
||
npx playwright test --retries=3
|
||
|
||
# 隔离运行单个测试以排除状态泄漏
|
||
npx playwright test tests/checkout.spec.ts --grep "adds item" --workers=1
|
||
|
||
# 每次尝试均开启跟踪以进行对比
|
||
npx playwright test --retries=3 --trace=on
|
||
|
||
# 全并行模式运行以暴露隔离问题
|
||
npx playwright test --fully-parallel --workers=4
|
||
|
||
# 列出不稳定测试(先失败后通过重试的测试)
|
||
npx playwright test --retries=2 --reporter=json | jq '.suites[].specs[] | select(.ok == true and (.tests[].results | length > 1))'
|
||
```
|
||
|
||
## 模式
|
||
|
||
### 不稳定性分类
|
||
|
||
每个不稳定测试都属于以下四类之一。先识别类别,再应用相应的修复方案。
|
||
|
||
| 类别 | 症状 | 典型根本原因 | 诊断方法 |
|
||
| ------------------ | -------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||
| **时序/异步** | 在任何环境下间歇性失败 | 竞态条件、缺少 `await`、随意等待 | 本地使用 `--repeat-each=20` 会失败 |
|
||
| **测试隔离** | 仅与其他测试一起运行时失败,单独运行通过 | 共享可变状态、数据冲突、测试顺序依赖 | 使用 `--workers=1 --grep "this test"` 通过,在完整套件中失败 |
|
||
| **环境** | 仅在 CI 中失败,本地通过 | 不同的操作系统、视口、字体、网络延迟、缺少依赖 | 对比 CI 截图/跟踪与本地结果;在本地 Docker 中运行 |
|
||
| **基础设施** | 与测试逻辑无关的随机失败 | 浏览器崩溃、OOM、DNS 解析、文件系统竞态 | 失败无规律;错误信息涉及浏览器内部机制 |
|
||
|
||
### 诊断流程图
|
||
|
||
按此决策树识别你的不稳定测试属于哪一类别。
|
||
|
||
```
|
||
测试不稳定
|
||
|
|
||
+-- 本地使用 --repeat-each=20 会失败吗?
|
||
| |
|
||
| +-- 是 --> 时序/异步问题
|
||
| | - 缺少 await
|
||
| | - 使用 waitForTimeout 而非断言
|
||
| | - 操作与断言之间的竞态条件
|
||
| | - 断言前未等待网络响应
|
||
| |
|
||
| +-- 否 --> 仅在 CI 中失败吗?
|
||
| |
|
||
| +-- 是 --> 环境问题
|
||
| | - 不同的视口/屏幕尺寸
|
||
| | - 缺少字体导致布局偏移
|
||
| | - CI 机器较慢导致超时
|
||
| | - 外部服务不可用
|
||
| |
|
||
| +-- 否 --> 仅与其他测试一起运行时失败吗?
|
||
| |
|
||
| +-- 是 --> 隔离问题
|
||
| | - 共享可变状态(模块级变量)
|
||
| | - 前一个测试留下的数据库/API 状态
|
||
| | - localStorage/cookies 在测试之间泄漏
|
||
| | - 并行测试在唯一约束上冲突
|
||
| |
|
||
| +-- 否 --> 基础设施问题
|
||
| - 浏览器进程崩溃
|
||
| - 内存不足
|
||
| - 文件系统或网络不稳定
|
||
| - 不稳定的第三方服务
|
||
```
|
||
|
||
### 修复:时序和异步问题
|
||
|
||
**使用时机**:本地使用 `--repeat-each=20` 会失败,或者出现 `waitForTimeout`、缺少 `await` 或竞态条件。
|
||
|
||
最常导致不稳定的根源。修复方法始终相同:用 Playwright 的自动重试机制替换随意等待和手动检查。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
// ---- 修复 1:用断言替换 waitForTimeout ----
|
||
|
||
// 不好——随意延迟,在慢机器上失败,在快机器上浪费时间
|
||
test("bad: uses arbitrary wait", async ({ page }) => {
|
||
await page.goto("/dashboard")
|
||
await page.getByRole("button", { name: "Refresh" }).click()
|
||
await page.waitForTimeout(3000) // 希望数据在 3 秒内加载完成
|
||
await expect(page.getByTestId("data-table")).toBeVisible()
|
||
})
|
||
|
||
// 好——自动重试断言等待恰好所需的时间
|
||
test("good: uses auto-retrying assertion", async ({ page }) => {
|
||
await page.goto("/dashboard")
|
||
await page.getByRole("button", { name: "Refresh" }).click()
|
||
await expect(page.getByTestId("data-table")).toBeVisible()
|
||
})
|
||
|
||
// ---- 修复 2:断言前等待网络响应 ----
|
||
|
||
// 不好——点击按钮后立即断言,但数据来自 API
|
||
test("bad: does not wait for API response", async ({ page }) => {
|
||
await page.goto("/users")
|
||
await page.getByRole("button", { name: "Load More" }).click()
|
||
// 不稳定:API 响应可能尚未到达
|
||
await expect(page.getByRole("listitem")).toHaveCount(20)
|
||
})
|
||
|
||
// 好——等待填充数据的特定 API 响应
|
||
test("good: waits for API response", async ({ page }) => {
|
||
await page.goto("/users")
|
||
|
||
const responsePromise = page.waitForResponse(
|
||
(resp) => resp.url().includes("/api/users") && resp.status() === 200
|
||
)
|
||
await page.getByRole("button", { name: "Load More" }).click()
|
||
await responsePromise
|
||
|
||
await expect(page.getByRole("listitem")).toHaveCount(20)
|
||
})
|
||
|
||
// ---- 修复 3:处理动画和过渡 ----
|
||
|
||
// 不好——元素存在但正在动画中,点击落在错误目标上
|
||
test("bad: clicks during animation", async ({ page }) => {
|
||
await page.goto("/modal-demo")
|
||
await page.getByRole("button", { name: "Open" }).click()
|
||
// 模态框正在动画进入——点击可能错过其中的按钮
|
||
await page.getByRole("button", { name: "Confirm" }).click()
|
||
})
|
||
|
||
// 好——等待模态框完全稳定后再交互
|
||
test("good: waits for stable state", async ({ page }) => {
|
||
await page.goto("/modal-demo")
|
||
await page.getByRole("button", { name: "Open" }).click()
|
||
// toBeVisible 自动等待稳定性(无动画进行中)
|
||
await expect(page.getByRole("dialog")).toBeVisible()
|
||
await page.getByRole("button", { name: "Confirm" }).click()
|
||
})
|
||
|
||
// ---- 修复 4:对必须整体成功的多步骤断言使用 toPass() ----
|
||
|
||
test("good: retry entire assertion block", async ({ page }) => {
|
||
await page.goto("/search")
|
||
|
||
await expect(async () => {
|
||
await page.getByLabel("Search").fill("playwright")
|
||
await page.getByRole("button", { name: "Search" }).click()
|
||
await expect(page.getByTestId("result-count")).toHaveText("10 results")
|
||
}).toPass({
|
||
timeout: 15_000,
|
||
intervals: [1_000, 2_000, 5_000],
|
||
})
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
// 修复 1:用断言替换 waitForTimeout
|
||
test("good: uses auto-retrying assertion", async ({ page }) => {
|
||
await page.goto("/dashboard")
|
||
await page.getByRole("button", { name: "Refresh" }).click()
|
||
await expect(page.getByTestId("data-table")).toBeVisible()
|
||
})
|
||
|
||
// 修复 2:断言前等待网络响应
|
||
test("good: waits for API response", async ({ page }) => {
|
||
await page.goto("/users")
|
||
|
||
const responsePromise = page.waitForResponse(
|
||
(resp) => resp.url().includes("/api/users") && resp.status() === 200
|
||
)
|
||
await page.getByRole("button", { name: "Load More" }).click()
|
||
await responsePromise
|
||
|
||
await expect(page.getByRole("listitem")).toHaveCount(20)
|
||
})
|
||
|
||
// 修复 3:处理动画和过渡
|
||
test("good: waits for stable state", async ({ page }) => {
|
||
await page.goto("/modal-demo")
|
||
await page.getByRole("button", { name: "Open" }).click()
|
||
await expect(page.getByRole("dialog")).toBeVisible()
|
||
await page.getByRole("button", { name: "Confirm" }).click()
|
||
})
|
||
|
||
// 修复 4:对多步骤断言使用 toPass()
|
||
test("good: retry entire assertion block", async ({ page }) => {
|
||
await page.goto("/search")
|
||
|
||
await expect(async () => {
|
||
await page.getByLabel("Search").fill("playwright")
|
||
await page.getByRole("button", { name: "Search" }).click()
|
||
await expect(page.getByTestId("result-count")).toHaveText("10 results")
|
||
}).toPass({
|
||
timeout: 15_000,
|
||
intervals: [1_000, 2_000, 5_000],
|
||
})
|
||
})
|
||
```
|
||
|
||
### 修复:测试隔离问题
|
||
|
||
**使用时机**:测试单独运行时通过(`--grep "test name"`),但与其他测试一起运行时失败,或仅在并行模式下失败。
|
||
|
||
隔离问题源于共享状态:模块级变量、数据库行、localStorage、cookies 或文件系统产物。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test as base, expect } from "@playwright/test"
|
||
|
||
// ---- 修复 1:每个测试使用唯一数据 ----
|
||
|
||
// 不好——所有并行测试使用相同的邮箱,导致唯一约束冲突
|
||
test("bad: hardcoded data", async ({ page }) => {
|
||
await page.goto("/register")
|
||
await page.getByLabel("Email").fill("test@example.com")
|
||
await page.getByRole("button", { name: "Register" }).click()
|
||
await expect(page.getByText("Welcome")).toBeVisible()
|
||
})
|
||
|
||
// 好——每次测试运行使用唯一邮箱
|
||
test("good: unique data per test", async ({ page }) => {
|
||
const email = `test-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`
|
||
|
||
await page.goto("/register")
|
||
await page.getByLabel("Email").fill(email)
|
||
await page.getByRole("button", { name: "Register" }).click()
|
||
await expect(page.getByText("Welcome")).toBeVisible()
|
||
})
|
||
|
||
// ---- 修复 2:对共享的昂贵资源使用 Worker 作用域的夹具 ----
|
||
|
||
type WorkerFixtures = {
|
||
workerAccount: { email: string; id: string }
|
||
}
|
||
|
||
export const test = base.extend<{}, WorkerFixtures>({
|
||
workerAccount: [
|
||
async ({ request }, use) => {
|
||
const email = `worker-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`
|
||
const response = await request.post("/api/users", {
|
||
data: { email, password: "TestP@ss123!" },
|
||
})
|
||
const account = await response.json()
|
||
|
||
await use({ email, id: account.id })
|
||
|
||
// 该 Worker 中所有测试完成后清理
|
||
await request.delete(`/api/users/${account.id}`)
|
||
},
|
||
{ scope: "worker" },
|
||
],
|
||
})
|
||
|
||
// ---- 修复 3:在夹具拆卸中清理状态 ----
|
||
|
||
export const testWithCleanup = base.extend({
|
||
cleanPage: async ({ page }, use) => {
|
||
await use(page)
|
||
|
||
// 拆卸:清除所有客户端状态
|
||
await page.evaluate(() => {
|
||
localStorage.clear()
|
||
sessionStorage.clear()
|
||
})
|
||
await page.context().clearCookies()
|
||
},
|
||
})
|
||
|
||
// ---- 修复 4:隔离不能并行运行的测试 ----
|
||
|
||
import { test } from "@playwright/test"
|
||
|
||
// 仅在测试确实依赖共享状态时使用串行模式
|
||
// (例如,多步骤向导,每个测试是一个步骤)
|
||
test.describe.serial("checkout wizard", () => {
|
||
test("step 1: add items", async ({ page }) => {
|
||
await page.goto("/shop")
|
||
await page.getByRole("button", { name: "Add Widget" }).click()
|
||
await expect(page.getByTestId("cart-count")).toHaveText("1")
|
||
})
|
||
|
||
test("step 2: enter shipping", async ({ page }) => {
|
||
await page.goto("/checkout/shipping")
|
||
await page.getByLabel("Address").fill("123 Test St")
|
||
await page.getByRole("button", { name: "Continue" }).click()
|
||
})
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test: base, expect } = require("@playwright/test")
|
||
|
||
// 修复 1:每个测试使用唯一数据
|
||
test("good: unique data per test", async ({ page }) => {
|
||
const email = `test-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`
|
||
|
||
await page.goto("/register")
|
||
await page.getByLabel("Email").fill(email)
|
||
await page.getByRole("button", { name: "Register" }).click()
|
||
await expect(page.getByText("Welcome")).toBeVisible()
|
||
})
|
||
|
||
// 修复 2:对共享的昂贵资源使用 Worker 作用域的夹具
|
||
const test = base.extend({
|
||
workerAccount: [
|
||
async ({ request }, use) => {
|
||
const email = `worker-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`
|
||
const response = await request.post("/api/users", {
|
||
data: { email, password: "TestP@ss123!" },
|
||
})
|
||
const account = await response.json()
|
||
|
||
await use({ email, id: account.id })
|
||
|
||
await request.delete(`/api/users/${account.id}`)
|
||
},
|
||
{ scope: "worker" },
|
||
],
|
||
})
|
||
|
||
module.exports = { test, expect }
|
||
```
|
||
|
||
### 修复:环境问题
|
||
|
||
**使用时机**:测试本地通过但在 CI 中失败,或在某些操作系统、视口或机器上失败。
|
||
|
||
环境不稳定性源于本地机器与 CI 之间在渲染、时序、可用资源或外部服务可用性方面的差异。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// playwright.config.ts —— 与环境一致的配置
|
||
import { defineConfig, devices } from "@playwright/test"
|
||
|
||
export default defineConfig({
|
||
// ---- 修复 1:禁用动画以获得确定性行为 ----
|
||
use: {
|
||
// 禁用 CSS 动画和过渡
|
||
contextOptions: {
|
||
reducedMotion: "reduce",
|
||
},
|
||
},
|
||
|
||
// ---- 修复 2:跨环境使用一致的视口 ----
|
||
projects: [
|
||
{
|
||
name: "chromium",
|
||
use: {
|
||
...devices["Desktop Chrome"],
|
||
// 显式指定视口防止本地与 CI 之间的布局差异
|
||
viewport: { width: 1280, height: 720 },
|
||
},
|
||
},
|
||
],
|
||
|
||
// ---- 修复 3:使用 webServer 在 CI 中启动应用 ----
|
||
webServer: {
|
||
command: "npm run start",
|
||
url: "http://localhost:3000",
|
||
reuseExistingServer: !process.env.CI,
|
||
timeout: 120_000,
|
||
},
|
||
|
||
// ---- 修复 4:为较慢的 CI 机器设置更高的超时 ----
|
||
timeout: process.env.CI ? 60_000 : 30_000,
|
||
expect: {
|
||
timeout: process.env.CI ? 10_000 : 5_000,
|
||
},
|
||
})
|
||
```
|
||
|
||
```typescript
|
||
// tests/fixtures/stub-externals.ts —— 桩接外部服务
|
||
import { test as base, expect } from "@playwright/test"
|
||
|
||
export const test = base.extend({
|
||
// 自动夹具:在每个测试中拦截所有外部服务
|
||
stubExternals: [
|
||
async ({ page }, use) => {
|
||
// 拦截环境间存在差异的第三方脚本
|
||
await page.route(/google-analytics|segment|hotjar|intercom/, (route) => route.abort())
|
||
|
||
// 用一致响应桩接不稳定的外部 API
|
||
await page.route("**/api.external-service.com/**", (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ status: "ok", data: [] }),
|
||
})
|
||
)
|
||
|
||
await use()
|
||
},
|
||
{ auto: true },
|
||
],
|
||
})
|
||
|
||
export { expect }
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// playwright.config.js
|
||
const { defineConfig, devices } = require("@playwright/test")
|
||
|
||
module.exports = defineConfig({
|
||
use: {
|
||
contextOptions: {
|
||
reducedMotion: "reduce",
|
||
},
|
||
},
|
||
projects: [
|
||
{
|
||
name: "chromium",
|
||
use: {
|
||
...devices["Desktop Chrome"],
|
||
viewport: { width: 1280, height: 720 },
|
||
},
|
||
},
|
||
],
|
||
webServer: {
|
||
command: "npm run start",
|
||
url: "http://localhost:3000",
|
||
reuseExistingServer: !process.env.CI,
|
||
timeout: 120_000,
|
||
},
|
||
timeout: process.env.CI ? 60_000 : 30_000,
|
||
expect: {
|
||
timeout: process.env.CI ? 10_000 : 5_000,
|
||
},
|
||
})
|
||
```
|
||
|
||
```javascript
|
||
// tests/fixtures/stub-externals.js
|
||
const { test: base, expect } = require("@playwright/test")
|
||
|
||
const test = base.extend({
|
||
stubExternals: [
|
||
async ({ page }, use) => {
|
||
await page.route(/google-analytics|segment|hotjar|intercom/, (route) => route.abort())
|
||
|
||
await page.route("**/api.external-service.com/**", (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ status: "ok", data: [] }),
|
||
})
|
||
)
|
||
|
||
await use()
|
||
},
|
||
{ auto: true },
|
||
],
|
||
})
|
||
|
||
module.exports = { test, expect }
|
||
```
|
||
|
||
### 检测策略
|
||
|
||
**使用时机**:怀疑测试不稳定但并非每次都会失败,或者希望验证修复是否确实消除了不稳定性。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// ---- 策略 1:使用 --repeat-each 进行压力测试 ----
|
||
// 将测试运行 20 次。只要失败一次,就说明存在不稳定性缺陷。
|
||
// npx playwright test tests/checkout.spec.ts --repeat-each=20
|
||
|
||
// ---- 策略 2:通过重试配置捕获间歇性失败 ----
|
||
// playwright.config.ts
|
||
import { defineConfig } from "@playwright/test"
|
||
|
||
export default defineConfig({
|
||
// 在 CI 中重试以在报告中暴露不稳定测试
|
||
retries: process.env.CI ? 2 : 0,
|
||
|
||
// 报告器显示哪些测试需要重试
|
||
reporter: process.env.CI
|
||
? [
|
||
["html", { open: "never" }],
|
||
["json", { outputFile: "results.json" }],
|
||
]
|
||
: [["html", { open: "on-failure" }]],
|
||
})
|
||
|
||
// ---- 策略 3:自定义报告器跟踪不稳定测试指标 ----
|
||
// flaky-reporter.ts
|
||
import type { Reporter, TestCase, TestResult } from "@playwright/test/reporter"
|
||
|
||
class FlakyReporter implements Reporter {
|
||
private flakyTests: { name: string; file: string; retries: number }[] = []
|
||
|
||
onTestEnd(test: TestCase, result: TestResult) {
|
||
if (result.retry > 0 && result.status === "passed") {
|
||
this.flakyTests.push({
|
||
name: test.title,
|
||
file: test.location.file,
|
||
retries: result.retry,
|
||
})
|
||
}
|
||
}
|
||
|
||
onEnd() {
|
||
if (this.flakyTests.length > 0) {
|
||
console.log("\n--- FLAKY TESTS ---")
|
||
for (const t of this.flakyTests) {
|
||
console.log(` ${t.file} > "${t.name}" (needed ${t.retries} retries)`)
|
||
}
|
||
console.log(`Total flaky: ${this.flakyTests.length}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
export default FlakyReporter
|
||
```
|
||
|
||
```typescript
|
||
// playwright.config.ts —— 注册自定义不稳定测试报告器
|
||
import { defineConfig } from "@playwright/test"
|
||
|
||
export default defineConfig({
|
||
retries: process.env.CI ? 2 : 0,
|
||
reporter: [["html"], ["./flaky-reporter.ts"]],
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// flaky-reporter.js
|
||
class FlakyReporter {
|
||
constructor() {
|
||
this.flakyTests = []
|
||
}
|
||
|
||
onTestEnd(test, result) {
|
||
if (result.retry > 0 && result.status === "passed") {
|
||
this.flakyTests.push({
|
||
name: test.title,
|
||
file: test.location.file,
|
||
retries: result.retry,
|
||
})
|
||
}
|
||
}
|
||
|
||
onEnd() {
|
||
if (this.flakyTests.length > 0) {
|
||
console.log("\n--- FLAKY TESTS ---")
|
||
for (const t of this.flakyTests) {
|
||
console.log(` ${t.file} > "${t.name}" (needed ${t.retries} retries)`)
|
||
}
|
||
console.log(`Total flaky: ${this.flakyTests.length}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = FlakyReporter
|
||
```
|
||
|
||
### 隔离策略
|
||
|
||
**使用时机**:测试已知不稳定且无法立即修复。将其隔离,使其不阻塞 CI,但加以跟踪以免被遗忘。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
// ---- 选项 1:test.fixme() —— 跳过测试并附带原因 ----
|
||
test.fixme("checkout with promo code applies discount", async ({ page }) => {
|
||
// TODO(JIRA-1234):因促销服务中的竞态条件而不稳定
|
||
// 约 10% 的运行会失败。根本原因:/api/promo 在渲染完成后才响应
|
||
await page.goto("/checkout")
|
||
await page.getByLabel("Promo code").fill("SAVE20")
|
||
await page.getByRole("button", { name: "Apply" }).click()
|
||
await expect(page.getByTestId("discount")).toHaveText("-$20.00")
|
||
})
|
||
|
||
// ---- 选项 2:test.fail() —— 取反:测试仅在失败时通过 ----
|
||
// 当你明确知道测试会失败,并希望 CI 在它开始通过时提醒你时使用
|
||
test.fail("known broken: export to PDF", async ({ page }) => {
|
||
// 当此测试开始通过时,.fail() 注释将使其失败,
|
||
// 提醒你移除该注释
|
||
await page.goto("/reports")
|
||
await page.getByRole("button", { name: "Export PDF" }).click()
|
||
await expect(page.getByText("PDF ready")).toBeVisible({ timeout: 10_000 })
|
||
})
|
||
|
||
// ---- 选项 3:按标签跳过 —— 使用 grep 过滤器隔离 ----
|
||
test("@flaky checkout race condition", async ({ page }) => {
|
||
// 在 CI 中,排除带不稳定标签的测试:npx playwright test --grep-invert @flaky
|
||
// 仅在夜间运行不稳定测试:npx playwright test --grep @flaky --retries=5
|
||
await page.goto("/checkout")
|
||
await page.getByRole("button", { name: "Place Order" }).click()
|
||
await expect(page.getByText("Order confirmed")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
// 选项 1:test.fixme() —— 跳过测试并附带原因
|
||
test.fixme("checkout with promo code applies discount", async ({ page }) => {
|
||
// TODO(JIRA-1234):因促销服务中的竞态条件而不稳定
|
||
await page.goto("/checkout")
|
||
await page.getByLabel("Promo code").fill("SAVE20")
|
||
await page.getByRole("button", { name: "Apply" }).click()
|
||
await expect(page.getByTestId("discount")).toHaveText("-$20.00")
|
||
})
|
||
|
||
// 选项 2:test.fail() —— 取反:测试仅在失败时通过
|
||
test.fail("known broken: export to PDF", async ({ page }) => {
|
||
await page.goto("/reports")
|
||
await page.getByRole("button", { name: "Export PDF" }).click()
|
||
await expect(page.getByText("PDF ready")).toBeVisible({ timeout: 10_000 })
|
||
})
|
||
|
||
// 选项 3:按标签跳过
|
||
test("@flaky checkout race condition", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
await page.getByRole("button", { name: "Place Order" }).click()
|
||
await expect(page.getByText("Order confirmed")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**隔离的 CI 配置:**
|
||
|
||
```yaml
|
||
# .github/workflows/tests.yml
|
||
jobs:
|
||
e2e-tests:
|
||
steps:
|
||
- name: 运行稳定测试
|
||
run: npx playwright test --grep-invert @flaky
|
||
|
||
flaky-monitoring:
|
||
# 夜间运行,不在每次 PR 时运行
|
||
schedule:
|
||
- cron: "0 3 * * *"
|
||
steps:
|
||
- name: 带重试运行不稳定测试
|
||
run: npx playwright test --grep @flaky --retries=5 --reporter=json
|
||
- name: 报告不稳定测试结果
|
||
run: node scripts/report-flaky-metrics.js
|
||
```
|
||
|
||
### 预防清单
|
||
|
||
从一开始就应用这些规则,防止不稳定性进入你的测试套件。
|
||
|
||
**TypeScript**
|
||
|
||
```typescript
|
||
// playwright.config.ts —— 抗不稳定性配置
|
||
import { defineConfig, devices } from "@playwright/test"
|
||
|
||
export default defineConfig({
|
||
// 规则 1:完全并行运行测试以尽早暴露隔离问题
|
||
fullyParallel: true,
|
||
|
||
// 规则 2:如果代码中遗留了 test.only(),使 CI 失败
|
||
forbidOnly: !!process.env.CI,
|
||
|
||
// 规则 3:在 CI 中使用重试来暴露(而非隐藏)不稳定测试
|
||
retries: process.env.CI ? 2 : 0,
|
||
|
||
// 规则 4:合理的超时——既不过高也不过低
|
||
timeout: 30_000,
|
||
expect: { timeout: 5_000 },
|
||
|
||
use: {
|
||
// 规则 5:始终在重试时捕获跟踪以便调试
|
||
trace: "on-first-retry",
|
||
|
||
// 规则 6:使用 baseURL——绝不在测试中硬编码完整 URL
|
||
baseURL: process.env.BASE_URL || "http://localhost:3000",
|
||
|
||
// 规则 7:禁用动画以获得确定性行为
|
||
contextOptions: {
|
||
reducedMotion: "reduce",
|
||
},
|
||
|
||
// 规则 8:显式指定视口——本地与 CI 一致
|
||
viewport: { width: 1280, height: 720 },
|
||
},
|
||
|
||
// 规则 9:在 CI 中自动启动应用
|
||
webServer: {
|
||
command: "npm run start",
|
||
url: "http://localhost:3000",
|
||
reuseExistingServer: !process.env.CI,
|
||
},
|
||
})
|
||
```
|
||
|
||
```typescript
|
||
// tests/example-stable-test.spec.ts —— 在测试中应用所有规则
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test.describe("user profile", () => {
|
||
test("updates display name", async ({ page }) => {
|
||
// 规则 10:每个测试使用唯一数据
|
||
const newName = `User-${Date.now()}`
|
||
|
||
// 规则 11:使用 baseURL——仅使用相对路径
|
||
await page.goto("/profile")
|
||
|
||
// 规则 12:基于角色的定位器——对实现变更具有弹性
|
||
await page.getByRole("textbox", { name: "Display name" }).fill(newName)
|
||
await page.getByRole("button", { name: "Save" }).click()
|
||
|
||
// 规则 13:自动重试断言——绝不手动等待
|
||
await expect(page.getByRole("alert")).toHaveText("Profile updated")
|
||
|
||
// 规则 14:对结果(而非中间状态)进行断言
|
||
await expect(page.getByRole("textbox", { name: "Display name" })).toHaveValue(newName)
|
||
})
|
||
})
|
||
```
|
||
|
||
**JavaScript**
|
||
|
||
```javascript
|
||
// playwright.config.js
|
||
const { defineConfig, devices } = require("@playwright/test")
|
||
|
||
module.exports = defineConfig({
|
||
fullyParallel: true,
|
||
forbidOnly: !!process.env.CI,
|
||
retries: process.env.CI ? 2 : 0,
|
||
timeout: 30_000,
|
||
expect: { timeout: 5_000 },
|
||
use: {
|
||
trace: "on-first-retry",
|
||
baseURL: process.env.BASE_URL || "http://localhost:3000",
|
||
contextOptions: {
|
||
reducedMotion: "reduce",
|
||
},
|
||
viewport: { width: 1280, height: 720 },
|
||
},
|
||
webServer: {
|
||
command: "npm run start",
|
||
url: "http://localhost:3000",
|
||
reuseExistingServer: !process.env.CI,
|
||
},
|
||
})
|
||
```
|
||
|
||
```javascript
|
||
// tests/example-stable-test.spec.js
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test.describe("user profile", () => {
|
||
test("updates display name", async ({ page }) => {
|
||
const newName = `User-${Date.now()}`
|
||
|
||
await page.goto("/profile")
|
||
await page.getByRole("textbox", { name: "Display name" }).fill(newName)
|
||
await page.getByRole("button", { name: "Save" }).click()
|
||
|
||
await expect(page.getByRole("alert")).toHaveText("Profile updated")
|
||
await expect(page.getByRole("textbox", { name: "Display name" })).toHaveValue(newName)
|
||
})
|
||
})
|
||
```
|
||
|
||
## 决策指南
|
||
|
||
```
|
||
我的测试不稳定。我该怎么办?
|
||
|
|
||
+-- 第 1 步:在本地复现
|
||
| |
|
||
| +-- npx playwright test <file> --repeat-each=20
|
||
| +-- 失败?--> 时序问题。用自动重试断言修复。
|
||
| +-- 不失败?--> 继续到第 2 步。
|
||
|
|
||
+-- 第 2 步:与其他测试隔离
|
||
| |
|
||
| +-- npx playwright test --grep "exact test name" --workers=1
|
||
| +-- 单独通过?--> 隔离问题。用唯一数据 + 夹具修复。
|
||
| +-- 单独失败?--> 继续到第 3 步。
|
||
|
|
||
+-- 第 3 步:对比环境
|
||
| |
|
||
| +-- 下载 CI 跟踪,与本地跟踪对比
|
||
| +-- 不同?--> 环境问题。用显式视口、
|
||
| | reducedMotion、webServer 配置、桩接外部服务修复。
|
||
| +-- 相同?--> 继续到第 4 步。
|
||
|
|
||
+-- 第 4 步:检查基础设施
|
||
| |
|
||
| +-- 错误提到浏览器崩溃、OOM、DNS、ECONNREFUSED?
|
||
| +-- 是 --> 基础设施问题。用 Docker、重试配置、
|
||
| | 测试运行前的健康检查修复。
|
||
| +-- 否 --> 重新检查。启用 trace: 'on' 和重试以收集
|
||
| 更多数据。并排对比通过和失败的跟踪。
|
||
```
|
||
|
||
## 反模式
|
||
|
||
| 不要这样做 | 问题 | 应该这样做 |
|
||
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||
| 将超时增加到 120 秒来"修复"不稳定性 | 掩盖了真正的问题。失败时测试变得慢得难以忍受。拖慢整个 CI 流水线。 | 诊断根本原因。修复竞态条件,而不是超时。 |
|
||
| 使用 `page.waitForTimeout(N)` | 随意延迟在快机器上太慢,在慢机器上太快。不稳定性的头号原因。 | 使用 `expect(locator).toBeVisible()`、`page.waitForResponse()` 或 `expect.poll()`。 |
|
||
| 忽略不稳定测试("再运行一次就能通过") | 不稳定测试侵蚀对整个套件的信任。人们不再看失败报告。真正的缺陷被漏掉。 | 立即诊断。如果无法立即修复,使用 `test.fixme()` 加跟踪工单进行隔离。 |
|
||
| 添加 `--retries=3` 就声称修复了 | 重试不能修复不稳定性,它们只是隐藏了它。需要重试的测试就是有缺陷的测试。 | 使用重试来**检测**不稳定性(在报告中检查重试次数),而非掩盖它。 |
|
||
| 使用 `test.describe.serial()` 修复顺序依赖的测试 | 串行模式强制块中的所有测试顺序执行。它隐藏了隔离缺陷并拖慢了套件。 | 修复隔离问题。每个测试应独立于执行顺序通过。 |
|
||
| 模拟所有内容以防止环境差异 | 过度模拟降低了实际系统能正常工作的信心。测试通过但应用却坏了。 | 仅模拟外部/第三方服务。对你自己的 API 进行真实测试。 |
|
||
| 每次提交都在 CI 中运行 `--repeat-each=100` | 将 CI 时间放大 100 倍。浪费资源。 | 在本地或夜间任务中进行压力测试,而不是每次 PR 都做。 |
|
||
|
||
## 故障排查
|
||
|
||
| 症状 | 类别 | 修复 |
|
||
| --------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||
| 测试间歇性失败,提示"Timeout 5000ms" | 时序 | 将 `expect.timeout` 增加到 10 秒,或在断言前添加 `page.waitForResponse()` |
|
||
| 测试单独通过,在完整套件运行中失败 | 隔离 | 检查模块级 `let` 变量、共享数据库行或 localStorage 泄漏 |
|
||
| 测试本地通过,在 CI 中失败 | 环境 | 对比跟踪。检查视口、字体、`reducedMotion` 和外部服务可用性 |
|
||
| 测试失败,提示"Target closed"或"Browser closed" | 基础设施 | 检查 CI 内存限制。添加 `--workers=50%` 以减少并行负载。在 `beforeAll` 中添加健康检查 |
|
||
| 测试每次失败方式不同 | 时序 + 隔离 | 启用 `trace: 'on'` 并对比多个失败跟踪。不一致性本身就是线索 |
|
||
| 不稳定测试 99/100 次通过 | 时序(罕见竞态) | 本地使用 `--repeat-each=200`。针对特定竞态添加 `page.waitForResponse()` 或 `expect.poll()` |
|
||
| 可视化对比测试不稳定 | 环境 | 使用 `maxDiffPixelRatio` 阈值。使用 `@font-face` 设置显式字体。使用 Docker 实现一致渲染 |
|
||
| 测试仅在 WebKit 上不稳定 | 环境 | WebKit 时序行为不同。按项目添加 WebKit 特定的断言或增加超时 |
|
||
|
||
## 相关文档
|
||
|
||
- [core/assertions-and-waiting.md](assertions-and-waiting.md) —— 自动重试断言和显式等待
|
||
- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) —— 用于测试隔离的夹具拆卸
|
||
- [core/test-data-management.md](test-data-management.md) —— 每个测试使用唯一数据、工厂函数
|
||
- [core/configuration.md](configuration.md) —— 重试、超时和跟踪配置
|
||
- [core/debugging.md](debugging.md) —— 跟踪查看器、UI 模式和 Inspector,用于诊断失败
|
||
- [core/common-pitfalls.md](common-pitfalls.md) —— 导致不稳定性的常见错误
|