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

30 KiB
Raw Permalink Blame History

译文开始。

不稳定测试

使用时机:测试有时通过、有时失败,需要诊断根本原因、修复并防止再次发生。 前置知识core/assertions-and-waiting.mdcore/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"

// ---- 选项 1test.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")
})

// ---- 选项 2test.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")

// 选项 1test.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")
})

// 选项 2test.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 特定的断言或增加超时

相关文档