在自动化测试的圈子里,e2e(端到端测试)一直是个容易挨骂的词。跑得慢、爱乱挂、维护起来像是在给一头大象做保养——这些话我都说过,甚至一度坚持“单测加接口测试就够了”。直到连续三次线上问题,单测、接口测试全绿,用户那边却已经崩到不行,我才彻底改观。这篇文章会把我这些年做 e2e 的完整思路捋一遍:为什么要做、哪些用例值得做、怎么做才不脆、挂了之后怎么查。适合正在纠结要不要上 e2e 的测试开发同学,以及被 e2e 稳定性折磨的团队。文章里没有高深的理论,全部是我实际踩过坑之后的总结。
1. 从抗拒到接受:e2e 到底在验证什么
1.1 三次线上事故让我明白的
我以前对 e2e 的态度,基本是“能不做就不做”。理由很充分:单测覆盖了核心逻辑,接口测试验证了服务端契约,前端组件也有自己的测试,那为什么还要花大力气把所有东西串起来跑一遍?
直到有一次,用户反馈下单流程里“优惠券选了之后没生效”。排查下来发现:后端接口一切正常,单测也覆盖了优惠计算,前端组件测试也过了,问题出在组件 A 计算完优惠金额之后,传给组件 B 的字段名是discount,而组件 B 读取的字段是discountAmount。两个组件各自都有测试,但没有任何一层测试把它们俩真正连起来跑。这种“字段对不上”的 bug,单测和接口测试永远测不出来,只有模拟真实用户点完整流程的 e2e 才能抓住。
第二次是权限问题。用户登录后在某个菜单页面能看到别人的数据,单测覆盖了权限工具函数,接口测试覆盖了服务端校验,但前端在路由跳转时少带了一个角色参数,导致页面渲染走了另一个分支。这种问题如果不在真实浏览器里走完整流程,几乎不可能提前发现。
第三次更典型。第三方支付回调,本地联调时一切正常,上了临时环境之后偶发失败。最后发现是回调地址拼错了环境变量,只有当整个链路真正跑起来、连到正确环境时才会暴露。
三次事故之后我彻底想明白一件事:单测和接口测试解决的是“每个零件是好的”,但用户不关心零件,用户关心的是“整台机器能不能用”。e2e 验证的就是这台机器能不能用。
1.2 e2e 验证的不是一段代码,而是“用户旅程”
很多人把 e2e 理解成“多写几个页面跳转测试”,这有点浪费了它的价值。真正值得用 e2e 去守的,是那些能代表用户完整操作路径的场景。
举例来说,对一个电商系统,“用户从搜索商品到支付成功”是一个完整旅程。这个旅程里涉及搜索服务、商品服务、购物车服务、订单服务、支付服务、消息通知服务,还涉及前端路由、状态管理、本地缓存、第三方支付页面跳转。任何一个环节出问题,最后的“支付成功”页都不会正常出现。e2e 做的就是这张“全局安全网”。
我个人的习惯是,把 e2e 用例天然地和“用户故事”绑定。每个用例的开头都是“作为一个用户,我想要……”,然后把这个故事从头到尾在真实浏览器里演一遍。这比“打开首页、点击按钮、断言结果”这种写法学到的信息量大得多,因为你被迫去思考用户每一步的真实状态和依赖。
1.3 单测、接口测试、e2e 各自的位置
这三者不是替代关系,而是互补关系。我经常用一个流水线类比:单测是检查每个工位上的零件是否合格,接口测试是检查零件从 A 工位运到 B 工位的传送带是否正常,e2e 是检查整条流水线从原材料入口到成品出口是否能跑通。你不会因为传送带正常就不检查成品,反之亦然。
| 测试层级 | 关注点 | 执行速度 | 常见问题定位 |
|---|---|---|---|
| 单测 | 函数/组件的输入输出 | 毫秒级 | 逻辑错误、边界条件 |
| 接口测试 | 服务端接口的请求响应 | 秒级 | 参数校验、鉴权、数据状态 |
| 组件测试 | 单个 UI 组件的渲染与交互 | 秒级 | 样式、交互状态 |
| e2e | 完整用户旅程 | 分钟级 | 集成问题、环境配置、数据链路 |
我的建议不是“什么都往 e2e 里塞”,而是把 e2e 当成人体的“急诊室”——平时小病由单测和接口测试处理,但遇到真正要命的事故,必须有一个能完整还原用户路径的机制兜底。没有急诊室,其他测试再全,出事时都是束手无策。
2. 先想清楚:哪些用例值得进 e2e,哪些千万别塞进去
2.1 用“用户关键路径”筛用例
在开始写 e2e 之前,最重要的一件事不是选工具,而是画“关键路径图”。我通常会把系统里所有用户往来的主要流程列出来,然后标出哪些流程一旦出问题,会造成最直接的用户投诉、资损、或者其他严重业务影响。
举个例子,一个典型的电商系统,关键路径大概是:
- 新用户注册并登录
- 搜索商品并查看详情
- 加入购物车并结算
- 使用优惠券和地址管理
- 完成支付并接收支付回调
- 查看订单状态和退款申请
每个关键路径背后都挂着完整的业务链路,这些是 e2e 的“一等公民”。至于后台管理系统的列表页、详情页、配置项增删改,通常不在关键路径范围内,我会把这些交给接口测试去覆盖,因为它们的操作者不是普通用户,出了问题影响范围也相对可控。
筛选的时候我会问自己三个问题:
- 如果这条路径坏了,用户会不会立刻感知到?
- 这条路径是不是横跨多个服务或第三方系统?
- 这条路径是不是高频、高价值的操作?
三个问题里只要有两个回答“是”,基本就值得进 e2e。
2.2 不适合进 e2e 的用例类型
我也见过很多团队把 e2e 写成“全量回归测试”,最后把自己玩崩了。有些用例真的不应该放进 e2e,比如:
- 纯展示性页面的文案和样式断言,这种应该交给前端组件测试,e2e 断言样式又慢又脆。
- 已经由单测全覆盖的纯计算逻辑,比如复杂的打折规则、税率计算,写进 e2e 只是白白拉长执行时间。
- 只依赖静态数据、没有任何用户交互的接口拼接测试,这种用接口测试跑更快、更稳定。
- 对第三方系统行为的强断言。比如支付页面跳转后断言第三方页面的内部元素,这类用例依赖第三方页面的 DOM 结构,对方一改就挂,得不偿失。
我个人有一条红线:凡是单独拎出来可以用更快的测试层级解决的问题,就不值得放进 e2e。e2e 的价值在“集成”,不在“覆盖”。
2.3 用“e2e profile”定义运行范围
说到这,正好回应热搜里那个“e2e profile1”。所谓 profile,在 e2e 测试体系里就是指“一组测试用例的集合,配上一套对应的运行环境配置”。一个成熟的项目通常不只跑一套 e2e,而是按运行时机和风险级别划分成多个 profile。
我常用的划分方式是:
- profile1:冒烟集。包含所有关键路径里的最小核心场景,比如登录、下单主流程、支付回调。每次代码合并前通过 CI 跑一遍,要求 10 分钟内完成,目的是快速发现“这个变更是否把大动脉切断了”。
- profile2:发版集。在 profile1 的基础上加上订单、退款、权益等二级重要流程。每次发布前跑,允许 30 分钟左右。
- profile3:全量集。包含所有 e2e 用例,包括边缘流程和长链路场景。一般定时任务在夜间跑,第二天看一眼结果。
不同 profile 要放在不同 CI 流程里,避免“想快速验收却被迫等一个半小时”的尴尬。这也是我强烈建议团队做的事情:先定义自己的 profile1,把最小冒烟集跑稳,再慢慢扩充。
3. 工具选型:我为什么从 Selenium 换到了 Playwright
3.1 早期用 Selenium 的问题:脆弱、慢、调试难
我在项目早期用的是 Selenium WebDriver,当时觉得这是唯一能“自动化浏览器”的方案。但跑了一段时间之后,问题越来越明显。
首先是等待问题。页面加载、异步渲染、接口返回时间都不确定,Selenium 的经典做法是WebDriverWait+ 各种 expected condition,写起来非常啰嗦。为了省事,不少人干脆time.sleep(3),结果网络慢的时候照样崩,网络快的时候又白白浪费好几秒。
其次是调试体验。用例失败之后,能拿到的信息只有一张截图和一段堆栈。有些问题光看截图根本看不出当时页面到底处于什么状态,更别提定位前端到底哪一步出了岔子。
最难受的是并行执行。Selenium 跑单浏览器实例还能将就,一旦要在几个浏览器里并行跑,环境隔离、窗口管理、资源竞争的问题立刻冒出来,CI 机器经常被拖垮。
3.2 Playwright 解决了什么
后来我切到了 Playwright,最直观的感受是“它为稳定性而生”。几个让我回不去 Selenium 的设计:
- 自动等待。Playwright 的所有核心操作默认会等待元素可见、可交互、稳定之后才执行,不需要手写一堆等待条件。
- 浏览器上下文隔离。每个测试可以创建一个全新的 browser context,相当于一个干净的浏览器会话。并行跑测试时互不干扰,cookie、localStorage 都是隔离的。
- 网络拦截和模拟。可以直接 mock 接口返回,也可以在测试里等待某个请求完成,这对处理前后端异步竞态非常有用。
- Trace Viewer。用例失败后可以生成一个完整的 trace 回放文件,能看到每一步操作、网络请求、控制台日志和页面截图,排错效率翻倍。
一个简单的用例长这样:
import { test, expect } from '@playwright/test'; test('下单支付主流程', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: '登录' }).click(); await page.getByPlaceholder('用户名').fill('e2e_user_001'); await page.getByPlaceholder('密码').fill('Test@123456'); await page.getByRole('button', { name: '立即登录' }).click(); await page.getByTestId('search-input').fill('无线耳机'); await page.getByTestId('search-btn').click(); await page.getByText('无线耳机 黑色').click(); await page.getByRole('button', { name: '加入购物车' }).click(); await page.getByRole('button', { name: '去结算' }).click(); await page.getByRole('button', { name: '提交订单' }).click(); await expect(page.getByText('支付成功')).toBeVisible(); });这段代码没有任何 sleep,Playwright 会自动等待每一步的 UI 稳定,而且定位器用的是可读的getByRole、getByTestId,不会因为样式类名变动就挂掉。
3.3 不同环境 profile 的具体配置方法
Playwright 自带配置文件playwright.config.ts,我们可以在这里定义多个 project,每个 project 对应一个 profile 或者一套运行环境。
import { defineConfig } from '@playwright/test'; export default defineConfig({ timeout: 60_000, retries: 1, reporter: process.env.CI ? 'github' : 'list', use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', trace: 'retain-on-failure', screenshot: 'only-on-failure', }, projects: [ { name: 'profile1-smoke', testMatch: /smoke\/.*\.spec\.ts/, grep: '@smoke', }, { name: 'profile2-release', testMatch: /smoke|release\/.*\.spec\.ts/, grep: '@smoke|@release', }, { name: 'profile3-full', testMatch: /.*\.spec\.ts/, }, ], });再把常用命令固化到脚本里,开发本地跑冒烟,CI 里按分支跑对应 profile:
npx playwright test --project=profile1-smoke npx playwright test --project=profile2-release npx playwright test --project=profile3-full这套配置配合 CI 平台,基本能做到“合并代码前跑冒烟,发版前跑发版集,夜间全量”的节奏,既给了反馈速度,又给了兜底深度。
4. 稳定性的三个命门:等待、数据、重试
4.1 等待策略:不要用固定 sleep,要用自动等待和断言等待
很多人写 e2e 一遇到不稳定就加 sleep,这事我强烈不建议。固定 sleep 本质上是在赌“网络和渲染速度一定会在某个阈值内”,可只要出现一次超过阈值的抖动,用例就挂了;反过来,如果一切顺畅,sleep 又在白白浪费时间。
正确的做法是依赖框架的自动等待机制。Playwright 和 Cypress 的核心操作都会自动轮询直到元素可交互,这已经覆盖了绝大多数场景。
少数需要手动“显式等待”的场景,是当页面操作必须等待某个网络请求完成、再刷出后续界面时。这个时候我习惯等接口回来,而不是等元素出现:
const respPromise = page.waitForResponse('**/api/order/price'); await page.getByRole('button', { name: '去结算' }).click(); await respPromise;这样比waitForTimeout可靠得多,它等的不是“一段时间”,而是“一个真实发生了的事件”。
4.2 测试数据隔离:共享数据的后果
e2e 不稳定的一大元凶是测试数据互相污染。经典的场景是:某个用例假设系统里只有一条待处理订单,于是它总能在列表第一行看到目标订单;可另一个用例跑完了没清理自己的数据,就凭空多出几行,两个用例一起跑时必挂。
我的做法是“每个用例自带数据、自管数据”。具体来说:
- 用唯一的业务标识,比如订单号加
_e2e_时间戳,避免和其他用例冲突。 - 每个用例开始前通过接口或数据库操作准备自己的测试数据,而不是依赖环境里预置的数据。
- 用例结束后做清理,但别过度依赖清理逻辑,因为用例挂了可能没跑到清理步骤。宁可让数据产生冗余,也别出现“别人依赖我的脏数据”这种耦合。
举例来说,创建订单时:
const orderNo = `E2E_${Date.now()}_${process.pid}`; const resp = await request.post('/api/admin/order/create', { data: { orderNo, amount: 100 } });这样每次跑出来的数据都是独一无二的,不会因为上一个用例没删干净就漏到当前用例里。
4.3 失败重试:该重试,还是该修根因?
我原来很喜欢给 e2e 配置重试,觉得“挂一次就重试一次,总有一次能过”。后来发现这纯粹是自欺欺人。如果你的用例会因为时间、状态、顺序等因素偶发失败,重试只是在掩盖根因,而且会浪费大量 CI 时间。
更合理的原则是:重试只用于“已知且可控的抖动”,比如第三方支付页短暂不可达、某个外部通知服务偶尔超时,这类情况重试一次可以接受,但一定要在日志里标记“这是重试后才通过的”。至于那些由测试代码自身缺陷、数据未隔离、等待条件不对导致的失败,重试多少次都会挂,不如老老实实修用例。
Playwright 里配置重试很简单:
retries: process.env.CI ? 1 : 0,但我更建议顺手在测试报告里看“首次执行失败率”,如果某个用例连续七天都在重试后通过,那它一定有问题,必须拆开排原因。
5. 排错实录:一次 e2e 从“必挂”到“稳定”的全过程
5.1 现象:CI 任务“灵异失败”
有一次我们 CI 上的 profile1 冒烟集,莫名出现一个失败的用例:进入商品列表页后,点开某个商品详情,偶尔会出现内容为空的页面,断言“商品标题可见”失败。本地跑同一个用例,连续十次都通过,CI 上却隔三差五挂一次。
最开始团队的第一反应是“网络问题”,加了一堆waitForTimeout,结果不仅没改善,反而把用例拖得更慢。后来我决定不再猜,直接把失败的 trace 文件拉出来看。
5.2 排查链路:从“重试”到“复盘”
通过 trace 回放,我发现了一个关键细节:点开商品详情后,页面上确实发出了详情接口请求,服务端也返回了 200,但响应的data字段是空的。这就有意思了——接口没报错,但数据为空。
接着我看了服务端日志,发现这个商品详情接口依赖一个缓存服务。缓存 key 是按用户 ID 拼接的,而 e2e 每次登录使用的是同一个测试账号。第一次跑用例时,缓存里还没有数据,后端会去查数据库并回填缓存;但 CI 并行跑多个用例时,多个测试进程同时走到这一步,其中一个进程回填完了,另一个进程却读到了还没回填完成的过期状态。
这个问题的概率性来自并发窗口:如果是单进程跑,几乎总会走到“先回填再读取”的顺序;一旦并行执行,两个请求间隔被拉近,就可能命中不稳定的缓存时间窗口。
为了验证我的猜测,我在本地强制模拟并发:同时启动两个 Playwright project,分别操作同一个测试账号,果然复现了 CI 上的问题。
5.3 修复方案与验证
修复方案分两层。
第一层是治标。既然是缓存回填的竞态,那就让 e2e 在跑用例之前主动清掉测试账号的缓存。通过后端的一个管理接口,把所有和测试账号相关的缓存 key 一次性清掉,保证用例跑的时候要么缓存完全失效、要么已经回填完毕,不存在中间状态。
test.beforeAll(async ({ request }) => { await request.post('/api/admin/cache/clear', { data: { account: 'e2e_user_001' } }); });第二层是治本。后端缓存回填的逻辑里,对“并发首次访问同一用户”的场景加了分布式锁。这是后端团队配合改的,本质上是让缓存重建过程具备原子性。
修复之后,同样的用例连续在 CI 上跑了一个星期,一次都没挂。复盘时最有价值的教训并不是“如何修缓存”,而是整个排查链路:看 trace、看网络响应、看服务端日志、推测并发窗口、本地复现、再修复。这套链路比任何工具本身都重要。
结尾的个人体会
做了这么久 e2e,我最深的感受是:e2e 本身并不脆弱,脆弱的是我们对运行环境的各种假设。数据隔离没做好、等待方式用错、用例顺序有依赖、外部服务不稳定,任何一条假设写错,都会变成 CI 上的随机炸弹。与其追求把 e2e 做得多庞大、覆盖率多高,我更推荐先把 profile1 的冒烟集跑稳,再逐步往外扩展。最后分享一个小技巧:每次 e2e 失败之后,不要只截图发群里,而是强迫自己回答“这个失败是因为测试代码写错了,还是业务真的有 bug”。前者说明你的测试需要修,后者说明你的业务需要修,几乎所有 flaky test 都能在这种追问下现出原形。