说实话,我对 "e2e" 这三个字母的感情非常复杂。刚带团队那会儿,我一度在周会上拍过桌子说"这些端到端测试全是负担,删了算了"。为什么?因为每次发布前跑一次 e2e,红的比绿的多,修一个用例的时间够我写两个功能,CI 排队排到天荒地老。但后来真正经历过一次线上事故——所有单元测试和接口测试都是绿的,用户却在下单流程里卡死——我才意识到,e2e 不是负担,是我自己把它用坏了。这篇文章我想从一次失败的 e2e 实践讲起,说说我怎么在半年内把一套从几乎被废弃的端到端测试,重建成团队发布流程里最可靠的一道闸门。如果你也在为 e2e 测试的稳定性、成本和 CI 集成头疼,或者正准备给项目引入端到端测试,这篇文章里的经验应该能帮你少走不少弯路。
1. 从"差点删掉整套 e2e"到"把它当成发布闸门"
1.1 当初为什么我会说出"删了算了"
我们当时的测试体系用 Selenium 加上一套自己封装的断言库,用例数量接近 200 条,但执行一次要 40 分钟,而且成功率不到 60%。每周光修回归失败就要搭进去半天时间。那段时间团队对 e2e 的态度非常统一:"这东西就是样子工程,跑绿了没人看,跑红了全组遭殃。"
后来我认真复盘过,发现当时的失败跟业务复杂度没什么关系,纯粹是基建太弱,几个问题叠在一起:
- 等待策略全是 fixed sleep:代码里到处都是
sleep(3000)、sleep(5000),网络快的时候浪费三秒,网络慢的时候照样挂。这是 flaky 的头号来源。 - 没有数据隔离:所有用例共用一套测试账号和同一批测试数据,一个用例创建了订单 A,另一个用例查询订单列表时把它也查了出来,断言一对比就挂。
- 失败没有上下文:用例挂了只给一个红色断言错误,没有截图、没有视频、没有 DOM 快照,排错全靠猜,猜不出来就重新跑一遍碰运气。
这样的 e2e 自然没有人愿意维护。说实话,那会儿我自己也觉得它是团队的负担,而不是资产。
1.2 那个深夜的线上事故改变了我的认知
真正让我态度转变的是一次发版事故。功能上线后,用户反馈"注册成功后无法跳转到欢迎页",但诡异的是,单元测试覆盖了注册逻辑,接口测试也验证了后端返回,全部都是绿的。最后排查发现,是前端页面上一个按钮在特定屏幕尺寸下被底部浮层遮挡,用户点不到,这个交互问题只有真实浏览器里才能暴露。
我翻了一下测试报告,发现对应路径的 e2e 用例早就因为不稳定被同事注释掉了——它之前确实抓到过这类问题,只是失败次数太多,成了大家眼里的"麻烦来源",最终被"优化"掉了。
那次事故让我彻底想通了一个道理:e2e 不稳定不是"要不要做"的问题,而是"怎么做才稳定"的问题。你绕着它走,它不会消失,只会让线上问题在你完全不知情的时候出现。
2. e2e 到底在测什么:定位它的不可替代性
2.1 从测试金字塔看 e2e 的位置
聊 e2e 之前,得先说清楚它在整个测试体系里的位置。经典的测试金字塔分三层:底层是单元测试,中间是服务/接口测试,顶层是 UI/端到端测试。很多团队把 e2e 当成"覆盖一切"的工具,什么测试都往顶层塞,这是对 e2e 最大的误用。
我的理解是这样:单元测试检查的是"单个零件能不能转",接口测试检查的是"两个齿轮能不能咬合",e2e 检查的是"整辆车在真实路面上开起来是什么感觉"。有些问题只在系统协作层暴露,比如前端按钮被遮挡、后端返回字段和前端类型对不上、第三方服务超时后的兜底逻辑失效——这些用单元测试和接口测试都模拟不出来,因为它们各自验证的都是局部正确性。
我用一个更生活化的类比。想象你要开一家餐厅:单元测试是检查每颗菜洗没洗干净、每块肉切没切对尺寸;接口测试是检查洗好的菜能否顺利送到切配台、切好的肉能否按时下锅;而 e2e 是完整地让一位"神秘顾客"从进门、点餐、等餐、用餐到结账走完全程。洗菜洗得再干净,如果服务员把菜送错了桌,顾客体验依然为零。e2e 的不可替代性恰恰在这里——它只关心最终的用户结果,不在乎中间哪个环节出了问题。
2.2 适合 e2e 覆盖的场景和不适合硬塞的场景
做了几年之后,我总结出适合 e2e 覆盖的核心对象:
- 核心转化链路:注册 -> 登录 -> 选品 -> 加购 -> 下单 -> 支付 -> 订单确认。这条链路上任何一个环节出问题,都是直接经济损失。
- 跨系统协作流程:涉及前端、网关、多个微服务、消息回调、回调通知的链路,接口测试只能分别验证各系统,但组合起来的时序问题只有 e2e 能抓到。
- 关键 UI 状态:按钮的可点/禁用状态、错误提示文案、空态、权限控制、表单校验。这类问题不涉及复杂逻辑,但对用户体验影响很大。
- 高频回归的主路径:比如已经稳定运行的核心功能,每次发版都可能被旁边的新功能波及,e2e 作为兜底非常划算。
反过来,有些场景就别往 e2e 里塞:
- 复杂算法和纯逻辑计算:比如优惠券分摊、运费计算,用单元测试跑几百个边界用例又快又准,用 e2e 反而又慢又脆。
- 一次性的活动页面:活动生命周期短,页面过期后用例就成废代码,维护成本完全收不回来。
- 极端大数据量渲染:这属于性能测试的范畴,用 e2e 硬测很容易超时,得到的结果也不具备性能参考价值。
这个边界想清楚之后,e2e 用例的规模就控制住了,维护成本也降下来一大截。
3. 搭建能长期活下去的 e2e 基建:从选型到 e2e profile1 配置文件
3.1 工具选型:我为什么从 Selenium 换到 Playwright
搞 e2e 的第一步是选工具,这一步选错,后面全是坑。我们最早用的 Selenium,它最大的优势是生态老、支持语言多,但缺点也很明显:写起来琐碎、等待机制容易出问题、跨浏览器跑起来各种诡异报错。后来我们试过 Cypress,开发者体验确实好,但它的架构决定了测试必须跑在浏览器同进程里,遇到多标签页、iframe、跨域场景就非常痛苦——而我们这种前后端分离、经常对接第三方登录的系统,这些场景几乎是日常。
最终我们换到了 Playwright,主要看中几点:
- 自动等待做得好:默认就会等待元素可交互,不用满屏写 sleep。
- 多标签、多域、iframe 支持强:通过 CDP 协议驱动真实浏览器,几乎不受同源策略限制。
- 调试能力出众:trace 录制、视频回放、UI 模式,排错效率高得不是一点半点。
- 支持多语言绑定:虽然我们用 TypeScript,但它也支持 Java 和 Python,团队切换不挑语言。
我给个简化的对比表,供还在选型的同学参考:
| 工具 | 等待机制 | 多标签/跨域 | 调试能力 | 适合场景 |
|---|---|---|---|---|
| Selenium | 显式/隐式等待,容易 flaky | 支持好 | 一般,依赖日志 | 老项目、必须用特定语言绑定的团队 |
| Cypress | 自动重试断言 | 受限,多标签/跨域麻烦 | 优秀,时间旅行 | 纯前端团队、SPA 为主的简单场景 |
| Playwright | 自动等待 + 网络空闲判断 | 支持好 | 强(trace、视频、UI 模式) | 前后端完整链路、跨域跨系统、需要长期维护 |
我的建议是,新项目直接选 Playwright,除非你有一堆不可替代的 Selenium 历史资产。
3.2 e2e profile1:第一套核心流程配置该怎么设计
这里顺便说说我项目里的一个实践。我们维护了好几套 e2e 测试配置,其中最关键的一套叫 "e2e profile1",指的是"第一套核心用户主流程"的测试档位。
什么是 profile?你可以把它理解成一套测试的边界配置:跑哪个环境、用哪类账号、加载哪些用例、连接哪些依赖服务、超时和重试策略是什么。我强烈建议把这种配置独立成文件,而不是散落在用例里。一份典型的e2e.profile1.config.ts长这样:
// e2e.profile1.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests/profile1', timeout: 30_000, expect: { timeout: 10_000 }, fullyParallel: true, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 4 : undefined, use: { baseURL: process.env.E2E_BASE_URL ?? 'https://staging.example.com', trace: 'on-first-retry', video: 'retain-on-failure', screenshot: 'only-on-failure', locale: 'zh-CN', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } }, ], });几个配置项我展开说一下,因为有经验的人才会知道这些细节为什么重要:
testDir指向tests/profile1,意味着这个 profile 只加载第一套核心流程的用例,不会跟其他 profile 混在一起。互不干扰是配置隔离的第一原则。retries在 CI 里设置 2 次,本地不重试。重试是为了过滤偶发的环境抖动,但不能无限重试,否则真实回归会被掩盖。后面我会专门说这个问题。trace: 'on-first-retry'的意思是第一次失败时不记录 trace,重试时才记录。这样既能保留失败现场,又不会让每次成功运行的 trace 占用存储空间。projects里同时定义了桌面 Chrome 和移动端 Pixel 5,因为我们发现移动端 Web 的交互问题在纯桌面测试里根本发现不了。
3.3 目录结构与用例组织
配置隔离还体现在目录组织上。我们不是按"页面"组织测试,而是按"用户旅程"组织,这样更贴近真实使用,也方便和业务团队沟通。
tests/ profile1/ # 核心交易主流程 checkout.spec.ts payment.spec.ts profile2/ # 账户与用户中心 login.spec.ts register.spec.ts support/ fixtures.ts # 测试数据与账号管理 commands.ts # 常用操作封装 selectors.ts # 选择器统一维护这里有个特别容易踩的坑:选择器不要散写在各个用例里。前端改一次 class 名,你就要满项目翻用例,遇上那种深层 CSS 选择器,简直是一场灾难。我们的做法是约定元素一律使用>npx playwright test --reporter=html,line # 失败时自动上传 artifacts 到对象存储,并在群里推送带链接的卡片
这一步做完,团队对 e2e 的信任感会明显上来,因为它不再是一团谜,而是一个能直接给出证据的测试系统。
6. 维护 e2e 的长期主义:成本控制与团队习惯
6.1 把维护成本算清楚,别被固定成本吓跑
很多团队一上来就想把 e2e 铺满所有业务,结果被固定成本压垮:跑得慢、修得烦、发版被阻塞,最后就是砍掉收场。我的建议是反过来——先把核心链路守住,再慢慢扩展。
我们每年做一次全量评审,删掉已经不符合真实用户路径的用例。比如曾经有个用例专门测老版本浏览器的兼容,浏览器淘汰后直接删了,一分钟都不留。每个月还会拉一次数据,统计哪几条用例最近三十天失败次数最多,排在前面的就是维护重点:该修的修,该删的删,该重构的重构。e2e 是资产还是负债,取决于你有没有定期打理它。
6.2 让"写 e2e"融入开发流程,而不是事后补
e2e 最容易在项目里沦为"发版前一天临时补一补"的杂活,效果自然很差。我们现在定了一条规矩:涉及核心链路的功能改动,PR 里必须带 e2e 用例,否则不予合并。这条规矩能落地,是因为写 e2e 的成本真的降下来了——Playwright 的 codegen 可以录制操作直接生成脚本,开发者先手动走一遍业务流,导出代码,再补几条关键断言就行,全程十分钟以内。
另一个习惯是每周固定留一个时段处理不稳定用例,别把红色报表拖到发版前才集中处理。小修小补永远比集中修轻松,而且能让团队对 e2e 始终保持在"可信"的状态。
6.3 选择器治理和前端协作
最后聊聊和前端同学的协作。前端经常改样式导致选择器失效,这是 e2e 维护的主要成本来源。我们从前端侧约定:所有需要 e2e 覆盖的元素,一律加>