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

17 KiB
Raw Permalink Blame History

并行执行与分片

何时使用:通过单机并发(并行)或多机分布(分片)来加速测试套件。当你的测试套件超过 5 分钟时,这项技术至关重要。

快速参考

# Workers(单机并行)
npx playwright test --workers=4          # 固定 worker 数量
npx playwright test --workers=50%        # 按 CPU 核心数的百分比

# 分片(跨机器拆分)
npx playwright test --shard=1/4          # 运行第一部分
npx playwright test --shard=2/4          # 运行第二部分

# 合并分片结果
npx playwright merge-reports ./blob-report          # 合并为默认 HTML 报告
npx playwright merge-reports --reporter=html,json ./blob-report  # 多种格式

# 全并行模式
npx playwright test --fully-parallel     # 覆盖当前运行的配置

模式

模式 1:配置 Workers

何时使用:控制在一台机器上同时运行多少测试。 何时避免:你只有 1–2 个测试(并行没有效果)。

// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  // fullyParallel: true 表示文件内部的测试也并行运行。
  // 如果不设置,只有文件之间并行运行(文件内部的测试按顺序执行)。
  fullyParallel: true,

  // Workers:并行进程的数量
  // - undefined:自动检测(CPU 核心数的一半,上限为合理数值)
  // - number:固定数量
  // - string 百分比:CPU 核心数的 '50%'
  workers: process.env.CI ? "50%" : undefined,
})

fullyParallel 的实际控制范围:

设置 文件之间并行运行 文件内部测试并行运行
fullyParallel: false(默认) 否——每个文件内串行
fullyParallel: true 是——每个测试相互独立

当某个文件需要串行执行时的逐文件覆盖:

// tests/onboarding.spec.ts
import { test, expect } from "@playwright/test"

// 即使配置中设置了 fullyParallel: true,该文件的测试仍会串行执行
test.describe.configure({ mode: "serial" })

test("步骤 1:输入公司名称", async ({ page }) => {
  // ...
})

test("步骤 2:选择套餐", async ({ page }) => {
  // ...
})

模式 2:跨 CI 机器分片

何时使用:即使在最大 workers 下,单机运行测试套件仍然太慢。将工作拆分到 N 个独立的 CI 任务中。 何时避免:测试套件在一台机器上运行时间不足 5 分钟。

分片将测试文件列表分成 N 个相等的组。每个分片运行一个组。

# 机器 1             机器 2             机器 3             机器 4
--shard=1/4          --shard=2/4          --shard=3/4          --shard=4/4

分片运行的配置——使用 blob reporter

// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  fullyParallel: true,
  workers: process.env.CI ? "50%" : undefined,

  // Blob reporter 输出一个二进制文件,后续可以合并。
  // 非 CI 环境下,使用 HTML 格式方便本地查看。
  reporter: process.env.CI ? [["blob"], ["github"]] : [["html", { open: "on-failure" }]],
})

模式 3:合并来自分片的 Blob 报告

何时使用:你对测试进行了分片,需要一份统一的报告。 何时避免:没有分片——常规的 HTML reporter 可以直接使用。

每个分片在 blob-report/ 目录下生成一个 .zip 文件。所有分片完成后,合并它们:

# 将所有 blob-report/ 目录下载到一个文件夹,然后:
npx playwright merge-reports --reporter=html ./all-blob-reports

# 多种输出格式
npx playwright merge-reports --reporter=html,json,junit ./all-blob-reports

# 自定义输出目录
PLAYWRIGHT_HTML_REPORT=merged-report npx playwright merge-reports --reporter=html ./all-blob-reports

GitHub Actions 示例(合并任务):

merge-reports:
  if: ${{ !cancelled() }}
  needs: test
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: 20
        cache: "npm"
    - run: npm ci

    - uses: actions/download-artifact@v4
      with:
        path: all-blob-reports
        pattern: blob-report-*
        merge-multiple: true

    - run: npx playwright merge-reports --reporter=html ./all-blob-reports

    - uses: actions/upload-artifact@v4
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 14

模式 4Worker 作用域的 Fixture 用于共享资源

何时使用:多个并行 worker 各自需要一个昂贵的资源(数据库连接、认证令牌),该资源应每个 worker 创建一次,而非每个测试创建一次。 何时避免:该资源创建成本很低——使用常规的测试作用域 fixture。

// fixtures.ts
import { test as base } from "@playwright/test"

type WorkerFixtures = {
  dbConnection: DatabaseClient
  workerAuthToken: string
}

export const test = base.extend<{}, WorkerFixtures>({
  // 每个 worker 进程创建一次,在该 worker 的所有测试之间共享
  dbConnection: [
    async ({}, use) => {
      const db = await DatabaseClient.connect(process.env.DB_URL!)
      await use(db)
      await db.disconnect()
    },
    { scope: "worker" },
  ],

  workerAuthToken: [
    async ({}, use, workerInfo) => {
      // 每个 worker 获得一个唯一的用户,避免测试干扰
      const response = await fetch(`${process.env.API_URL}/auth/token`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          username: `worker-user-${workerInfo.workerIndex}`,
          password: process.env.TEST_PASSWORD,
        }),
      })
      const { token } = await response.json()
      await use(token)
    },
    { scope: "worker" },
  ],
})

export { expect } from "@playwright/test"

模式 5:测试隔离以实现安全并行

何时使用:准备测试以并行运行而不相互干扰。 何时避免:永远不必避免——可靠的并行执行始终需要隔离。

黄金法则:每个测试必须创建自己的状态并在完成后清理自己。没有测试应依赖或修改其他测试使用的状态。

// 错误做法:测试共享硬编码的用户——并行运行时会冲突
test("更新个人资料", async ({ page }) => {
  await page.goto("/users/shared-user/profile")
  await page.getByLabel("姓名").fill("新姓名")
  await page.getByRole("button", { name: "保存" }).click()
  // 另一个并行测试也在编辑 "shared-user" —— 竞态条件!
})

// 正确做法:每个测试创建自己的用户
test("更新个人资料", async ({ page, request }) => {
  // 为此测试创建一个唯一的用户
  const res = await request.post("/api/test/users", {
    data: { name: `user-${Date.now()}`, email: `${Date.now()}@test.com` },
  })
  const user = await res.json()

  await page.goto(`/users/${user.id}/profile`)
  await page.getByLabel("姓名").fill("更新后的姓名")
  await page.getByRole("button", { name: "保存" }).click()
  await expect(page.getByLabel("姓名")).toHaveValue("更新后的姓名")

  // 清理
  await request.delete(`/api/test/users/${user.id}`)
})

使用 workerInfotestInfo 生成唯一标识符:

import { test, expect } from "@playwright/test"

test("创建订单", async ({ page }, testInfo) => {
  const uniqueId = `order-${testInfo.workerIndex}-${Date.now()}`
  // 为该测试创建的任何数据使用 uniqueId
  await page.goto(`/orders/new?ref=${uniqueId}`)
  // ...
})

模式 6:基于测试数量的动态分片数

何时使用:你希望根据实际存在的测试数量自动调整分片数,而非硬编码。 何时避免:你的测试数量稳定,且固定分片数已经工作良好。

# .github/workflows/playwright.yml —— 动态分片计算
jobs:
  determine-shards:
    runs-on: ubuntu-latest
    outputs:
      shard-count: ${{ steps.calc.outputs.count }}
      shard-matrix: ${{ steps.calc.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - run: npm ci
      - id: calc
        run: |
          TEST_COUNT=$(npx playwright test --list --reporter=json 2>/dev/null | node -e "
            const data = require('fs').readFileSync('/dev/stdin', 'utf8');
            const parsed = JSON.parse(data);
            console.log(parsed.suites?.reduce((acc, s) => acc + (s.specs?.length || 0), 0) || 0);
          ")
          # 每 20 个测试 1 个分片,最少 1 个,最多 8 个
          SHARDS=$(( (TEST_COUNT + 19) / 20 ))
          SHARDS=$(( SHARDS > 8 ? 8 : SHARDS ))
          SHARDS=$(( SHARDS < 1 ? 1 : SHARDS ))
          # 构建矩阵数组:["1/N", "2/N", ...]
          MATRIX="["
          for i in $(seq 1 $SHARDS); do
            [ $i -gt 1 ] && MATRIX+=","
            MATRIX+="\"$i/$SHARDS\""
          done
          MATRIX+="]"
          echo "count=$SHARDS" >> $GITHUB_OUTPUT
          echo "matrix=$MATRIX" >> $GITHUB_OUTPUT

  test:
    needs: determine-shards
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: ${{ fromJson(needs.determine-shards.outputs.shard-matrix) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --shard=${{ matrix.shard }}

决策指南

场景 Workers 分片数 原因
< 50 个测试,< 5 分钟 自动(默认 无需优化
50200 个测试,515 分钟 CI 中使用 '50%' 24 个分片 平衡速度与 CI 成本
200+ 个测试,> 15 分钟 CI 中使用 '50%' 48 个分片 将反馈时间控制在 10 分钟以内
因资源争用导致的 flaky 测试 减少:workers: 2 保持当前 更少的 worker = 更低的 CPU/内存压力
测试修改共享数据库 workers: 1 或按 worker 隔离 仍然有用 分片拆分文件;worker 执行它们
CI 环境 CPU/RAM 有限 workers: 1'25%' 更多分片 用更多机器来弥补更少的 worker
问题 workers(进程内) --shard(跨机器)
拆分什么? 将测试按 CPU 核心拆分到一台机器上 将测试文件拆分到独立的 CI 任务中
由什么控制? playwright.config.ts--workers CLI --shard=X/Y CLI 标志
共享内存? 是(同一台机器) 否(独立的机器)
是否需要合并报告? 否(单个进程) 是(merge-reports
成本 免费(同一台机器) 更多 CI 分钟(更多机器)

反模式

反模式 问题 正确做法
无故使用 fullyParallel: false 文件内部测试串行执行,套件变慢 除非特定测试需要串行,否则设置 fullyParallel: true
CI 中为了"保险"使用 workers: 1 完全抵消了并行性 修复隔离问题;使用 workers: '50%'
测试共享硬编码的用户账户 并行时出现竞态条件——两个测试修改同一份数据 每个测试通过 API 或 fixture 创建唯一数据
--shard=1/4 未配合 blob reporter 每个分片生成自己的 HTML 报告,没有合并视图 为分片 CI 运行配置 reporter: [['blob']]
只有 3 个测试也进行分片 分片设置的额外开销超过了节省的时间 仅在测试套件超过 5 分钟时进行分片
到处使用 test.describe.serial() 扼杀并行性,制造隐藏依赖 仅当测试确实依赖于前一个测试的状态时使用
Worker 数量高于 CPU 核心数 上下文切换开销增加,更慢而非更快 使用 '50%' 或让 Playwright 自动检测
CI 矩阵中未使用 fail-fast: false 一个分片失败会取消其他分片,导致结果不完整 对分片策略始终设置 fail-fast: false

故障排查

测试单独通过但一起运行时失败

原因:测试之间共享状态——数据库行、cookie、全局变量、文件系统。

修复:隔离每个测试。每个测试使用唯一数据:

test("创建订单", async ({ page, request }, testInfo) => {
  // 每个测试使用唯一产品——不会与并行测试冲突
  const product = await request.post("/api/test/products", {
    data: { name: `Widget-${testInfo.workerIndex}-${Date.now()}` },
  })
  // ...
})

分片没有产生任何测试:"No tests found"

原因:分片数量超过了测试文件的数量。某个分片分到了零个文件。

修复:将分片数减少到不超过测试文件的数量:

# 如果你有 10 个测试文件,最多 10 个分片
npx playwright test --shard=1/10  # 正常
npx playwright test --shard=1/20  # 某些分片会为空

合并报告缺少部分测试结果

原因:某个分片的 blob 报告文件未被下载,或因名称冲突被覆盖。

修复:为每个分片的 artifact 赋予唯一名称:

# 每个分片
- uses: actions/upload-artifact@v4
  with:
    name: blob-report-${{ strategy.job-index }} # 每个分片唯一
    path: blob-report/

# 合并步骤
- uses: actions/download-artifact@v4
  with:
    pattern: blob-report-*
    merge-multiple: true
    path: all-blob-reports

Worker 作用域的 fixture 未共享——每个测试都重新创建

原因:缺少 { scope: 'worker' } 选项,或者 fixture 依赖于测试作用域的 fixture。

修复:确保 fixture 使用 worker 作用域,且仅依赖于 worker 作用域的 fixture

export const test = base.extend<{}, { sharedResource: Resource }>({
  sharedResource: [
    async ({}, use) => {
      const resource = await Resource.create()
      await use(resource)
      await resource.destroy()
    },
    { scope: "worker" },
  ], // 不要忘记这个
})

使用更多 worker 后测试反而更慢

原因:机器达到 CPU 或内存瓶颈。更多 worker 导致系统颠簸。

修复:减少 worker 数量,直到找到最佳值:

// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  workers: process.env.CI ? 2 : undefined, // 从较低值开始,稳定后再增加
})

相关文档