如果你在一个稍微有点规模的前端或全栈项目里待过,大概率见过这样的场景:CI 流水线里有一组 e2e 测试,绿的时候大家不觉得是谁的功劳,红的时候所有人都会停下来问“怎么又挂了”。我接手维护过一套跑了三年的 e2e 套件,执行时间从 15 分钟涨到 40 分钟,失败率从偶尔一次变成“每次合代码都心惊胆战”。中间试过不少办法,最后才慢慢想明白:e2e 的问题,绝大多数不是工具问题,而是我们把它当成了一堆“脚本按钮”去写,而不是当成一套需要长期维护的工程系统去做。这篇内容不打算系统讲测试金字塔理论,而是围绕我自己在多个项目里把端到端测试跑稳的实操经验,分成六个方面展开:价值边界、工具选型、稳定性设计、真实排障、CI 集成,以及什么时候该果断砍掉不必要的用例。
1. e2e 到底在替谁把关:先把要测的“端”划清楚
1.1 端到端到底“端”到哪
所谓 end-to-end,就是从用户真实入口出发,一直走到这个操作在系统里产生完整结果为止。比如用户下单,路径是:浏览器页面 → 前端接口层 → 网关 → 订单服务 → 库存服务 → 支付回调 → 数据库落库 → 页面展示成功。只要中间任何一个环节掉链子,用例就会红。很多人把 e2e 理解成“把界面点了点、断言几个文字”,那是把 e2e 做浅了。
我见过不少团队嘴上说着“我们有 e2e”,实际上只测了登录页面是否显示、菜单能不能点开、表格里有没有数据。这些页面里的信息和按钮,确实离不开前端,但它们不是一条“端到端的业务路径”。真正的 e2e 关心的是:一个用户带着真实意图进来,系统有没有把这件事从头到尾办完。它关注的是业务结果,而不是界面元素本身。
1.2 单元测试、集成测试、e2e 的分工边界
- 单元测试:关在一个函数内部算正确性,比如金额计算、时间格式化、状态机转换。
- 集成测试:验证模块与模块的协议、接口是否对得上,比如订单服务调用库存服务的接口契约。
- e2e:验证一条完整业务价值链路,用户到底能不能办成事。
用体检来类比:单测是抽血查某一项指标,集成测试是心脏彩超,e2e 是让你跑完八百米再测全套。血液指标正常不能说明你能跑完八百米,但八百米跑完基本能暴露整体协调性的问题。这也就解释了为什么很多团队觉得 e2e“最接近真实”——它确实更接近真实,但它也是最昂贵的,所以不该被用来做那些“抽血就能查出来”的事情。
1.3 两类很容易写废的 e2e 用例
第一类是拿 e2e 做输入校验。比如登录框填错密码,断言“密码错误”提示,这种用例用单测和组件测试完全覆盖了,放在 e2e 里只会徒增执行时间和脆弱点。第二类是连 UI 文案、按钮位置、颜色都精确断言。前端本来就是组件化迭代最快的部分,每改一次 UI 就要改一次 e2e,最后测试变成了 UI 的“人质”。
真正适合 e2e 的,是那些用户付出真金白银或时间之后无法撤回的关键路径:注册登录、加购支付、订单状态流转、权限控制、跨系统数据同步。判断标准很简单:这条路径如果断了,用户是不是真的办不成事?如果是,就值得用 e2e 守住;如果不是,大概率说明你把测试写在错误的层级了。
1.4 别什么项目一上来就铺 e2e
还没想清楚业务流程、界面还在疯狂改版、自动化基础为零的团队,先不要把 e2e 当成“银弹”全面铺开。我见过最舒服的推进方式是从 3 到 5 条核心冒烟路径起步,跑稳之后再以每周 2~3 条的速度往回归集里加。等团队有了“红灯必须当晚查清楚”的习惯,再慢慢扩容也不迟。
2. 工具选型与工程骨架:我为什么绕着 Selenium 走进了 Playwright
2.1 录制回放的问题出在哪
早期的“录制回放”看起来很美,点几下就生成脚本,但录出来的选择器大多是.btn:nth-child(2)或者.login-form > div > span这种东西。前端只要微调一个按钮的位置,脚本立刻废掉。更麻烦的是,录制产物通常没有结构,数据准备、断言增强、失败现场处理都很难往里面塞。
说白了,录制回放只解决“怎么写脚本”,不解决“怎么维护”。而 e2e 的成本大头永远在维护。所以我现在更愿意把脚本当工程代码来写:有清晰的目录、有固定的模式、有明确的断言策略,而不是让一堆录制出来的“黑盒动作”堆在仓库里。
2.2 三款主流 e2e 工具的横向对比
| 对比维度 | Selenium | Cypress | Playwright |
|---|---|---|---|
| 语言支持 | Java/Python/Ruby/JS 等 | JavaScript/TypeScript 为主 | JS/TS、Python、Java、.NET |
| 浏览器驱动 | 需要 WebDriver + driver 管理 | 内置 Chromium,Firefox 支持受限 | 内置 Chromium/Firefox/WebKit |
| 自动等待 | 弱,主要靠手工显式等待 | 强,命令自动重试断言 | 强,交互前自动检查可操作性 |
| 多标签页/跨域 | 支持但脚本繁琐 | 受限较多 | 原生支持 |
| 调试痕迹 | 截图/日志,需外部工具拼接 | 时间旅行回放 | Trace 查看器 + 录像 + 截图 |
| 并发能力 | 需要 Grid 或云服务 | 多机器并行 | 内置 shard 分片与并行 worker |
Selenium 在很长一段时间里是唯一跨语言、跨浏览器的方案,我早期在 Java 项目里也写过不少,但基建成本确实高:要维护各个浏览器的 driver 版本,要写大量显式等待,分布式执行还要自己搭 Grid。Cypress 把开发者体验拉上来一大截,内置重试和调试体验都很香,但它跑在自己的执行进程里,多标签页、跨域这些场景会受到限制。Playwright 算是后来居上,它把自动等待、Trace 回放、多浏览器驱动、并行执行打包进框架,等于省掉了之前团队要自己手搓的很多轮子。
选型建议:如果团队没有历史包袱,我会首选 Playwright。但如果你们已经在 Selenium 或 Cypress 上积累了庞大的用例库和基建,不要为了“新”而重写。工具迁移的成本要算进去,e2e 工具的价值是帮业务兜底,不是用来做技术表演。
2.3 从零搭一个 Playwright 工程
初始化一套 Playwright 工程并不复杂:
mkdir e2e-demo && cd e2e-demo npm init -y npm install -D @playwright/test npx playwright install --with-deps一个可以落地的playwright.config.ts大致长这样:
import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 30_000, fullyParallel: true, retries: process.env.CI ? 1 : 0, reporter: [['html', { open: false }]], use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'profile1-smoke', testMatch: /smoke\/.*\.spec\.ts/ }, { name: 'profile2-regression', testMatch: /regression\/.*\.spec\.ts/ }, ], });这个配置里我习惯把测试集拆成多个 profile:profile1-smoke是提交代码时快速跑的核心冒烟,profile2-regression是每晚跑的完整回归。多 profile 的好处是同一个代码库、同一条 CI 里,可以按反馈速度和覆盖范围拆分开执行,彼此不会污染。和团队沟通时也很方便:“跑 profile1”就是只跑冒烟,“跑 profile2”就是完整回归。
2.4 登录态与多 Profile:别让每条用例都重新登录一遍
如果每条用例都从登录页开始,一半执行时间都会花在重复登录上,而且登录流程本身如果有验证码或风控,还会额外引入不稳定因素。Playwright 可以先把登录态保存成storageState,后续用例直接复用:
// 登录一次并保存会话 await page.goto('/login'); await page.getByLabel('用户名').fill('e2e_user'); await page.getByLabel('密码').fill('testpass'); await page.getByRole('button', { name: '登录' }).click(); await page.context().storageState({ path: 'state/user.json' });然后在其他用例里直接使用这个已登录 context。只有登录功能本身的用例才需要从裸状态开始。这样既缩短了执行时间,也避免了“登录步骤每次都因为网络慢而失败”的经典 flaky 问题。
3. 稳定性的底气:我从五个角度重构用例写法
3.1 选择器:从“CSS 堆叠”到“用户视角”
错误的写法:
await page.click('.submit-btn > span:nth-child(2)');正确的写法:
await page.getByRole('button', { name: '提交订单' }).click(); // 或者给稳定元素加>await expect(page.getByText('支付成功')).toBeVisible({ timeout: 10000 });类比一下:等电梯最靠谱的方式是按着按钮等门开,而不是数三秒之后再按。自动等待和显式等待都是这个逻辑,它们等待的是“条件”,而waitForTimeout等待的是“日历时间”。
3.3 测试数据的幂等与独立
最让人头大的 flakiness 是“上次跑的数据影响了这次跑”。比如订单号重复、用户被锁定、优惠券被用过一次就失效。我的建议是:
- 每个用例自己创建数据,不依赖前置用例留下的数据。
- 关键标识字段加随机前缀,比如
order_${Date.now()}_${pid}。 - 清理用
afterAll或直接调后台接口删除,不要在测试步骤里再走一遍 UI 删除。
如果遇到失败数据残留,宁可让它留着,也不要为了清理而写一堆复杂的查询逻辑。清理逻辑越复杂,本身就越容易成为新的不稳定源。真实项目中最好的状态是:测试数据一眼就能看出来是测试数据,跑完可以安全丢弃。
3.4 用例独立性:随机执行顺序也不怕
检查用例是否独立,最简单的方法是把执行顺序打乱。Playwright 可以按文件名随机顺序跑,或者手动把 spec 文件顺序打乱。如果跑出来仍然全绿,说明用例没有偷偷依赖前面的状态;如果变红,说明有人在共享登录态、共享购物车、共享定时任务。
如果确实要共享大对象,那就应该用一次性 fixture,比如通过 fixture 创建一个测试会员卡,而不是复用“上一个用例留下的订单”。我见过太多团队因为“不独立”而被迫串行执行,执行时间线性拉长,最后又不得不砍用例。独立性是稳定性的地基,这关不过,后面所有优化都是空中楼阁。
3.5 失败现场保护:截图、录像、Trace 一步到位
配置里我习惯默认trace: 'retain-on-failure',screenshot: 'only-on-failure',偶尔再开视频。真出问题时,截图可以告诉你“当时屏幕长什么样”,Trace 则更进一步:每一步操作、网络请求、控制台报错、元素快照都有。我做排障时一半时间都花在 Trace 上,因为它比截图多了一个时间维度,可以往前翻几步,看到底是哪一步开始偏离预期。
这里也提醒一句:重试机制要克制。retries: 2可以缓解偶发环境问题,但不能盖住逻辑错误。如果一个用例要依赖重试才能绿,那它还没达到进 CI 的门槛,需要被单独标记并处理。
4. 一次真实排障:CI 上 30% 概率失败的“幽灵用例”
4.1 表象:本地全绿,CI 总是间歇红
当时的场景是一个支付成功页的 e2e:登录 → 选套餐 → 模拟支付回调 → 页面出现“支付成功”文案。本地跑,点了二十次,最多红一两次;上了 CI,大概每三次就有一次红。而且每次红的步骤都一样:等不到“支付成功”文本。团队第一反应是“等待不足”,于是加了两个waitForTimeout继续跑,结果还是红。
4.2 收窄失败范围:不是环境吵,是时序问题
我把 Trace 拉出来,看到请求链路:前端触发支付回调后,接口返回了 200,但页面断言失败发生在文案出现之前。这说明不是网络彻底断了,而是页面状态没有在超时时间内走到预期状态。然后我把该用例单独连续跑 20 次,依旧偶发。到这里基本可以判定:这不是“CI 环境独有的坑”,而是一个稳定的时序问题,只是 CI 机器更慢,所以更容易触发。
4.3 抓证据:前端断言对象和真实状态更新错位
我在测试里给关键节点加时间戳,再对齐后端接口日志。最后发现:极端情况下后端处理支付回调要 8 到 9 秒,而前端在回调事件触发后,会先渲染一个旧状态的“待支付”区域,稍后才刷新成“已支付”。我们断言的“支付成功”文案,恰好挂在旧状态区域上,后来没有及时替换成可见状态。问题本质不是测试等得不够,而是测试等错了对象——它在等一个和页面状态更新没有严格先后关系的静态文案。
4.4 修复:把断言从“等文案”升级为“等业务状态”
后来我把断言改成两层确认,先等业务状态,再等 UI 文案:
await expect .poll(async () => { const res = await request.get('/api/order/12345'); return (await res.json()).status; }) .toBe('PAID', { timeout: 15000 }); await expect(page.getByText('支付成功')).toBeVisible();第一层用接口轮询确认订单状态已经变成PAID,第二层再等 UI 文案展示。这背后的思路是:业务状态是唯一可信的信号,UI 文案只是它的投影。顺带把后端慢回调时页面缺少过渡状态的问题反馈给了产品,前端后来也补上了 loading 态,那才是真正影响用户的根因。
4.5 可以复用的通用排障思路
- 别急着改等待时间,先重复运行把失败概率稳定下来。
- 拉 Trace 看失败发生在哪个动作之后,不要只盯着断言文本本身。
- 在测试里埋时间戳,再对齐后端日志,确认是否存在时序乱序。
- 把断言从“UI 文案”提升为“业务状态 + UI 文案”两层。
- 如果发现是产品体验问题,别只改测试,还要反馈给业务和前端团队。
5. 给 e2e 装上“日常化”的心脏:CI 分片、环境隔离与报告
5.1 CI 上为什么更容易红
本地运行通常独享完整资源,CI 跑在低配容器里,网络抖动、并发争抢、多分支同时共用环境、共享环境被其他测试污染,这些都更容易暴露问题。我不建议把 e2e 直接打到生产或共享 staging 上随便跑,尤其是涉及支付和权限的项目。最稳妥的做法,是每个 CI 任务开一个临时部署的整套服务;退一步,至少在 staging 上做好测试账号隔离和脏数据回收。
环境隔离听起来是基建事,但它直接决定了 e2e 红的时候团队愿不愿意去查。如果每次失败都要先花半小时判断“是不是环境被谁占了”,那团队很快就会选择无视红灯。
5.2 分片与并行:从 40 分钟压到 8 分钟
Playwright 自带 shard 能力,在 CI 里可以这样拼:
strategy: matrix: shardIndex: [1, 2, 3, 4] shardTotal: [4] steps: - run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}我见过最明显的执行时长变化,是把 40 分钟的套件压到 8 分钟。但能这样压的前提,是前文说的数据独立和用例独立都做到了。否则并行跑起来,两个用例抢同一个订单号,会比原来更红。分片不是银弹,它只是把“已经稳了”的套件加速;套件本身不稳定的话,分片只会让失败来得更快。
5.3 retries 与 flaky 检测
retries: 2可以用来缓解偶发环境波动,但它不能掩盖逻辑错误。更值得关注的是“第一次失败、重试成功”的用例,这是典型的 flaky 信号。我的做法是:CI 报告里把这类用例单独标记颜色,每周集中处理。如果一套 e2e 里有超过 5% 的用例需要重试才会绿,那说明它们还没达到进 CI 的门槛,应该回到本地把稳定性修好再放进来。
5.4 报告、通知与可读性
测试执行完,如果没有直观报告,团队不会愿意打开看。我把 Playwright 的 HTML Report 和 Trace 文件都接到内部消息通知里:红灯时推一条包含失败步骤、截图、Trace 链接的卡片。目标只有一个:让团队看到红灯时,能在 1 分钟内判断“这是产品 bug,还是测试问题”。这个能力一旦建立,e2e 就从“负担”变成了“护栏”。
6. 什么时候该砍掉 e2e:有些团队把它当蜡烛在烧
6.1 e2e 的收益曲线不是线性的
e2e 用例数量和维护成本不是 1+1=2 的关系。越到后面,每次 UI 组件改动、环境配置调整、第三方接口升级,都会让一撮用例跟着动。如果团队一边加用例一边不敢删,最后就会变成代码库里的定时炸弹。
在一套中等规模 Web 项目里,我个人觉得比较舒服的量级是 50 到 80 条覆盖核心价值链路的用例。超过 150 条时,哪怕每天全绿,我也会开始怀疑团队是不是在拿 e2e 替代本应该下沉到集成测试的东西。数量不是荣耀,价值才是。
6.2 “红灯没人管”和“绿油油没意义”都是危险信号
红灯没人管,不代表测试没有价值,更可能是测试价值已经归零。绿油油一片也不一定就好,如果改了一个核心字段后 e2e 依然全绿,说明断言根本没在保护业务。
每一条用例都应该回答一个问题:它失败了,是不是一定意味着某个真实用户会受影响?答不上来的用例,删掉不可惜。
6.3 降级替代方案:不是只有全有或全无
对第三方支付、短信、地图这类外部依赖,不要每次都真的去调用真实服务,可以用契约测试加本地录制的响应来兜底层,只留一两条“真调用”的 e2e 做定期探活。把 e2e 里查数据库、调接口这些底层能力下沉到集成测试,e2e 里只保留真实的用户路径和行为断言。这样当 e2e 红灯时,你不用从一百种环境原因里猜,大概率是真的业务出了问题。
6.4 每周十分钟的“测试维护晨会”
我和团队坚持的一个习惯,是每周固定花十分钟,把一周的失败记录翻一遍:哪些 flaky、哪些该修、哪些该删、哪些需要通知上游。这十分钟对团队来说是一个债务审计窗口。两三个月之后,e2e 的健康度会明显往一个方向走:要么越来越稳,要么你越来越清楚哪些用例根本不值得存在。
最后再分享一个我常问自己的问题:如果这条用例明天开始不再跑了,你会不会松一口气?如果答案是“会”,那它大概率是在消耗团队,而不是在保护业务。e2e 的价值从来不取决于你写了多少条、覆盖率多少,而取决于每一个红灯是否都值得被认真对待。我见过很多团队花大力气把 e2e 做成了又厚又重的壳,真正稳的项目往往用例不多,但每条都有不可替代的视角。希望你在看完这篇之后,不是马上回去重构用例,而是先打开最近的失败报告,问一句:它到底在替谁把关。