
项目标题叫e2e看起来只是两个字母但我最近两三个月的精力基本都耗在这上面了。e2e 全称 End-to-End端到端测试说直白一点就是照着真实用户的操作路径把整个系统从入口一路点到出口验证每个环节是否真的能串起来。我最早也觉得 e2e 不就是自动化点点点直到在一个牵扯几十个微服务、十几个外部依赖的业务系统里落地后才发现这个领域的水比想象中深得多。这篇文章想分享的不只是怎么搭一个 e2e 测试工程更是在落地e2e profile1这种配置化测试方案后总结出的一整套实操经验。不管你是刚接触自动化测试的测试工程师还是正在为团队搭建回归防线的前端或全栈下面这些思路、配置和避坑点应该都能直接用上。1. 端到端测试方案设计先想清楚再动手1.1 为什么 e2e 不是自动化点点点我见过不少团队对 e2e 的态度分成两种极端一种觉得没必要反正单元测试都写了接口测试也在跑为什么还要多一层另一种是天天在 e2e 里跑全量用例结果整套用例跑完要 30 分钟还动不动因为某个按钮文案变了就红一片然后得出结论e2e 太慢太不稳定不适合我们。这两种反应本质上都是没想清楚 e2e 的定位。e2e 的价值不在于替代单元测试也不在于提升接口覆盖率而在于验证跨模块、跨服务、跨团队的真实业务链路。举个例子我负责的下单流程里一个提交订单按钮的背后至少经历了前端校验、网关鉴权、订单服务、库存服务、支付回调、消息推送六个环节。如果只写单元测试每个模块内部看起来都是绿的但用户从点击到看到下单成功的整个链路未必是通的甚至中间一个字段在接口对接时串了位单元测试根本没机会发现。所以我后来给团队定了一条原则e2e 用例宁缺毋滥只覆盖核心主路径和高风险场景绝不搞每个接口都写一遍 e2e那种自嗨式覆盖。e2e 测的是一个系统作为整体面对真实用户操作时到底能不能把活干完。它是自动化测试金字塔里最顶层、离用户最近的一道防线也是回归成本里最容易被低估的一块。1.2 框架选型Selenium、Cypress 与 Playwright 的取舍工欲善其事必先利其器。我在动手搭工程之前把主流的三个框架都拉出来跑了两周测试用例分别是 Selenium、Cypress 和 Playwright。它们没有绝对的好坏但不同场景下的体验差距非常大。先聊 Selenium。它的生态最老什么浏览器都能驱动兼容性文档也最全但写起来是真的啰嗦。启动浏览器要配置 WebDriver定位元素要自己处理等待逻辑代码动不动就几百行。如果团队里已经有海量基于 Selenium 的资产那继续用没问题但新项目再选它我觉得是给自己上强度。我做过一个很简单的对比同样写一条搜索商品并加入购物车的用例Selenium 大概要 60 行Playwright 30 行内搞定后期的维护成本差距更大。然后是 Cypress。它的语法是三者里最贴合前端直觉的写断言和调试面板都很舒服时间旅行式的 debugging 体验确实是卖点。但它有两个我接受不了的限制一是它只能在自带测试运行器里跑对多浏览器、多设备策略的支持相对弱一些二是它对 iframe 和多标签页场景的处理非常别扭。我们的下单流程里正好有个第三方收银台 iframe用 Cypress 折腾了很久都没法顺畅地把焦点切进去最后只能放弃。最终我选的是 Playwright。它最打动我的一点是浏览器自动化应该像真实用户操作一样可靠默认对元素做可见性、可操作性、稳定性校验不满足条件就等到超时从机制上就不鼓励你无脑 sleep。再加上多浏览器、多设备、多上下文原生支持跑并行也特别方便。从工程协作角度看Playwright 对 CI 的友好度也高出不少HTML report、trace viewer、sharding 都是开箱即用的。这里放一个我当时做选型时用的对比表方便你快速有个判断框架能力SeleniumCypressPlaywright浏览器驱动方式WebDriver内置浏览器自带驱动/无头模式自动等待需要手动设计部分自动全自动默认iframe/多标签页支持较弱原生支持调试体验一般时间旅行调试trace viewer 强力CI 并行策略需要额外集成有限支持原生 worker/sharding上手门槛较高低中等1.3 测试金字塔与 e2e 的投入产出比动手写用例之前团队内部还有一轮争论到底要写多少个 e2e 用例有人觉得越多越好连个人中心改个昵称都想跑到 e2e 里去。我拿测试金字塔把大家拉回来了底层是大量快速稳定的单元测试中间层是接口测试和组件测试顶层才是小而精的 e2e。e2e 不需要追求数量但每一条都必须覆盖真实用户的关键旅程。关键旅程一般叫 Critical User Journey缩写 CUJ。怎么定义就是如果这个路径断了用户的核心诉求就没法完成。比如电商场景搜索商品、加入购物车、下单支付、查看订单状态这些就是 CUJ。个人中心改昵称、消息中心已读未读这些不是 CUJ交给接口测试和组件测试就好。我还定了两个量化指标来控制规模冒烟 e2e 控制在 5 到 10 条用例跑完不超过 2 分钟每次发布前执行核心回归 e2e 控制在 30 到 50 条用例跑完不超过 15 分钟随 CI 流水线夜间执行。超过这个规模e2e 的稳定性维护成本就会迅速吞噬收益。正是基于这个思路我才在工程里引入了 profile 的概念用不同的配置组合控制跑哪一批用例、用什么浏览器、超时多久、重试几次。这也是后面要重点讲的 e2e profile1 的大背景。2. 核心细节解析从用例设计到 profile1 配置2.1 用例设计三层次冒烟、回归、全链路场景把分层思路落到具体用例上我会先把系统里所有梳理过的用户路径列出来然后分成三个层次去设计每一层的关注点完全不同。第一层是冒烟用例对应核心业务闭环。比如电商系统就是搜索→加购物车→结算→支付→收到订单成功页。这一层用例必须在 3 分钟内全部跑完而且要在每次部署后立刻执行。它存在的意义是快速回答系统是不是还活着这个问题所以我不在这层写太多细碎断言只验证关键业务结果。冒烟用例只要挂一条就应该阻止发布因为它说明主干路已经断了。第二层是回归用例覆盖次核心流程和边界组合。比如不同支付方式下单库存不足时购物车提示已登录用户的登录态在多设备间如何变化。这一层用例数量会明显多起来跑得也慢适合放在夜间流水线里。执行完毕后我还会让团队把失败用例分成两类一类是产品需求变了导致断言过期另一类是系统确实出现了回归 bug。前者说明用例需要更新后者才是这条 e2e 最该发挥的价值。第三层是全链路场景用例模拟更长的用户操作序列。比如一个用户从注册、登录、浏览、收藏、下单、售后到再次购买一连串行为写在一个用例里。这种用例对测试数据的要求极高失败率也最高所以我通常只挑两到三条有业务价值的来做。设计时我会特别注意把操作动作和数据准备分开不要让一个用例里又注册用户又准备商品又发优惠券否则一旦失败定位问题会非常痛苦。2.2 profile1 到底是什么一段配置拆开讲聊到 profile1先说说它诞生的背景。当时我们的 CI 上要支持本地快跑PR 验证夜间全量三种执行场景每次跑测试都要带一长串命令行参数比如--projectchromium --grepsmoke --retries2 --timeout30s。团队里不同的人记的还不一样经常有人在 PR 上跑了全量用例白白等 25 分钟。我后来就把执行策略固化成了 profile 机制用TEST_PROFILE环境变量来切换不同的配置组合。profile1 是我们最常用来做发布验证的那套对应的是核心冒烟回归Chromium 高并发 worker 短超时 失败重试两次。理解了这个后面看配置就顺了。以我们日常使用 Playwright 的工程为例核心配置长这样// playwright.config.ts import { defineConfig, devices } from playwright/test; const profile process.env.TEST_PROFILE || profile1; export default defineConfig({ timeout: profile profile1 ? 30_000 : 60_000, retries: profile profile1 ? 2 : 1, workers: profile profile1 ? 4 : 2, reporter: [[html, { open: never }], [list]], use: { baseURL: profile profile1 ? https://staging.example.com : https://prod-staging.example.com, trace: retain-on-failure, screenshot: only-on-failure, }, projects: [ { name: chromium, use: { ...devices[Desktop Chrome] }, grep: /smoke|p0/, }, ], });这里有几个参数值得展开讲一讲。grep: /smoke|p0/是 Playwright 的标签过滤机制表示只有带smoke或p0标签的用例会被这场 profile 执行。这样 profile1 就能精准地只挑出冒烟级用例而不是把整个 tests 目录所有用例都跑一遍。timeout设置成 30 秒而不是默认的 30 秒呃不对其实是根据我们的业务接口平均响应时间算出来的。订单提交流程里有几个外部服务P95 延迟接近 8 秒再加上前端渲染、支付回调单条用例给 30 秒是够用的再设短了就容易出现偶发超时。retries: 2是另一个关键参数。我特意没有设成 0因为测试环境偶发资源抖动是正常的一条用例因为 2 秒的网络抖动就失败会浪费团队大量排查时间但也不设更多因为超过两次重试基本说明用例本身或被测系统有问题再重试只会掩盖真问题。2.3 为什么不用一个万能配置跑所有场景有同事问过我反正都是 Playwright 跑用例为什么非要多一层 profile直接在命令行过滤不也一样吗如果只是自己一个人用确实一样但放到团队协作里完全不一样。第一是防呆。团队成员水平参差不齐不是每个人都清楚--grep和--project的组合规则。把 profile 固化之后大家只需要记住TEST_PROFILEprofile1 npx playwright test这一条命令。第二是统一策略。超时、重试、并发度、数据准备方式、本地执行和 CI 执行的区别这些细节全部收口到代码里不会因为某个人在命令行多加了一个参数就破坏整体设计。第三是可审计。不同 profile 对应不同发布门禁profile1 是冒烟profile_full 是夜间回归跑在哪个环境、哪些用例、什么策略打开配置文件一眼就能看到。实测下来profile1 跑完整套冒烟用例只需要 2 分 40 秒全量模式要 25 分钟。对于发布验证这个场景快就是一切。也正是因为有了这一层抽象我们才能放心大胆地对用例做标签化管理不用每次发布时在脑子里重新拼一遍参数。3. 实操过程从零搭一套能跑的 e2e 工程3.1 环境准备与工程初始化选型定了之后搭工程这一步其实很简单但很多新手会卡在环境问题上。先说前提建议在 Node.js 18 以上的环境里操作npm 版本尽量新一点。然后在一个干净的目录里执行官方初始化命令它会自动建立标准的 tests 目录、生成playwright.config.ts和package.json。npm init playwrightlatest执行完会让你选择是 TypeScript 还是 JavaScript 项目我用 TypeScript因为用例写到后面规模变大之后类型提示能省很多排查低级错误的时间。还会问要不要装浏览器和 GitHub Actions 示例。第一次跑的话建议都选上方便后面对照着改。接下来安装浏览器运行时npx playwright install --with-deps chromium--with-deps会自动安装系统级依赖库这在 Linux CI 环境里尤其重要否则经常出现明明 Playwright 装了但一启动浏览器就报缺.so共享库的错误。我单独记这个是因为它太常见了很多人第一次在 Docker 容器里跑 e2e 都会踩中。初始化完成之后目录大概是这个样子tests/存放用例文件playwright.config.ts是全局配置入口package.json里会有test:e2e脚本。3.2 把 profile1 固化到工程配置里初始化完成之后我们需要在前面那段配置的基础上再把 profile1 真正接进 npm 脚本里这样团队成员就可以用极简命令触发。{ scripts: { test:e2e: npm run test:e2e:profile1, test:e2e:profile1: TEST_PROFILEprofile1 playwright test, test:e2e:full: TEST_PROFILEfull playwright test } }注意我这里把默认test:e2e指向了 profile1这是故意的。因为对于大多数开发者来说日常开发中最需要的是快速验证核心链路没有特殊需求就不应该被逼着去背全量回归的命令。如果你要跑全量则显式执行npm run test:e2e:full。再补一个细节在 CI 流水线里环境变量跨平台设置可能有些头疼Windows 的set TEST_PROFILE...和 Linux 的TEST_PROFILE...写法不一样。为了避免和维护这个差异较劲可以在配置文件里默认读取一个.env文件或者在 CI 编排平台比如 GitLab CI / GitHub Actions的 job 配置里显式注入环境变量。我更喜欢后者因为 CI 的配置本身就是文档谁看了都知道这次跑的是什么策略。3.3 核心链路用例编写与调试流程工程骨架搭好接下来就是写用例。我拿最经典的搜索商品并下单成功这个场景来演示它属于 profile1 里必须覆盖的主链路。import { test, expect } from playwright/test; test.describe(下单核心链路, () { test(搜索商品并提交订单, { tag: smoke }, async ({ page }) { // 打开首页 await page.goto(/); // 搜索商品 await page.getByPlaceholder(搜索商品).fill(无线鼠标); await page.getByPlaceholder(搜索商品).press(Enter); // 进入商品详情 const productCard page.locator(.product-card, { hasText: 无线鼠标 }); await expect(productCard).toBeVisible(); await productCard.click(); // 加入购物车并结算 await page.getByRole(button, { name: 加入购物车 }).click(); await page.getByRole(button, { name: 去结算 }).click(); // 提交订单 await page.getByRole(button, { name: 提交订单 }).click(); // 验证业务结果而不是只验证页面出现 await expect(page.locator(.order-success)).toContainText(订单提交成功); }); });这段代码里有很多 Playwright 推荐的最佳实践我展开说一下。第一条是定位器尽量贴近用户的视觉和语义习惯。getByPlaceholder、getByRole、getByText是优先选择的不要一上来就用page.locator(#root div div button)这种深层 DOM 结构加索引的写法。前端结构一调整这种定位器必挂返工成本极高。第二条是断言放在关键业务结果上。上面的用例里我没有断言加入购物车按钮出现了没有也没有断言去结算按钮的颜色或文案只等最终订单成功标识出现。细节的视觉问题交给视觉回归测试e2e 应该关注的是业务正确性而不是 UI 像素。调试时也有一个很顺手的工作流先用无头模式跑一遍如果挂了不要急着加console.log。直接打开 trace 文件和失败截图两步就能定位到是等待超时、断言失败还是数据没准备好。npx playwright test --trace on npx playwright show-traceshow-trace会生成一个可视化的调用链左侧是每一步操作右侧是页面快照和网络请求。尤其是碰到跳出弹窗导致后续点击失效这种问题看 trace 比看任何日志都高效。3.4 测试数据准备与幂等性设计写 e2e 用例最大的敌人不是选择器是数据。最典型的失败场景是第一次跑用例成功第二次跑就挂因为上一次的测试订单还留在测试环境里库存被扣完了或者用户名已经注册过导致冲突。我的方案是给每条用例准备一个完全独立的测试身份和测试数据。简单做法是用户名的后缀加随机值比如test_user_${Date.now()}订单编号也用时间戳拼接。更可控的做法是调用后端提供的测试专用 API 来初始化数据而不是通过 UI 一步步注册。很多团队会轻视这一步等到测试环境被跑脏之后才回来补那时候成本已经很高了。还要注意幂等性。e2e 用例执行了两次、三次结果应该保持一致。为了做到这点我在 profile1 里强制要求所有下单用例都必须使用独立的优惠券模板和独立的商品 SKU这些测试数据在每次用例开始时通过 API 动态创建用例结束之后通过清理任务回收。这样做虽然增加了一点前期工作量但换来的稳定性能让团队长期受益。4. 常见问题与排查技巧实录4.1 选择器不稳定为什么定位总是飘如果 e2e 用例每周都要修一遍八成是选择器写得不对。我见过最夸张的一个同事用page.locator(button:nth-child(3))去定位按钮结果产品经理上了一个活动横幅页面结构里多了一个节点三条用例当场全红。正确做法循序渐进优先用getByRole配上可读的name比如getByRole(button, { name: 提交订单 })如果页面上有多个相同文案的元素就用hasText配合父级定位器缩小范围比如page.locator(.product-card, { hasText: 无线鼠标 })最后才考虑写严格的 CSS 定位器但也尽量避免依赖深层嵌套结构。还有一个非常实用的技巧如果某些元素是异步渲染的不要用waitForTimeout(2000)去硬等应该用await expect(locator).toBeVisible()让 Playwright 自己反复检查。硬等待是 e2e 用例里最难维护的坑它在本地跑可能没问题一到 CI 上就变慢或变快偶发性失败让人防不胜防。4.2 测试数据污染环境被上一次运行搞脏了数据污染是 e2e 稳定性最大的隐形杀手。我们曾经出现过一条诡异的失败某天早上跑 profile1下单用例里的库存断言失败了但前一天晚上还跑得好好的。排查到最后发现是夜间全量模式里有一条用例下单后没有取消订单把测试商品库存扣光了导致第二天冒烟用例无货可用。解决这个问题的组合拳是第一所有用例的数据都动态创建绝不在用例之间共享可变更的公共数据第二用独立的测试商品池或按用例 ID 分配资源避免不同 profile 争抢同一批数据第三在每次 profile1 执行前先跑一个测试数据清理脚本把遗留的脏数据标记为失效而不是物理删除保证有据可查。这里还要提醒一句清理数据的逻辑一定不要写进冒烟用例本身否则冒烟用例跑完之后数据就被删了回归用例又得重建整个流程会变得非常冗余。4.3 超时与并发执行稳定性和速度如何兼得超时问题是最让人头疼的偶发失败来源。一条用例在本地跑十次全过在 CI 上跑一次就挂报错信息还都是等待元素超时 30s。我遇到这种情况会先打开 trace 看接口耗时如果某个请求的响应用了 40 秒那就说明不是定位器问题而是测试环境性能问题。这时候要把timeout调大或者单独对那一个步骤配置test.step级别的超时。并发执行又是一个双刃剑。我们一开始把 worker 开到 6profile1 跑得飞快但测试环境经常 502后面发现是压垮了后端的测试实例。后来把 worker 降到 4再配合后端服务每 30 分钟自动清理一次空闲会话稳定性和速度就都保住了。如果你发现并发越高失败率越高第一反应应该是看被测系统的容量而不是怀疑框架。还有一个很容易被忽略的点不同 profile 不要同时跑在同一个测试环境里。profile1 和夜间全量同时执行数据回收和用例之间的数据隔离就很难保证。我们直接在 CI 里给两个 job 加了互斥锁串行执行这样才能保证测试结果真实可信。4.4 第三方 iframe、弹窗与外部依赖的应对我们的下单流程里有一个第三方收银台支付信息是在 iframe 里输入的。这是 e2e 里最容易让人抓狂的场景。好在 Playwright 对 iframe 的支持比另外两个框架顺畅不少核心是用frameLocator。const paymentFrame page.frameLocator(#payment-iframe); await paymentFrame.getByPlaceholder(持卡人姓名).fill(Test User); await paymentFrame.getByPlaceholder(短信验证码).fill(123456); await paymentFrame.getByRole(button, { name: 确认支付 }).click();写这种用例时我的经验是两步走第一步先确认 iframe 能正常加载出来第二步再操作里面的元素。如果 iframe 加载慢光在这一步就会超时比较稳妥的做法是先用await expect(paymentFrame.locator(input)).toBeVisible()等它出现。弹窗则是另一类高频问题。很多系统的弹窗不是标准dialog而是一层 div 盖在上面但用户操作被遮住时Playwright 会直接报 element is not interactable。遇到这种问题我会检查是不是有遮罩层或者上一个弹窗没关掉。不要急着用page.reload()重开页面那只是掩盖问题。至于外部依赖比如短信发送、第三方登录我建议在测试环境里一律 mock 掉。但要 mock 得聪明不能把整个接口都 mock 掉否则就测不到真实业务链路的深度了。我们的做法是只 mock 外部供应商的边界接口比如发送短信验证码这个动作返回预设验证码但内部订单流转、支付回调逻辑仍然走真实链路。最后说个我踩过几次坑之后特别注意的小细节在 profile1 里我给失败重试次数设置成 2而不是 0也不是更多。重试机制在 CI 偶发抖动时很有用但设置过多的话反而会掩盖被测系统真正的问题。认真跑一遍读取真实失败原因这是 e2e 测试能真正发挥价值的前提。调试时顺手用 trace 记录几乎每次都能在几分钟内定位到根因比凭感觉改 selector 要高效得多。