30 KiB
30 KiB
译文开始。
不稳定测试
使用时机:测试有时通过、有时失败,需要诊断根本原因、修复并防止再次发生。 前置知识:core/assertions-and-waiting.md、core/fixtures-and-hooks.md
快速参考
# 压力测试——运行 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
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
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
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
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
// 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,
},
})
// 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
// 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,
},
})
// 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
// ---- 策略 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
// playwright.config.ts —— 注册自定义不稳定测试报告器
import { defineConfig } from "@playwright/test"
export default defineConfig({
retries: process.env.CI ? 2 : 0,
reporter: [["html"], ["./flaky-reporter.ts"]],
})
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
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
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 配置:
# .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
// 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,
},
})
// 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
// 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,
},
})
// 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 —— 自动重试断言和显式等待
- core/fixtures-and-hooks.md —— 用于测试隔离的夹具拆卸
- core/test-data-management.md —— 每个测试使用唯一数据、工厂函数
- core/configuration.md —— 重试、超时和跟踪配置
- core/debugging.md —— 跟踪查看器、UI 模式和 Inspector,用于诊断失败
- core/common-pitfalls.md —— 导致不稳定性的常见错误