端到端(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 选型
| 维度 | Playwright | Cypress |
|---|---|---|
| 分片 | 内置 --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 真正成为可靠的合并门禁。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。