☰
端到端测试实战:Playwright、Cypress、Selenium选型与稳定性治理
2026/10/26 7:25:50 网站建设 项目流程

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 三个主流工具的实测对比

下面这张表是我在实际项目中总结的对比,不是官方文档的复述,而是“用起来到底什么感觉”:

维度PlaywrightCypressSelenium
安装配置一条命令,自带浏览器一条命令,自带浏览器需要单独配 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”。这样失败的时候,不用看代码就知道哪个业务场景出了问题,排查效率会高很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询