17 KiB
并行执行与分片
何时使用:通过单机并发(并行)或多机分布(分片)来加速测试套件。当你的测试套件超过 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
模式 4:Worker 作用域的 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}`)
})
使用 workerInfo 和 testInfo 生成唯一标识符:
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 分钟 | 自动(默认) | 无 | 无需优化 |
| 50–200 个测试,5–15 分钟 | CI 中使用 '50%' |
2–4 个分片 | 平衡速度与 CI 成本 |
| 200+ 个测试,> 15 分钟 | CI 中使用 '50%' |
4–8 个分片 | 将反馈时间控制在 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, // 从较低值开始,稳定后再增加
})
相关文档
- ci/ci-github-actions.md —— 分片的 GitHub Actions 工作流
- ci/ci-gitlab.md —— GitLab 的
parallel:关键字与分片 - ci/ci-other.md —— CircleCI、Azure DevOps、Jenkins 上的分片
- ci/reporting-and-artifacts.md —— blob reporter 与 merge-reports
- core/fixtures-and-hooks.md —— worker 作用域的 fixture
- core/test-organization.md —— 并行与串行执行