看到“e2e”这三个字母,如果你第一反应是某个游戏里的术语,或者一串神秘代号,那你可能不是做软件交付的。但如果你是个开发、测试或者运维,看到这三个字母大概率会心一笑——对,就是那个让人又爱又恨的端到端测试。
我最早接触e2e测试,是刚带团队做一套B端后台管理系统的时候。那时候大家的共识是“单元测试覆盖率挺高,接口测试也有,应该够了吧”。结果上线前联调,业务流程走到一半就断,发现是前端少传了一个字段,后端按另一个字段名取的。这种问题,单元测试测不出来,接口测试也测不出来,只有真正模拟用户从头点到尾才能暴露。从那以后,我基本把e2e当成了产品上线前最后一道物理防线。
这篇文章不是教你背概念,而是把我这些年搭e2e测试体系过程中真正踩过的坑、趟出来的经验、以及一些可以“抄作业”的配置和代码,尽量完整地分享出来。内容可能会有点长,但每一条都有实际场景兜底,适合正在纠结“e2e到底怎么落地”的团队参考。
1. e2e,到底在测什么?——从一次线上事故说起
1.1 一场让整个团队重新审视e2e的事故
事情发生在一次版本发布后。用户反馈:在后台新建商品的时候,只要勾选了“参与促销”,保存成功之后再点编辑,促销价格永远显示为0。研发排查了大半天,最后定位到问题出在接口返回的数据结构里——促销价格字段从promotionPrice改成了promotion_price,前端代码只改了详情页,没改编辑页,于是一边拿得到数据,一边拿不到。
这个bug单靠单元测试是绝对测不出来的,因为单个模块的输入输出都正常。靠接口测试也只能测出后端两个接口都对,但没人测过“前端页面A保存的数据,能不能被前端页面B正确回显”这条链路。那条链路,就是一条典型的端到端路径。
那次事故之后,我们达成了一个很朴素的共识:单元测试保证的是一个函数够不够健壮,接口测试保证的是两个系统之间说话说得通,而e2e保证的是一个真实用户能不能把事情办成。这三者不是替代关系,是互补关系。
1.2 e2e测试的边界:测的是“流程”,不是“功能”
很多团队一上手就犯一个错误:把e2e当成功能测试的翻版,把页面上的每个按钮每个输入框都点了遍,断言各种细节。这是走偏了。
e2e的核心价值是验证跨模块、跨系统、跨角色的完整业务流程,而不是验证某个组件的交互细节。组件交互细节交给组件测试,页面交互细节交给前端单测,e2e只负责回答一个问题:用户这条关键路径,能不能从头走到尾,并且每一步的状态都是符合预期的。
举个例子,一个下单流程,e2e用例应该是:
- 用户点击“加入购物车”;
- 跳转购物车页,修改数量;
- 点击结算,进入确认订单页;
- 填写收货地址,提交订单;
- 页面跳转到收银台,生成订单号;
- 调用支付模拟接口,支付成功;
- 跳转支付结果页,显示“支付成功,等待发货”。
这7个步骤跨了商品详情、购物车、订单确认、订单生成、支付、结果页六个模块,可能还涉及库存系统的扣减。任何一个环节断了,用例就红。这才是e2e应该干的事。
1.3 三种测试的职责划分,e2e的不可替代性
我把测试金字塔在这个体系里的分工重新梳理了一下,画成一个简单的分工表,方便团队对照:
| 层级 | 主要职责 | 成本和速度 | 发现问题类型 |
|---|---|---|---|
| 单元测试 | 验证单个函数、组件、方法的输入输出 | 极低、极快 | 计算逻辑错误、异常处理缺失 |
| 接口/集成测试 | 验证模块间、服务间的数据传递与协议契约 | 中、中 | 字段不匹配、鉴权失效、依赖服务故障 |
| e2e测试 | 验证真实用户视角下的完整业务链路 | 高、慢 | 流程断裂、页面间数据不一致、权限页面跳转错误 |
我们当时给e2e定的目标和口径就是一句话:核心业务链路不允许在e2e这里出问题。至于页面按钮好看不好看、某个输入框样式是否错位,那不是e2e的职责。
2. 选型:为什么我最终倒向Playwright
2.1 三家主流方案的横评
拿前端e2e测试来说,主流逃不开Selenium、Cypress、Playwright这三家。每个都各有拥趸,我也都实际写过、跑过,这里把我的使用感受列出来。
| 工具 | 执行原理 | 上手曲线 | 最明显的短板 |
|---|---|---|---|
| Selenium WebDriver | 通过WebDriver协议驱动真实浏览器 | 中 | 等待策略需要自己封装,稳定性难控制 |
| Cypress | 在浏览器内运行,直接操作DOM | 低 | 多标签页和跨域场景支持弱势 |
| Playwright | 通过CDP(Chrome DevTools Protocol)与浏览器通信 | 中低 | 相对较新,历史沉淀案例略少 |
Selenium是资历最老的老大哥,生态丰富,但它的等待机制和浏览器驱动版本管理,在后期的CI维护里非常头疼。Cypress确实好用,尤其对前端工程师极其友好,调试体验一流,但一旦遇到多窗口、跨域、Iframe嵌套这类场景,限制就比较明显。
Playwright给我的感觉是,它在工程化落地这点上想得很清楚。它把wait-for机制、自动重试、多浏览器支持、shadow DOM穿透这些能力都做进了框架底层,而不是留给使用者去写一堆helper函数。
2.2 选Playwright的几个决定性理由
我不太喜欢无脑站队,但站在“搭一套可持续运行e2e体系”的角度,Playwright有几个点是决定性的:
- 自动等待机制足够聪明。Playwright的locator定位元素后,执行click、fill这些动作前会自动等待元素处于可操作状态。不用再手写
sleep(2000)去碰运气。 - 多浏览器、多设备模拟一把梭。同一个用例,想跑Chromium、Firefox、WebKit都行,还能模拟移动端视口。对中后台要做兼容性回归时,这套省了太多事。
- 网络拦截能力是原生功能。可以拦截请求并mock响应,也可以直接等待某个接口返回。这个能力在做前端联调测试的时候简直救命——不用等后端环境就绪,测试自己就能独立跑。
- 内置测试运行器和报告器。不用额外接Jest或者Mocha,自带断言、重试、并行分片、HTML报告,CI集成就很顺。
2.3 搭一个能跑通的最小可复现环境
环境搭建其实很傻瓜,但有几个细节容易被官方文档带过,这里我单独拎出来讲一下。
先初始化项目:
npm init -y npm install --save-dev @playwright/test npx playwright install第二行装的是测试框架,第三行是下载浏览器二进制文件。这里有个坑:npx playwright install默认下载的是最新版浏览器,如果你的测试机或CI环境网络受限,很可能下不动。我一般会在CI里单独写一个步骤,指定浏览器版本,比如:
npx playwright install chromium --with-deps--with-deps会自动安装系统级的依赖库,这个东西在Ubuntu的CI环境里特别重要,少了它Chromium经常起不来,报一堆奇怪的so库缺失。
框架装好之后,在playwright.config.ts里做最小配置:
import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './e2e', timeout: 60_000, retries: 2, use: { baseURL: 'http://localhost:5173', trace: 'on-first-retry', screenshot: 'only-on-failure', }, });这一步先不用做太复杂的配置,能把一个最简单的用例跑起来,后面再逐步加东西。
3. 写第一版e2e用例:一个真实登录购物流程的拆解
3.1 前置条件准备:测试数据和身份状态
e2e最容易让人头大的不是写用例,而是前置数据准备。如果没有一套隔离的测试环境,你的用例每次跑出来的结果可能都不一样——上次测试残留了一条订单,这次创建商品的名字重复了,页面就弹错误提示。
我的做法是:在用例里显式定义一个beforeAll,通过调用后端API接口去创建测试数据,而不是通过UI一步步填表单。这里有一个原则:能用API准备的数据,绝不用UI去准备。
比如要测一个“登录之后下单购买”的流程,直接先通过接口登录拿token,再用token创建商品数据,然后启动浏览器把token写入localStorage,让页面直接进入登录态:
test.beforeAll(async ({ request }) => { const loginRes = await request.post('/api/login', { data: { username: 'test_user', password: 'test_pass' } }); const { token } = await loginRes.json(); process.env.TOKEN = token; }); test.beforeEach(async ({ page }) => { await page.addInitScript((token) => { localStorage.setItem('token', token); }, process.env.TOKEN); });addInitScript会在页面加载任何脚本之前先执行,插入localStorage数据,所以页面一启动就是“已登录”状态。这样你测的才是“已登录用户下单”这个核心链路,而不是每次都被登录页拦住。
3.2 从用户视角写出完整行为链路
Playwright的用例写起来像在描述操作,可读性很强。我拿一个实际的下单流程举例:
test('用户完成从搜索到下单支付全流程', async ({ page }) => { // 1. 进入首页,搜索商品 await page.goto('/'); await page.getByPlaceholder('搜索商品').fill('机械键盘'); await page.getByRole('button', { name: '搜索' }).click(); // 2. 在搜索结果中点击第一个商品 await page.locator('.product-card').first().click(); // 3. 在详情页加入购物车 await page.getByRole('button', { name: '加入购物车' }).click(); await page.locator('.cart-badge').toHaveText('1'); // 4. 去购物车修改数量并结算 await page.goto('/cart'); await page.locator('.quantity-input').fill('2'); await page.getByRole('button', { name: '去结算' }).click(); // 5. 填写收货地址并提交订单 await page.getByPlaceholder('收货人').fill('张三'); await page.getByPlaceholder('手机号').fill('13800138000'); await page.getByPlaceholder('详细地址').fill('XX市XX区XX路100号'); await page.getByRole('button', { name: '提交订单' }).click(); // 6. 断言订单号生成并完成支付 await expect(page.locator('.order-number')).toBeVisible(); await page.getByRole('button', { name: '模拟支付' }).click(); await expect(page.locator('.pay-status')).toContainText('支付成功'); });这个用例里每一步都有明确的业务含义,每一步的断言都是针对“流程是否走通”而非“页面某个样式是否对”。这也是e2e用例的核心写法——以用户行为为主线,以关键状态变化为断言点。
3.3 断言写在哪一步:验证状态而非验证细节
新手写断言很容易掉进“断言得太死”的坑。比如断言某个按钮的文字精确等于“立即购买”,这在需求文案改写的时候就会误报;或者去断言某个列表的DOM结构完全一致,前端重构一下样式就红了一大片。
我的经验是两个原则:
- 断言用户能看到的关键业务状态。比如订单号出现、支付成功提示出现、列表数量增加。这些状态直接反映业务结果,是用户感知到的结果。
- 避免断言中间过程的非关键细节。比如某个下拉框的option顺序、某个按钮的样式类名,这些和“用户办成事”没有直接关系。
用toBeVisible()、toContainText()这类在Playwright里自带自动重试的断言,比直接取文本做全等比较要稳健得多。
4. 稳定性是e2e的命门:我踩过的五个坑和完整排查链路
说到稳定性,这里先给个反直觉的结论:e2e测试用例大面积失败,很多时候根本不是被测应用有bug,而是测试本身写得不够稳定。一个满是“假阳性”的测试套件,最终会被团队嫌弃然后废弃掉。我亲眼见过一个项目的e2e从每天跑变成每周跑,最后彻底没人看,就是因为误报太多,大家被狼来了搞疲了。
4.1 第一个坑:无脑sleep导致的浪费
我们早期写用例的时候,最常见的是到处插page.waitForTimeout(2000)这种代码。看起来“等两秒总该加载完了吧”,实际上这就是不稳定的根源。因为两秒在某些机器上够用,在CI的低配机器上就不够用,而且页面加载慢是慢在网络波动,和固定时间没有关系。
我在一个用例里统计过,光是各种waitForTimeout加起来就有近20秒的纯垃圾等待时间。把这些全部换成Playwright的自动等待之后,用例总耗时从80多秒降到40秒左右,稳定性反而更高。
这里有个排查思路供参考:
- 当你怀疑“某个元素为什么点击没效果”时,先打开Trace Viewer看时间线,看点击的那个时间点,元素到底处于什么状态。是没出现?是出现了但被遮挡?还是处于禁用态?
- 然后用
expect(locator).toBeEnabled()或者toBeVisible()做显式断言,让测试自己等,而不是你帮它等。
4.2 第二个坑:selector失效的完整排查过程
有一次我们整个下单流程突然全红,提示找不到“去结算”按钮。初看以为是前端改版了,但问了一圈前端说没动过。于是我开始按顺序排查:
第一步,打开Playwright的Trace查看器,发现locator在点击前已经定位到了元素,但点击时元素坐标落在了另一个浮层上。原来是因为页面上弹了一个优惠券浮层,透明遮罩挡住了按钮,所以点击事件被浮层接收了。
第二步,我再翻看录制视频,发现浮层是页面加载后延迟3秒弹出的。这说明了为什么本地跑没问题,因为本地网络快,3秒内就跑完了点击;CI环境网络慢,浮层弹出来正好挡在路上。
第三步,修复方式不是去关浮层,而是让点击前先避开干扰。我的做法是:点击前先处理掉浮层,或者在用例里对这个页面统一添加一个“关闭营销弹窗”的前置操作:
await page.locator('.modal-close').click().catch(() => {});这一步用catch吞掉弹窗不存在的异常,有就关,没有也不影响主流程。
这类问题的排查链路核心是:不要一红就直接甩给前端说“你上线改坏了”,而是先看Trace、看视频、看网络请求,定位到底是环境因素、测试因素还是产品因素。
4.3 第三个坑:请求挂在pending的罪魁祸首
另一个经典场景:测试跑到一半卡住不动,最后超时失败。当时我们定位到一个规律——只有跑完整测试套件的时候会卡,单个用例单独跑就没事。
一开始怀疑是资源没释放,并发导致了端口耗尽,但是排查了一圈也没发现。
后来把CI里跑失败的完整日志拉出来,发现卡住的那个接口是埋点统计接口。这个接口体积大、响应慢,而且因为一直占用着网络连接,导致浏览器达到了连接数上限,后面的业务请求全部排队等待。
解决方案有两个方向:
- 在全局配置里,把这个埋点接口直接拦截掉,不等待它响应:
await page.route('**/api/tracking', route => route.abort());- 或者设置
route.continue()但让它立即返回一个空的响应体。
这是我们后来必加的一条全局规则:所有非业务核心的第三方请求、埋点请求、广告请求,在e2e里一律拦截或短接。这能大幅度提升稳定性,还能减少很多不确定的等待。
4.4 第四个坑:测试间数据污染
还有一次,用例A创建了一条测试商品“黑色T恤”,用例B搜索“T恤”后断言结果至少有1条,突然在某个时间段内频繁失败。我不理解为什么之前能过,后来一直红。排查链路是这样的:
第一步,看失败时的页面截图,发现搜索结果里确实没有“黑色T恤”。 第二步,看数据库,发现“黑色T恤”商品有几十条,但都处于未审核状态。 第三步,看创建时间,发现用例A每次跑完并没有清理数据,导致了大量重复的未审核商品,而搜索列表默认只展示已审核商品。所以用例B搜索时,排在最前面的新商品恰恰是不能展示的未审核商品,自然就搜不到。
后来我们定了一条铁律:e2e测试数据必须唯一化、标签化、可清理。每个用例创建数据时带上时间戳、UUID,用例结束以后通过API清理掉这些数据。虽然多写一点代码,但换来的是测试套件的长期可跑。
4.5 第五个坑:浏览器crash的无声退出
这个坑很隐蔽——跑着跑着,浏览器进程没了,测试直接报基础设施错误,而且毫无规律。我们查了很久,最后发现是CI环境的内存不足。因为一套e2e并行开了多个浏览器实例,每个Chromium进程要占几百MB内存,机器物理内存不够,直接被OOM Killer干掉了。
解决方案也很直接:
- 在
playwright.config.ts里限制workers数量:
workers: process.env.CI ? 2 : undefined;- 或者给每个浏览器上下文设置更小的视口和禁用部分硬件加速:
use: { viewport: { width: 1280, height: 720 }, launchOptions: { args: ['--disable-dev-shm-usage'], }, }--disable-dev-shm-usage这个参数在容器环境里几乎是必加的,它能让浏览器改用自己的临时目录而不是共享内存,减少容器环境里的崩溃概率。
4.6 稳定性治理的几条铁律
把上面五个坑串起来,我们最后沉淀出了几条稳定性铁律,团队内部一直沿用:
- 不使用固定
sleep,一律用自动等待和显式断言。 - 全局拦截所有非核心的第三方请求。
- 测试数据唯一化,用例结束即时清理。
- 允许失败重试,但重试次数要克制,一般2次就够。
- 每次跑完必须保留Trace和截图,方便失败后追溯。
5. 把e2e搬进CI:流水线配置与运行策略
5.1 两个Pipeline设计:夜间全量、PR冒烟
e2e用例越多,跑得越慢,如果每次PR都跑全部用例,开发会等到失去耐心。我的经验是把用例分成两层:
- 冒烟层:挑选每个核心业务域的“黄金路径”,大概20~30条,必须5分钟内跑完。每次PR合并前强制跑,质量门槛就靠它。
- 全量层:所有e2e用例,覆盖各种分支场景、异常场景,每天夜间定时跑,跑完推送报告到群里。
这样既保证了日常开发的反馈速度,又保证了发布前的全量回归覆盖。分层方案在CI里就体现为两条任务流水线,配置完全独立。
5.2 并行分片:shard的运算逻辑
Playwright支持--shard参数,可以按分片并行。这个在高并发CI场景下非常有用。比如你有60个用例,4台并行机器,每台跑15个。
npx playwright test --shard=1/4 npx playwright test --shard=2/4 npx playwright test --shard=3/4 npx playwright test --shard=4/4在GitHub Actions或者自建Jenkins里,可以用矩阵策略生成这四个分片任务。分片有一个小技巧:分片是按文件粒度切割的,如果你的某个文件有几十个用例,就会被切到同一台机器上。所以建议每个用例文件不要塞太多用例,每个文件控制在3~5个用例比较均衡。
我们早期一个文件塞了40多个用例,分片配置形同虚设——一个分片跑40个,另外三个分片空跑。后来拆开文件之后,整个执行时间几乎按照机器数量等比下降。
5.3 失败重试和报告:快而准地定位
重试策略我上面提过,retries: 2就是一个经验值。只在第一次失败时重试,还是连续失败都重试,可以根据用例的重要性调整:
test.describe.configure({ retries: 3 });对于容易受环境波动的用例,可以单独调高重试次数。但需要警惕的是,如果一个用例连续重试3次还是失败,那基本说明是真失败,不要再依赖重试掩盖问题。
报告这一块,Playwright自带的HTML报告已经够用,但我建议在CI里额外做两件事:
- 把失败截图自动上传到对象存储,并在通知消息里附上截图链接。
- 把Trace文件同时保留,方便本机直接拉下来用
npx playwright show-trace打开看。
5.4 运行成本核算:经济账也要会算
最后聊一个很多团队不太算的账——e2e的运行成本。这不是说云服务器的钱,而是团队时间成本。
假设全量用例400条,单机串行跑要2小时。如果不做分片,那么每次发布前,测试同学要等2小时才能拿到结果。如果每晚跑一次,两个小时的CI占用法则影响的是整个团队的发布节奏。
分片之后,4台机器并行跑,时间降到30分钟以内。我的建议是:全量e2e跑完的耗时超过30分钟,就必须做分片;超过1小时,就必须引入分层策略,只把核心路径放到发布前阻塞环节里。
还有一个小经验:夜间全量跑完后,第二天早上第一件事就是看报告,把失败的case统一处理掉。不要让失败用例累计超过3天,一旦累计,后面会形成“反正都是挂的,不用管”的心态,整个e2e体系就形同虚设了。
个人体会是,e2e测试这套东西,技术和配置层面都不难,难的是坚持“像用户一样思考”去设计用例,以及持续治理稳定性。如果团队能顶住最初几周的不适感,把核心路径的e2e真正跑起来,它对交付质量的保护是其他测试手段很难替代的。最后再分享一个小技巧:每季度抽空把e2e用例集里的断言全部重新审视一遍,把那些已经不符合业务现状的用例删掉或重写,这个动作能让整个测试套件长期保持健康,而不是越跑越沉重。