1. 当“e2e”不再只是三个字母:从测试金字塔顶端说起
第一次在项目里看到e2e这个目录名的时候,我以为是谁偷懒把easy拼错了。后来才知道,这是End-to-End的缩写,中文一般叫“端到端测试”。如果你做过前端或者全栈开发,大概率在 CI 流水线里见过它——那个跑得最慢、最容易红、但一旦绿了就让人特别安心的环节。
e2e 测试的核心思路其实很朴素:把整个系统当成一个黑盒,从用户真实操作的入口进去,一路走到最终结果出来,中间不关心内部怎么实现。比如一个登录功能,单元测试关心的是密码加密函数对不对,集成测试关心的是数据库连接通不通,而 e2e 测试关心的是——我打开浏览器,输入账号密码,点登录,能不能看到首页。它模拟的是真实用户的行为路径,所以叫“端到端”。
为什么现在大家都在聊 e2e?因为现代应用的复杂度已经远超十年前。一个页面可能由十几个微前端拼起来,一次点击背后要经过网关、鉴权、业务服务、消息队列、缓存、第三方接口。单元测试全绿,集成测试也全绿,上线之后用户还是点不动按钮——这种事太常见了。e2e 就是最后那道防线,它不保证你每个零件都好,但它保证“整台机器能跑起来”。
这篇文章适合谁看?如果你是被 CI 里飘红的 e2e 用例折磨过的开发者,或者是刚接手一个“测试覆盖率很高但线上还是出问题”的项目,又或者你正准备给团队引入 e2e 但不知道从哪下手,那接下来的内容应该能帮你少走不少弯路。我会从工具选型、用例设计、稳定性治理、CI 集成这几个角度,把 e2e 这件事拆开讲透,中间穿插我自己踩过的坑和目前还在用的解决方案。
2. 工具选型:Playwright、Cypress、Selenium 到底怎么选
2.1 先搞清楚你的项目形态再谈选型
很多人一上来就问“哪个工具最好”,这个问题没有标准答案,因为选型取决于你的项目形态。我一般会先问三个问题:第一,你的应用是纯 Web 还是包含桌面端、移动端?第二,你的团队更熟悉 JavaScript 还是 Python/Java?第三,你对跨浏览器测试的需求有多强?
如果你的项目是纯 Web 应用,团队以 JS/TS 为主,那 Playwright 和 Cypress 基本就是首选。如果项目里有 Electron 桌面端或者需要测原生 App,那 Playwright 的覆盖面会更广一些。如果团队是 Java 技术栈,或者公司有历史遗留的 Selenium 资产,那继续用 Selenium 也不是不行,只是要做好心理准备——它的等待机制和调试体验确实要原始一些。
我自己的经历是:2019 年之前主要用 Selenium,后来切到 Cypress,再后来因为多标签页和跨域场景的限制转到了 Playwright。每次切换都不是因为“新工具一定好”,而是旧工具在特定场景下真的卡住了。
2.2 三个主流工具的实测对比
下面这张表是我在实际项目中总结的对比,不是官方文档的复述,而是“用起来到底什么感觉”:
| 维度 | Playwright | Cypress | Selenium |
|---|---|---|---|
| 安装配置 | 一条命令,自带浏览器 | 一条命令,自带浏览器 | 需要单独配 driver |
| 等待机制 | 自动等待,几乎不用写 sleep | 自动等待,但某些场景要手动 | 基本靠显式等待 |
| 多标签页 | 原生支持 | 需要绕路 | 支持但写法繁琐 |
| 跨域访问 | 原生支持 | 需要配置 | 支持 |
| 调试体验 | Trace Viewer 很强 | 时间旅行调试很直观 | 基本靠日志 |
| 执行速度 | 快,支持并行 | 中等 | 较慢 |
| 语言支持 | JS/TS、Python、Java、.NET | 主要 JS/TS | 全语言 |
| 社区生态 | 增长很快 | 成熟稳定 | 庞大但老化 |
Playwright 的 Trace Viewer 是我目前用过最舒服的 e2e 调试工具。用例失败之后,它会生成一个包含每一步截图、DOM 快照、网络请求、控制台日志的压缩包,打开之后可以像看录像一样回放整个执行过程。以前排查一个偶发失败可能要跑十遍,现在看一遍 trace 基本就能定位。
Cypress 的时间旅行调试也很棒,鼠标悬停在某一步就能看到当时的页面状态。但它在多标签页和跨域场景下确实有硬伤,如果你的应用有第三方登录跳转,用 Cypress 会比较难受。
Selenium 的优势在于“什么都能测”,但代价是“什么都要自己写”。等待、重试、截图、报告,这些在 Playwright 和 Cypress 里开箱即用的东西,Selenium 都需要额外搭。如果你是新项目,我不太建议从 Selenium 起步。
2.3 一个容易被忽略的选型因素:CI 环境
选型的时候一定要考虑 CI 环境。Playwright 官方提供了 Docker 镜像,里面浏览器和依赖都装好了,在流水线里跑很省心。Cypress 也有官方镜像,但体积偏大。Selenium 需要自己维护 Grid 或者用第三方服务。
还有一个细节:无头模式下的表现。有些工具在本地有头模式跑得好好的,一到 CI 的无头模式就各种诡异问题。Playwright 和 Cypress 在这方面做得比较好,Selenium 有时候需要额外配置窗口大小和 GPU 参数。
我的建议是:新项目直接上 Playwright,老项目如果 Cypress 用得好就别折腾,Selenium 项目如果维护成本还能接受就继续用,但如果要重写,优先考虑前两者。
3. 用例设计:别把 e2e 写成“慢速单元测试”
3.1 e2e 用例应该覆盖什么
我见过很多团队的 e2e 用例,打开一看,全是“点击按钮,断言按钮文字变了”这种。这不是 e2e,这是用浏览器跑的单元测试。e2e 的价值在于验证跨模块、跨服务的完整业务链路,而不是验证某个组件的渲染逻辑。
那什么样的用例才算合格的 e2e?我一般用“用户故事”来判断:这个用例描述的是不是一个真实用户会做的事情?比如“用户注册后能成功下单并收到确认邮件”就是一个好的 e2e 用例,因为它跨越了注册、登录、商品、订单、通知多个模块。而“点击购物车图标后角标数字加一”就更适合放在组件测试里。
具体来说,我会把 e2e 用例分成三类:
- 核心链路用例:注册、登录、下单、支付、退款这些主流程,必须覆盖,而且要在每次提交时都跑。
- 关键边界用例:比如库存不足时的下单、优惠券过期时的结算、权限不足时的访问,这些不需要每次都跑,但每天至少跑一次。
- 跨系统用例:涉及第三方支付、短信、邮件的场景,可以用 mock 或者沙箱环境,但链路要完整。
3.2 用例的粒度控制:少即是多
e2e 用例最大的敌人是“数量”。我见过一个项目有 800 条 e2e 用例,跑一次要两个小时,结果就是没人看结果,红了就重跑,重跑还红就跳过。这样的 e2e 等于没有。
我的经验是:核心链路用例控制在 20 到 50 条之间。这个数量足够覆盖主流程,又不会让执行时间失控。剩下的场景用集成测试和单元测试去补,不要什么都往 e2e 里塞。
那怎么判断一条用例该不该留在 e2e 里?我会问自己:如果这条用例失败了,是不是意味着用户真的用不了?如果是,留下;如果只是某个边缘情况,移到下层测试去。
3.3 选择器策略:别让用例因为改了个 class 就挂掉
e2e 用例脆弱的头号原因就是选择器不稳定。今天用.btn-primary,明天前端重构改成.button-main,用例就红了。解决这个问题的标准做法是给关键元素加专门的测试属性,比如><button>// Playwright 里的写法 await page.getByTestId('submit-order').click();
这样前端怎么改样式、改结构,只要>test.beforeEach(async ({ request }) => { // 通过 API 创建测试用户 await request.post('/api/test/users', { data: { username: 'testuser', password: 'testpass' } }); }); test.afterEach(async ({ request }) => { // 清理测试数据 await request.delete('/api/test/users/testuser'); });
用 API 准备数据比用 UI 准备数据快得多,也稳定得多。UI 准备数据意味着你要先走一遍注册流程,万一注册页面改了呢?用例还没开始测正事就挂了。
4. 稳定性治理:让 e2e 不再“看天吃饭”
4.1 偶发失败的三大根源
e2e 最让人头疼的就是“偶发失败”——同样的代码,跑十次挂一次,重跑又好了。这种问题不解决,团队很快就会对 e2e 失去信任。根据我的经验,偶发失败基本来自三个地方:
第一,等待时机不对。页面元素还没渲染出来就去点击,或者接口还没返回就去断言。虽然现代工具都有自动等待,但有些场景它们判断不了,比如动画还没结束、异步加载还没完成。
第二,测试数据冲突。两个用例同时跑,用了同一个用户名,一个创建成功一个创建失败。或者上一个用例的数据没清理干净,影响了下一个用例。
第三,环境不稳定。CI 机器性能波动、网络延迟、第三方服务响应慢,这些都会导致用例超时。
4.2 等待策略:从 sleep 到智能等待
新手写 e2e 最喜欢用sleep,比如await page.waitForTimeout(3000)。这种写法的问题在于:你永远不知道 3 秒够不够。网络快的时候 1 秒就好了,浪费 2 秒;网络慢的时候 3 秒不够,还是挂。
正确的做法是等待具体条件,而不是等待固定时间。Playwright 提供了很多等待方法:
// 等待元素可见 await page.getByTestId('order-list').waitFor({ state: 'visible' }); // 等待接口返回 await page.waitForResponse(resp => resp.url().includes('/api/orders') && resp.status() === 200); // 等待页面加载完成 await page.waitForLoadState('networkidle');如果某个场景实在找不到合适的等待条件,那说明这个场景本身可能就不适合做 e2e,或者需要前端加一个可观测的信号(比如某个>// playwright.config.ts export default defineConfig({ retries: process.env.CI ? 2 : 0, });
注意process.env.CI ? 2 : 0这个写法——本地开发不重试,因为本地失败通常是真的有问题;CI 环境重试两次,用来抵消环境波动。
4.4 失败排查:Trace 和视频是救命稻草
当用例在 CI 上失败,而你本地复现不了的时候,Trace 文件就是唯一的线索。Playwright 的 Trace Viewer 可以回放整个执行过程,包括每一步的截图、DOM 快照、网络请求、控制台输出。
// playwright.config.ts export default defineConfig({ use: { trace: 'on-first-retry', video: 'on-first-retry', screenshot: 'only-on-failure', }, });on-first-retry的意思是第一次重试时才记录 trace,这样既不会拖慢正常执行,又能在失败时拿到完整信息。我一般还会把 trace 文件上传到 CI 的 artifact 里,方便随时下载查看。
一个小技巧:Trace Viewer 支持在本地打开远程下载的 trace 文件,命令是
npx playwright show-trace trace.zip。打开之后按时间轴回放,基本能定位到是哪一步出了问题。
5. CI 集成:让 e2e 成为流水线的一部分
5.1 什么时候跑 e2e 最合适
e2e 跑得慢,所以不能每次提交都全量跑。我一般会分三档:
- 提交时:只跑核心链路的冒烟用例,控制在 5 分钟以内。
- 合并到主分支时:跑全量核心用例,控制在 15 分钟以内。
- 每晚定时:跑全量用例,包括边界场景和跨系统场景。
这样既保证了快速反馈,又保证了覆盖度。关键是不要让 e2e 成为开发的瓶颈,如果每次提交都要等 30 分钟才能合并,大家就会想办法绕过它。
5.2 并行执行:把 15 分钟压到 5 分钟
Playwright 支持多 worker 并行执行,在 CI 上可以充分利用多核 CPU:
// playwright.config.ts export default defineConfig({ workers: process.env.CI ? 4 : undefined, fullyParallel: true, });fullyParallel: true让每个测试文件并行跑,workers: 4表示用 4 个进程。如果 CI 机器有 8 核,可以设成 8。但要注意,并行度太高可能会导致资源竞争,反而变慢,需要根据实际情况调。
还有一个优化点是分片执行。如果用例特别多,可以把它们分到多台机器上跑:
npx playwright test --shard=1/3 npx playwright test --shard=2/3 npx playwright test --shard=3/3这样三台机器同时跑,总时间就降到三分之一。
5.3 报告与通知:让失败被看见
e2e 失败之后,最重要的是让该看到的人看到。我一般会配置两种通知:
- CI 页面上的报告:Playwright 自带 HTML 报告,可以上传到 artifact,点开就能看到哪些用例失败、失败原因、trace 文件。
- 即时通知:失败时发到团队群或者邮件,附上失败用例列表和报告链接。
// playwright.config.ts export default defineConfig({ reporter: [ ['html', { outputFolder: 'playwright-report' }], ['junit', { outputFile: 'results.xml' }], ], });JUnit 格式的报告可以被大多数 CI 系统解析,直接在流水线页面上显示用例通过率。
5.4 环境管理:别让 e2e 依赖“某台机器”
e2e 需要一个稳定的测试环境。我见过有的团队直接在开发环境跑 e2e,结果开发一改代码,e2e 就挂了。正确的做法是有独立的测试环境,数据可以随时重置,服务版本和即将上线的版本一致。
如果条件允许,可以用 Docker Compose 在 CI 里临时起一套环境:
# docker-compose.test.yml services: app: build: . ports: - "3000:3000" db: image: postgres:15 environment: POSTGRES_PASSWORD: test这样每次跑 e2e 都是干净的环境,不受其他因素干扰。
6. 我踩过的那些坑和现在的做法
6.1 坑一:用 UI 准备数据,慢且脆
早期我写 e2e,喜欢用 UI 走一遍完整流程来准备数据。比如测退款,先用 UI 下单,再用 UI 申请退款。结果就是用例又慢又容易挂——下单页面改个按钮位置,退款用例就挂了。
现在的做法是用 API 准备数据,用 UI 验证结果。下单用 API 调,退款用 UI 走。这样既保证了测试的是真实用户操作,又避免了前置步骤的脆弱性。
6.2 坑二:断言太细,改个文案就挂
以前我喜欢断言页面上的具体文字,比如expect(page.getByText('订单提交成功')).toBeVisible()。结果产品把文案改成“订单已提交”,用例就挂了。
现在的做法是断言关键状态,而不是具体文案。比如断言订单列表里多了一条记录,或者断言 URL 跳转到了订单详情页。文案可以变,但业务状态不会随便变。
6.3 坑三:忽略移动端视口
有一次我们的 e2e 在桌面端全绿,上线之后移动端用户反馈按钮点不到。排查发现是移动端布局下按钮被遮挡了,但 e2e 只测了桌面视口。
现在的做法是至少覆盖一个移动端视口。Playwright 可以很方便地配置多个项目:
// playwright.config.ts export default defineConfig({ projects: [ { name: 'Desktop Chrome', use: { ...devices['Desktop Chrome'] } }, { name: 'Mobile Safari', use: { ...devices['iPhone 13'] } }, ], });这样同一套用例可以在不同设备上跑,提前发现响应式问题。
6.4 坑四:没有清理机制,数据越积越多
e2e 跑多了,测试环境里的数据会越来越多,最后可能影响性能甚至导致用例失败。我现在的做法是每个用例跑完就清理自己创建的数据,另外每天凌晨重置一次测试数据库。
test.afterEach(async ({ request }) => { await request.post('/api/test/cleanup', { data: { testRunId: process.env.TEST_RUN_ID } }); });给每次测试运行分配一个TEST_RUN_ID,清理的时候只删这个 ID 相关的数据,不会误删其他数据。
6.5 现在的日常:e2e 是朋友不是负担
经过这几年的折腾,我现在对 e2e 的态度是:它不是一个“有了就行”的东西,而是一个需要持续维护的资产。用例要定期 review,过时的删掉,不稳定的修掉,新的核心链路及时补上。
我们团队现在的节奏是:每周五花 30 分钟过一遍 e2e 报告,看看哪些用例经常失败、哪些用例从来没失败过(可能已经没价值了)、哪些新功能还没覆盖。这个习惯坚持了半年之后,e2e 的稳定性从 70% 提升到了 95% 以上,大家也愿意相信它的结果了。
最后分享一个小心得:e2e 用例的命名要像用户故事一样,比如“已登录用户可以成功下单并查看订单详情”,而不是“test_order_001”。这样失败的时候,不用看代码就知道哪个业务场景出了问题,排查效率会高很多。