为什么 E2E 测试会拖慢整个 CI
一个非常普遍的现象是:E2E 测试在 CI 中的作用和它的执行成本并不成正比。它最接近用户真实行为,能验证关键链路是否真的可用,但同时也是整个流水线里最慢、最容易让开发失去耐心的部分。很多前端团队一开始只有十几条 E2E 用例,跑起来几分钟还能接受;随着覆盖的核心流程变多,用例从几十条涨到几百条,整个套件从 5 分钟涨到 40 分钟,并不夸张。真正让人头疼的是,一次改动可能只涉及一个按钮文案,但 CI 依然要把完整的 E2E 回归跑完。

E2E 慢的根源,很少是某一条用例写得特别烂,而是整个执行过程长期处于串行状态。浏览器进程启动、上下文创建、页面加载、等待接口返回、断言重试,每一步都有固定开销。用例一多,这些开销就线性叠加。另外一个隐性成本是失败后的重试:一条不稳定的用例,重试三次可能就会让整体时间再增加一分半钟。
如果要给 E2E 计时做减法,分片是绕不开的选项。所谓前端 E2E 测试分片,本质是把一组测试按照某种规则拆成若干子集,分发给不同的 CI runner 并行执行。它把“总时长 = 所有用例耗时之和”改成了“总时长 ≈ 最慢的那个分片 + 固定开销”。这里面最关键的问题,是如何拆才能让每个分片的工作量尽可能接近。
这里要先明确一点:分片不是“把 CI 任务复制成 N 份,各自跑一遍完整测试”。那样只会增加资源消耗,不会缩短时间。真正需要做的是把测试集合拆开,每个 runner 只跑其中一部分。很多团队第一次尝试时,直接在 CI 里开了多台机器跑同一套用例,最后发现时间没缩短,账单倒是翻了几倍。
分片方式怎么选:静态、耗时加权,还是动态调度
目前比较常见的分片分配方式有三种,它们在实现成本和分配均衡性上差异很大。
静态按文件分片是最直接的做法。比如把 400 条用例按文件列表平均分成 4 份,每个 runner 跑一份。好处是不需要任何额外的数据支撑,配置也很简单。问题是文件之间的执行时间差异可能非常大,一个包含大量慢场景的文件会让某个分片成为长尾,其他 runner 跑完后空闲等待,整体加速效果被拉低。
基于历史耗时加权分片会在静态分片的基础上引入上一次跑完的用例耗时数据。比如先把每个测试文件的耗时汇总,再按总耗时分配到 N 个 runner,尽量让每个分片的预计总耗时接近。实现成本比纯静态分片高一些,需要把测试计时数据保存下来,但反馈到 CI 上的效果通常非常明显。
动态 worker 抢占则是另一套思路:不再预先分配,而是准备一个共享任务队列,每个 runner 空闲时主动去拿下一个测试文件或测试用例。这种方式均衡性最好,但需要额外维护队列服务,比如 Redis 或共享存储,适合测试数量多、用例耗时波动大的团队。
三种方式的差异可以用下面的表格快速对比。
| 分片方式 | 分配粒度 | 均衡性 | 实现成本 | 适合场景 |
|---|---|---|---|---|
| 静态按文件 | 测试文件 | 一般 | 低 | 用例较少,文件耗时接近 |
| 基于历史耗时加权 | 测试文件或用例 | 较好 | 中 | 套件规模较大,耗时相对稳定 |
| 动态 worker 抢占 | 用例级 | 最好 | 高 | 用例多且耗时波动明显 |
大多数团队会从第一种开始,跑一段时间后发现长尾,再升级到第二种。直接上动态调度不是不可以,只是要评估维护队列的成本是否值得。对于 2 到 3 个 runner 就能覆盖的规模,静态分片加上少量人工调整,通常已经够用。
一个可以直接落地的 Playwright 分片示例
如果你用的是 Playwright,它自带的 shard 参数已经覆盖了静态分片的场景。配合 GitHub Actions 的 matrix 机制,可以很快实现一套并行 E2E 任务。下面是一个最小化的配置示例。
name: e2e
on:
push:
branches: [main]
jobs:
e2e:
strategy:
matrix:
shard: [1, 2, 3, 4]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright test --shard=${{ matrix.shard }}/4
这个配置会同时启动 4 个 runner,每个 runner 只跑四分之一用例。和串行执行比起来,提速效果是直观的。但要注意,分片数量不是越多越好。每个 runner 都要完整地 checkout 代码、安装依赖、启动浏览器,这些固定开销会在每个分片上重复。分片数量从 2 升到 4 时收益最明显,继续往 8 个以上扩展,就需要观察总时间是否真的还在下降。
落地时建议先观察最慢的分片。如果某一个分片耗时明显高于其他分片,就需要把几个稳定的“大头”用例手动挪开。这里不存在一次配置永久生效的情况,分片策略本身就是需要定期根据运行数据调整的。
真正让分片效果缩水的几个坑
分片不是把代码挪到 CI 配置文件里就结束了。下面这些坑,是很多团队从串行切换到并行之后才慢慢暴露出来的。
- 测试之间存在共享状态。串行执行时,前一个用例留下的登录态、缓存或数据库记录,后面的用例可能会直接复用;一旦拆到不同 runner,这些隐式依赖就会让用例失败,而且失败方式还很随机。
- 静态分片会让长尾问题转移。如果其中一个文件里的用例普遍很慢,无论怎么平均分配文件数量,都可能让这个分片变成最慢的那条路径。
- 失败重试会放大噪声。并行环境下,多个分片同时出现偶发失败时,CI 界面会显示好几条失败记录,不稳定的测试更容易被关注,也更容易误导排查方向。
- 资源开销并不是线性下降的。分片需要复制代码、安装依赖、启动环境,这些重复开销会随着 runner 数量增加而变得更加明显。超过某个阈值后,再增加分片数量,总时间反而可能上升。
还有一个误区是把分片当作用例治理的替代品。如果 E2E 套件里本身就有大量重复场景、等待时间设置过长、或者过度依赖网络接口的真实返回,那么分片只是把这些低效问题平摊到更多机器上。真正合理的做法是先把明显冗余的用例删掉,再把 wait 逻辑改成显式等待,最后才考虑并行。
分片解决的是“用例总量大”和“执行串行”的问题,解决不了“单条用例本身就慢”的问题。
实际项目中,最常见的场景是:团队从 2 个 runner 开始,发现时间降了一半,于是直接改成 6 个 runner,结果提升比例反而没到预期。原因是启动浏览器、下载依赖、构建前端资源这些固定成本在每个 runner 上都会重复一次。分片数量越多,固定开销占比越高,收益自然递减。
如何把 CI 时间缩短 80%
先回到目标。假设原本一串 E2E 回归需要 40 分钟,目标是缩短 80%,也就是跑进 8 分钟。只靠把测试绝对并行是不够的,还需要叠加其他优化手段。这里有一个相对明确的落地路径。
- 先测量再动手。接入分片前,先把总耗时、每个文件的耗时、失败率、重试次数这些数据收集起来,找到耗时占比最高的那部分用例。
- 拆掉不能并行的外部依赖。比如测试库、mock 服务、需要共享的临时环境,至少在 CI 上应该做到每个 runner 独立准备一份。
- 用 4 个分片起步。将总用例按文件数量均分,观察是否出现长尾;如果出现,再通过历史耗时调整分配。
- 合并短用例文件。很多只有一两个用例的文件会让静态分片效果变差,可以把它们按模块合并,减少分配到的最小粒度。
- 把“慢用例”单独处理。对于上传下载、视频处理、远程接口请求这类超过 60 秒的用例,要么拆成单独的稳定性作业,要么标记成更宽松的重试策略,避免影响整个分片的节奏。
这套路径的核心是逐步逼近最慢的那个分片。当最慢分片的时间从 40 分钟降到 20 分钟,再降到 10 分钟、8 分钟,CI 的整体时间也就跟着降下来了。这里的 80% 不是靠某一个配置项魔法般达成的,而是分片数量、用例耗时治理、资源成本和稳定性之间共同作用的结果。
如果你的 CI 已经被 E2E 测试时间困扰了很久,可以先从最简单的静态分片开始。拿到第一轮运行数据后,再判断是否需要引入历史耗时加权,或者进一步走向动态调度。分片的方向并不难,难的是在分片之后,还能保持测试自身的确定性。
最后说几句
前端 E2E 测试分片策略看起来是一个 CI 优化话题,背后其实是对测试套件可管理性的重新组织。它要求团队理解用例的执行特征,愿意收集运行数据,并且接受并行带来的不稳定因素排查成本。但它带来的回报也很直接:当你把一次全量回归从 40 分钟压缩进 8 分钟左右,开发和测试对 CI 的信任感都会明显提升。
如果正在为 E2E 测试太慢而焦虑,不必一开始就搭建复杂的调度系统。先把分片跑起来,把数据拿在手里,再根据最慢分片去调整。一步一步来,80% 的缩短是有机会实现的。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/746/