> > 译文开始。 # 不稳定测试 > **使用时机**:测试有时通过、有时失败,需要诊断根本原因、修复并防止再次发生。 > **前置知识**:[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 --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) —— 导致不稳定性的常见错误