项目标题叫"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 都是开箱即用的。
这里放一个我当时做选型时用的对比表,方便你快速有个判断:
| 框架能力 | Selenium | Cypress | Playwright |
|---|---|---|---|
| 浏览器驱动方式 | 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 验证""夜间全量"三种执行场景,每次跑测试都要带一长串命令行参数,比如--project=chromium --grep=@smoke --retries=2 --timeout=30s。团队里不同的人记的还不一样,经常有人在 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_PROFILE=profile1 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 playwright@latest执行完会让你选择是 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_PROFILE=profile1 playwright test", "test:e2e:full": "TEST_PROFILE=full 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 开到 6,profile1 跑得飞快,但测试环境经常 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 要高效得多。