E2E 浏览器测试与视觉回归:Playwright、Cypress 与分片

系统讲解在 GitHub Actions 中运行端到端浏览器测试与视觉回归的工程实践,涵盖 Playwright 与 Cypress 的 CI 配置、基于 shard 的并行分片、blob 报告合并、快照基线管理、trace/video 产物留存以及 flaky 用例的稳定性治理。

端到端(E2E,End-to-End)测试是 CI 里最贵、最慢、也最容易失稳的一环。一个典型的中型前端项目,跑一遍完整 E2E 可能需要 20 分钟以上,其中还夹杂着因网络抖动、动画时序、环境差异导致的随机失败(flaky)。如果 CI 里的 E2E 经常"红一下绿一下",团队很快就会学会无视它——这比没有 E2E 更糟。

本文要回答的是三个具体问题:如何把 Playwright / Cypress 稳定地接进 GitHub Actions;如何用分片(sharding)把 20 分钟压到 5 分钟;以及如何用视觉回归和产物留存,让失败可复现、可定位。

一、E2E 在 CI 中的成本模型

1.1 成本来自哪里

E2E 的成本可以拆成三块:

成本项来源优化方向
时间串行执行、浏览器启动分片、并行、复用浏览器
稳定性时序、网络、环境差异重试、等待策略、固定环境
资源CPU/内存、Docker 镜像缓存浏览器、精简依赖

三者互相牵制:为了快而盲目并行会加剧不稳定,为了稳而加大 timeout 又会拖长总时长。

1.2 分层:不是所有用例都该进 E2E

健康的测试金字塔里,E2E 只覆盖关键用户旅程(登录、下单、支付这类)。大量边界情况应当下沉到单元测试和组件测试。组件层的测试策略可参考 React 测试指南 ,E2E 只保留"非它不可"的部分。测试金字塔各层的取舍在 /github-actions-test-coverage-integration/ 中有更完整的讨论。

二、Playwright 在 Actions 中的落地

Playwright 官方提供了 @playwright/test 运行器,内置了分片、重试、trace、报告合并等 CI 友好能力,是当前主流选择。

2.1 基础工作流

# .github/workflows/e2e.yml
name: E2E

on:
  pull_request:
  push:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run Playwright tests
        run: npx playwright test --reporter=blob

      - name: Upload blob report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: blob-report
          path: blob-report/
          retention-days: 7

--reporter=blob 是关键:它把结果写成可合并的中间格式,多个分片各自上传后统一合并成 HTML 报告。

2.2 浏览器缓存

npx playwright install 每次都会下载浏览器,非常耗时。用缓存跳过重复下载:

- name: Cache Playwright browsers
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

缓存键绑定 lockfile 的哈希,Playwright 版本升级时自动失效,避免版本错配。

2.3 报告合并

分片跑完后,用一个汇总 job 合并 blob 报告并发布 HTML:

  merge-report:
    if: always()
    needs: [e2e]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - 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

merge-multiple: true 把多个分片的 blob 报告合并到同一目录,merge-reports 再产出单一 HTML。

2.4 本地与 CI 的环境差异

CI 上常见的坑:CI 用 linux 而本地是 darwin,快照不一致;CI 无 GPU,动画渲染有差异。对策:

// playwright.config.ts
export default defineConfig({
  snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}-{projectName}{ext}",
  use: {
    trace: "on-first-retry",
    video: "retain-on-failure",
    screenshot: "only-on-failure",
  },
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
});

trace: "on-first-retry" 只在首次重试时录制 trace,既保留调试信息又不拖慢正常用例。视觉回归的基线差异可参见 Playwright E2E 测试 中关于快照路径的约定。

2.5 失败用例的本地复现

CI 失败后,最高效的排查方式是把产物拉回本地复现,而不是反复重跑 CI:

# 1. 下载 CI 上传的 trace
gh run download <run-id> -n playwright-artifacts-1

# 2. 用 trace viewer 打开
npx playwright show-trace test-results/**/trace.zip

# 3. 只跑那一个失败用例
npx playwright test tests/checkout.spec.ts --grep "支付失败回退" --headed

trace viewer 提供 DOM 快照、网络请求、控制台日志三条时间线,能定位到"第几毫秒、哪个请求、哪个元素"出错,远比看日志猜原因高效。

三、Cypress 集成

Cypress 生态成熟,cypress-io/github-action 把安装、启动、录制打包成一个 step,接入成本低。

3.1 基础工作流

name: Cypress

on: [pull_request]

jobs:
  cypress:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm start
          wait-on: "http://localhost:3000"
          wait-on-timeout: 120
          browser: chrome
          record: true
        env:
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

wait-on 会等待服务就绪再跑用例,避免"服务还没起来就开始访问"的经典竞态。

3.2 Cypress Cloud 并行

Cypress 的并行依赖 Cypress Cloud 做编排,通过 --parallel 与 --group 分组:

jobs:
  cypress:
    strategy:
      fail-fast: false
      matrix:
        containers: [1, 2, 3, 4]
    steps:
      - uses: cypress-io/github-action@v6
        with:
          record: true
          parallel: true
          group: "E2E-Chrome"
        env:
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

Cypress Cloud 会根据历史耗时把用例均衡分配到 4 个容器,比按文件均分更均匀。

3.3 Playwright 与 Cypress 选型

维度PlaywrightCypress
分片内置 --shard,无需云服务依赖 Cypress Cloud
多浏览器原生支持 Chromium/Firefox/WebKit支持但 WebKit 较弱
架构进程外驱动,支持多 tab进程内,单 tab 为主
报告合并内置 merge-reports依赖云或第三方
生态增长快成熟、组件测试完善

如果不想引入云服务依赖,Playwright 的内置分片是更自洽的选择。

四、测试分片与并行

4.1 按数量分片

Playwright 的 --shard 接受 current/total 形式,配合 matrix 即可并行:

jobs:
  e2e:
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3, 4]
        shardTotal: [4]
    steps:
      - run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
      - uses: actions/upload-artifact@v4
        with:
          name: blob-report-${{ matrix.shardIndex }}
          path: blob-report/

4.2 分片不均衡问题

按文件数量均分常常不均:有的文件 50 个用例,有的只有 2 个。结果是某个分片拖尾。对策:

  • 把大文件拆成多个小文件;
  • 用 --shard 结合"按历史耗时分配"的自定义脚本;
  • Playwright 会按测试用例(而非文件)分配,通常比 Cypress 的文件级分配更均衡。

4.3 只跑受影响的用例

Monorepo 中可以用变更检测只跑相关包的 E2E,思路与增量构建一致。例如用 dorny/paths-filter 判断变更范围:

- uses: dorny/paths-filter@v3
  id: filter
  with:
    filters: |
      frontend:
        - 'apps/web/**'
      checkout:
        - 'packages/checkout/**'

再据此决定跑哪些 spec,可显著缩短无关变更的反馈时间。Monorepo 下缓存与任务编排的整体策略,需要结合包依赖图与缓存命中率一起考虑。

4.4 matrix 的注意事项

fail-fast: false 必须显式设置,否则一个分片失败会取消其他分片,导致报告不完整。矩阵的默认 fail-fast: true 语义在 E2E 场景下几乎总是错的。

五、视觉回归测试

5.1 两种技术路线

视觉回归有两条路:

路线代表基线存储成本
本地快照Playwright toHaveScreenshot仓库内 PNG低,但需人工更新
云端服务Percy / Chromatic云端高,但审查体验好

5.2 Playwright 原生快照

test("homepage visual", async ({ page }) => {
  await page.goto("/");
  await page.waitForLoadState("networkidle");
  await expect(page).toHaveScreenshot("homepage.png", {
    maxDiffPixelRatio: 0.01,
    animations: "disabled",
  });
});

两个关键参数:maxDiffPixelRatio 容忍微小抗锯齿差异,animations: "disabled" 冻结动画避免时序抖动导致误报。

5.3 快照的跨平台陷阱

快照必须与生成它的平台绑定。在 macOS 上生成的基线,在 Linux CI 上可能因字体渲染差异而失败。解决方案:

  • 基线统一在 CI(Linux)容器中生成,本地用 Docker 复现;
  • 或用 snapshotPathTemplate 按平台分目录。

更新基线的流程应当是:CI 失败 → 下载 diff 产物 → 确认无异常 → 用 --update-snapshots 在 Linux 环境重新生成 → 提交。

5.4 云端视觉服务的取舍

Percy/Chromatic 的价值在于审查界面:它把 diff 以滑块形式展示,支持逐条 approve,并与 PR 评论集成。代价是按截图量计费,且把渲染结果上传到第三方。私有项目需评估数据合规。

六、产物与报告留存

6.1 该留存什么

失败时的可复现性取决于产物的完整度:

产物用途保留期建议
HTML 报告总览与用例详情14 天
trace完整执行回放7 天
video失败片段7 天
screenshot失败瞬间7 天

6.2 失败即上传

用 if: always() 保证失败时也上传:

- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: playwright-artifacts-${{ matrix.shardIndex }}
    path: |
      playwright-report/
      test-results/
    retention-days: 7

6.3 JUnit 报告与注解

把结果转成 JUnit XML 可以让失败用例直接以注解形式出现在 PR 上:

- run: npx playwright test --reporter=junit
  env:
    PLAYWRIGHT_JUNIT_OUTPUT_NAME: junit.xml

- uses: mikepenz/action-junit-report@v4
  if: always()
  with:
    report_paths: junit.xml

6.4 与覆盖率报告的协同

E2E 通常不做行覆盖率,但可以统计"关键旅程覆盖了哪些页面"。把这类结果并入统一的质量面板,能让团队看到测试的行为覆盖而非仅行覆盖。

七、稳定性治理

7.1 重试不等于修复

retries: 2 能掩盖偶发失败,但也会掩盖真实缺陷。正确姿势是:重试只作为缓冲,同时把重试用例记录下来单独治理。

// 只对特定用例加重试,而非全局
test("flaky checkout", async ({ page }) => {
  test.slow();
  // ...
});

7.2 等待策略

所有"等待"都应当基于条件而非固定时长。反模式与正模式:

// 反模式:固定 sleep
await page.waitForTimeout(3000);

// 正模式:等待可观测条件
await page.waitForResponse((r) => r.url().includes("/api/cart") && r.ok());
await expect(page.getByRole("button", { name: "结算" })).toBeEnabled();

getByRole 这类基于可访问性(accessibility)语义的定位器,既更稳定,也间接提升了页面可访问性,可参考 无障碍测试 。

7.3 隔离与并行安全

并行执行时,用例之间不能共享可变状态(同一个测试账号、同一份数据)。对策是为每个 worker 分配独立数据:

import { test as base } from "@playwright/test";

export const test = base.extend({
  testUser: async ({}, use, workerInfo) => {
    const user = await createUser(`e2e-${workerInfo.workerIndex}-${Date.now()}`);
    await use(user);
    await deleteUser(user.id);
  },
});

7.4 检测 flaky 用例

用 --repeat-each 主动复现不稳定性:

# 每个用例重复 5 次,暴露时序问题
npx playwright test --repeat-each=5 --workers=1

把这条命令放进夜间定时任务,长期跟踪哪些用例反复失败。

7.5 定时全量回归

PR 上跑的是"受影响用例",夜间定时任务则跑全量,并开启更高的重复次数,主动暴露那些只在特定时序下出现的问题:

name: E2E Nightly

on:
  schedule:
    - cron: "0 18 * * *"   # 每日 18:00 UTC

jobs:
  nightly:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npx playwright install --with-deps chromium
      - run: npx playwright test --repeat-each=3 --reporter=html
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: nightly-report
          path: playwright-report/

夜间任务不必阻断任何东西,它的产出是"稳定性趋势数据":哪些用例在重复运行中失败率上升,就该被优先修复或重写。

八、性能与成本优化清单

  • 浏览器缓存命中(~/.cache/ms-playwright);
  • 只在相关路径变更时触发(paths-filter);
  • 分片数与 runner 核数匹配(通常 2~4 worker/核);
  • trace 只在失败或重试时录制;
  • 报告与产物设定了 retention-days;
  • 关键旅程之外的用例已下沉到组件测试。

总结

E2E 在 CI 中的成败,取决于三件事:分片让它在时间上可接受,产物让它失败时可复现,稳定性治理让它值得被信任。Playwright 凭借内置分片、blob 报告合并与 trace 回放,在当前是 CI 友好度最高的选择;Cypress 生态成熟但并行依赖云服务。视觉回归要么接受本地快照的平台约束,要么为云端审查界面付费。无论选哪条路,把重试当作缓冲而非解药,把等待建立在条件而非时长上,才能让 E2E 真正成为可靠的合并门禁。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「github-actions」更多文章

  1. 多云部署编排与基础设施漂移检测
  2. 文档站与静态站点发布流水线
  3. AI 代码审查与 PR 助手集成