1. 先搞明白:我不建议你把E2E当成“全流程点一遍”
说到 e2e,很多人第一反应是“端到端测试”,然后立刻想到把整个页面从头到尾点一遍:登录、搜索、加购物车、下单、支付,一气呵成。这个理解不算错,但我做测试工程这些年,最怕的就是这种“理解不算错”带来的盲目自信:真正把 e2e 用好的团队,不是在堆用例,而是在筛流程。这篇内容就从我个人的实操经验出发,把 e2e 背后的设计思路、落地方式、稳定性处理和持续集成接入完整过一遍,顺便聊聊我常挂在嘴边的 e2e profile1 到底是什么。准备入自动化测试的团队可以看,已经被“分分钟 flaky”的用例折磨到秃头的测试同路人更应该看。
1.1 E2E到底在测什么
E2E(End-to-End)测试,字面意思是“端到端”,但它的核心不是“把用户能点的都点一遍”,而是“验证一条完整业务链路在真实运行环境里能否走通”。这里的“端”有两个:一端是用户入口,比如浏览器里的页面、小程序里的按钮、移动端 App 的操作;另一端是用户真正关心的结果,比如订单状态变成“已支付”、邮件发出、余额扣成功。
和单元测试、接口测试不一样,e2e 测试往往会同时穿过前端界面、后端接口、数据库、第三方服务。它模拟的不是函数调用,而是人类用户的行为。比如用户不会关心你某个按钮绑定的 click 事件写得好不好,他关心的是点完“提交订单”之后,钱是不是真的扣了,页面是不是真的跳到了“支付成功”。这就决定了 e2e 的粒度天然是“业务场景级”,而不是“代码函数级”。
所以你会发现,e2e 的用例名称通常长这样:“用户登录后可以正常结算订单”“新用户注册后能收到验证邮件”“已登录用户修改密码后旧 token 失效”。这种用例不是在验证某一行代码,而是在验证一整套业务承诺。
1.2 为什么“全流程点一遍”会翻车
我见过不少团队,一听说要做 e2e,第一批用例就把“所有菜单里的所有页面都点进去再退出来”写了一遍。这种用例跑起来确实很壮观,截图里满满的页面,但三个月之后基本全部死掉,为什么呢?
第一个原因是成本。e2e 用例比单元测试贵得多。单元测试跑 1000 个可能只需要几秒钟,但 e2e 用例每一条都要启动浏览器、加载页面、等待网络、操作数据库,跑一条主流程轻松超过一分钟。全流程点一遍这种写法,不是在做质量保障,而是在制造一台“定时冒烟机”。
第二个原因是重复。如果一个操作在五个流程里都会出现,比如登录,你把它重复写在五条用例里,那么只要登录页面的某个 DOM 变了,五个用例一起红。这在统计报表上很好看,但对修脚本的人来说一点意义都没有,因为他只改了一处前端代码,却要处理五条失败记录。
第三个原因是“流程太长所以稳定性太差”。一整条用户流程中,任何一环出现抖动——网络慢了、第三方支付回调超时了、某个图片加载不出来——整条用例都可能失败。于是团队每天不是在排查业务 bug,而是在给测试脚本“擦屁股”。久而久之,连真正的回归问题也会被淹没在一堆 flaky 结果里。
1.3 这篇文章适合谁
如果你是一个后端工程师,想在交付前快速验证核心页面没被改坏,e2e 可以帮你建立“最低限度的安全感”。如果你是专职测试,需要设计一套可持续维护的自动化用例,这篇文章里的 profile、选择器、等待策略会直接可用。如果你是在带团队的技术负责人,我觉得你更需要关注的是第五节里关于“门禁到底该卡在哪一层”的讨论,这决定了团队会不会陷入“测试比开发还忙”的泥潭。
2. E2E用例的设计拆解:决定成败的不是工具,是范围
很多人一提到 e2e,先问“用 Cypress 还是 Playwright”,然后立刻去装环境写脚本。我的建议是反过来,先想清楚“我要测哪些链路”,再决定用什么工具。工具解决的是怎么写得稳,而设计解决的是哪些值得写。后者才是决定 e2e 项目生死的关键。
2.1 用“用户核心价值链路”圈定边界
我设计 e2e 用例时,筛选原则不是“影响面大”,而是“如果这条链路挂了,用户会立刻感知到”。说通俗点:你在网上买东西,如果商品详情页某个 banner 显示错位,可能用户会吐槽,但不会放弃购买;但如果你点“立即购买”之后永远进不了结算页,那用户就会流失。所以前者可以交给视觉回归测试,后者才是 e2e 要守住的核心链路。
拿电商来举例,我会优先纳入 e2e 的范围是:注册/登录、浏览商品、加入购物车、提交订单、支付成功、订单状态同步。因为这些环节跨前端、后端、数据库、第三方支付甚至消息队列,任何一个环节出问题,最终都会体现在“用户买不了东西”或“钱对不上账”上。至于商品筛选的排序是否准确、优惠券计算是否符合复杂规则,这些更适合放进接口测试甚至单元测试,因为它们的核心逻辑在后端,通过界面去验证纯属绕远路。
我还经常用一句话反问团队:这条用例如果没有了,你晚上敢安心睡吗?不敢的才写进 e2e,敢的放过。很多团队失败,就是因为把“不敢睡的”和“其实无所谓”全写进了 e2e,结果核心链路被海量边缘用例拖垮。
2.2 从测试金字塔看E2E的位置
测试金字塔是敏捷社区里老生常谈的概念,但落到 e2e 里尤其重要。金字塔底层是单元测试,数量最多;中间是接口/组件测试;顶层是 e2e,数量最少。你可以把 e2e 看成“最后的守门员”,而不是“唯一的守门员”。
如果整个项目只有完整流程的 e2e 测试,那一旦失败,要从 UI 层面逐层推断是前端问题、后端问题还是数据问题,效率非常低。反过来,如果你有大量单元测试和接口测试,它们会把问题定位到具体模块,e2e 只需要承担“按用户视角走一遍,确认各模块协作没问题”的职责。这套分工不是某个组织规定的,而是由成本和反馈速度决定的。
所以你在设计 e2e 用例时,要经常问自己:这个场景的核心断言,能不能用接口测试更稳定地覆盖?如果能,那就别让它进 e2e。E2E 只做那些“只有从用户入口走一遍才能验证”的事,比如页面交互、状态流转、跨系统协作。这样你的 e2e 用例数量会急剧下降,每一条的含金量却会大幅上升。
2.3 E2E Profile1:一个更可执行的“用例分组”思路
我平时在团队里常用“profile”来组织 e2e 用例。这里的 profile 不是性能测试里的 profile,也不是用户画像,而是“一套用例分组配置”。有人第一次听到我提 e2e profile1 时有点懵,其实用大白话讲,它就是“第一套跑得最勤的 e2e 用例组合”。
我会把 e2e 用例分成几个 profile:profile1 通常对应“核心冒烟集”,在每次代码合并、每天凌晨都跑,数量最少、运行时间短、稳定性要求最高;profile2 可以对应“全量回归集”,在发版前跑,数量多、跑得慢,允许有一部分需要人工确认的边界场景;甚至可以有 profile3,专门跑跨系统、跨第三方服务的场景,放在夜间执行。
这么设计有什么好处?因为一条 e2e 用例的“运行频率”不一定等于它的“重要性”。核心冒烟集可能只有 20 条,但每次提交都跑,能第一时间挡住明显的主流程破坏。全量回归集可能有 200 条,如果也每次提交都跑,团队会等到崩溃。用 profile 把用例切成不同执行节奏,比单纯用 tag 标记“冒烟/回归”更好落地,因为它把“什么时候跑哪些用例”变成了一套可执行、可调度的配置。
2.4 场景拆分示例
为了让“profile”不悬空,我给你一个实际拆法。假设一个 B2B 后台系统,用户核心链路是:登录后台、切换项目、创建工单、指派负责人、关闭工单、查看报表。
那么 profile1 核心冒烟集可以只写 6 条:登录成功、登录失败提示、创建工单主流程、工单状态流转、报表页面能加载、退出登录。每条都在 3 分钟内能跑完,总时长控制在 20 分钟以内。
profile2 全量回归集则在上述基础上补充分页查询、权限校验、批量导入、不同浏览器的兼容场景。这些用例不需要每次提交都跑,但在发版前必须全绿。这样拆分之后,profile1 的高频执行不会拖累团队节奏,profile2 的全面覆盖又不会丢失。
3. 实操:搭一套能落地的主链路E2E(含代码)
讲完设计,不落地的 e2e 都是空谈。这一节我用目前我比较常用的 Playwright 来做示范,因为它在多浏览器支持、自动等待和调试体验上做得比较均衡。如果你团队已经在用 Cypress 或 Selenium,思路完全通用,不必纠结非要换工具。
3.1 技术选型:Playwright、Cypress、Selenium怎么选
选型是我被问得最多的问题。我的判断依据很简单:团队技术栈、应用形态、以及你需要的测试类型。下面这张表是我常用的对比方式:
| 工具 | 优势 | 常见痛点 | 适合场景 |
|---|---|---|---|
| Playwright | 多浏览器同构、自动等待、支持多标签/多用户、内置 trace 和视频录制 | 学习资料相对没有 Selenium 历史悠久 | 中大型 Web 应用、需要跨浏览器或并行执行 |
| Cypress | 语法友好、调试体验好、Time travel 方便 | 默认只支持自家浏览器体系、多标签支持有限 | 前端团队为主、单页应用、偏组件与交互 |
| Selenium WebDriver | 生态最老、语言绑定多、兼容性广 | 异步稳定性要自己多费心、脚本写起来啰嗦 | 已有大量 Selenium 资产、或强绑定老框架 |
如果让我给新项目一个默认答案:Web 端且没有历史包袱,我会选 Playwright。倒不是说 Cypress 不好,而是 Playwright 的多浏览器和并行能力在 e2e 这种“重流程”测试里更省心。尤其是 profile1 这种要高频执行的用例集,能多浏览器并行跑是很大的时间红利。
3.2 项目初始化与基础配置
先创建一个空目录,初始化 npm 项目,并安装 Playwright:
mkdir e2e-project && cd e2e-project npm init -y npm install -D @playwright/test npx playwright install安装完成后,创建一个playwright.config.ts,这是所有 profile 和浏览器项目配置的入口。下面这份配置只保留了最核心的部分:
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 60_000, expect: { timeout: 10_000, }, fullyParallel: true, retries: process.env.CI ? 2 : 0, reporter: [['html', { outputFolder: 'playwright-report' }]], use: { baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, ], });几个点需要重点说明。fullyParallel: true表示测试文件之间并行执行,这能大幅压缩 profile1 的执行时间,但对测试数据隔离有要求,后面我会讲怎么处理。retries只在 CI 环境开 2 次,本地不开,因为本地重试会掩盖脚本问题。trace和video一开始就打开,会帮你省下大量排查 flaky 的时间。
3.3 写第一个E2E用例:登录→创建订单→校验结果
现在写一条真正的 e2e 用例。我习惯把 profile1 用例放在tests/profile1/目录下,目录本身就是第一个分组信号。看一下这条用例:
import { test, expect } from '@playwright/test'; test.describe('profile1 - 核心冒烟集', () => { test('用户登录后可以创建订单', async ({ page }) => { await page.goto('/login'); await page.getByLabel('邮箱').fill('test@example.com'); await page.getByLabel('密码').fill('password123'); await page.getByRole('button', { name: '登录' }).click(); await expect(page.getByText('你好,测试用户')).toBeVisible(); await page.goto('/orders/new'); await page.getByLabel('商品名称').fill('无线键盘'); await page.getByLabel('数量').fill('2'); await page.getByRole('button', { name: '提交订单' }).click(); await expect(page.getByText('订单创建成功')).toBeVisible(); await expect(page).toHaveURL(/orders\/\d+/); }); });你会注意到我全篇没有写sleep,也没有写固定的waitForTimeout。关键断言都用了toBeVisible和toHaveURL,它们会自动重试直到超时。这就是 Playwright 的 auto-waiting:你不用手动去猜“这个接口要 2 秒还是 3 秒返回”,框架会等到元素出现。
选择器我也刻意避开了.class-name这种会随重构随意变化的写法。getByLabel和getByRole是站在用户使用习惯上找元素,而不是站在 DOM 实现细节上找元素。如果开发把输入框外面的标签文案改了,用例会红,这是合理的;但如果开发只是把内部 class 从input-wrapper改成了input-container,用例不会红,这才是稳定的。
3.4 用Profile1跑通“核心冒烟集”
现在我们有了 profile1 这个分组,怎么让它真正变成一条可执行命令?最简单的做法是在package.json里加几个脚本:
{ "scripts": { "test:e2e:profile1": "playwright test tests/profile1 --project=chromium", "test:e2e:profile2": "playwright test tests/profile2 --project=chromium", "test:e2e:all": "playwright test" } }如果你不想用目录区分,也可以用标签。比如在用例名上写@profile1,然后运行:
npx playwright test --grep "@profile1"我更推荐用目录区分 profile。理由很简单,当你用--grep筛选用例时,所有用例文件都必须先加载到同一个测试上下文里,profile2 里那些慢用例的初始化配置多少会干扰 profile1。目录隔离之后,每个 profile 可以有自己的 setup、自己的辅助函数,互不污染。
跑完一次 profile1,你会得到一份 HTML 报告。如果某条用例失败,报告里能看到失败那一步的截图、视频,以及包含接口请求和 DOM 操作快照的 trace。不要小看这些调试数据,它们就是 e2e 稳定性的第一道保险。没有这些,排查一条偶发失败可能要一上午;有了这些,十分钟就能定位到是哪个网络请求超时了。
4. E2E稳定性的关键:等待、选择器与测试数据
E2E 最大的敌人不是 bug,而是“明明功能没问题,测试却红了”。这类情况统称为 flaky。我做了几年 e2e,总结下来,大部分 flaky 都逃不开三个层面:等待策略写得不对、选择器绑得太死、测试数据没有隔离。
4.1 不要用固定sleep,用自动等待
最常见的新手写法是:
await page.waitForTimeout(3000); await page.click('#submit');这段代码背后的潜台词是“我等 3 秒,接口总该回来了吧”。问题是,这 3 秒在本地可能够用,在 CI 的慢机器上可能不够用,在接口偶发抖动时更不够用。于是你只能不断调大这个数字,从 3000 改成 5000,再改成 8000,最后整个用例一小半时间都在空等。
正确做法是让等待绑定“某个状态”。比如等按钮变成可点击、等加载提示消失、等某个关键文案出现。Playwright 的toBeVisible、toHaveText、toHaveURL这些断言本身就是等待策略,它们会以 poll 的方式不断检查,直到条件满足或超时。这样用例的耗时随应用实际响应速度浮动,而不是被一个拍脑袋的固定数值锁死。
顺便提醒一句:不建议把总体超时时间设得过大。我见过有人把每个用例的 timeout 设成 5 分钟,结果一个真正的性能回退问题拖了 5 分钟才暴露。60 秒对大多数 Web 主流程来说已经非常充裕。
4.2 选择器怎么写得不容易碎
选择器是 e2e 脚本里最让人头痛的一环。我见过用#root > div > div:nth-child(2) > button这种路径写的用例,后端一升级样式库,就全员爆红。CSS 选择器说白了就是“DOM 结构地址”,只要前端组件的嵌套层级一变,地址就失效了。所以我的建议是:优先用语义化选择器。
顺序大概是这样的:能找 label 就用getByLabel,能找按钮文案就用getByRole('button', { name: ... }),实在没有就用getByTestId。getByTestId需要开发在关键元素上主动加>name: e2e-profile1 on: pull_request: paths: - 'apps/**' - 'packages/**' - '.github/workflows/e2e.yml' jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 - name: Install dependencies run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Start app run: npm run start:test & env: NODE_ENV: test - name: Run profile1 run: npx playwright test tests/profile1 --project=chromium
这个配置只装了 chromium,因为 profile1 追求的是“快”,而不是“覆盖所有浏览器”。如果你要求兼容,可以把 firefox、webkit 都装进一个 job,或者拆成三个并行 job。一般来说,profile1 只跑 chromium,profile2 才跑多浏览器矩阵,这是成本和覆盖之间的平衡。
5.2 并行分片、失败重试、报告汇总
E2E 最怕的就是跑得太慢。比如 profile2 有 200 条用例,串行跑,平均每条 1 分钟,就是三个多小时。这个时间在发版等待期里太致命了。所以执行效率几乎是 e2e 工程化程度的直接体现。
Playwright 支持分片执行,把同一份用例按比例切给多台机器并行:
npx playwright test --shard=1/4 npx playwright test --shard=2/4 npx playwright test --shard=3/4 npx playwright test --shard=4/4在 CI 里把它配置成四个并行 job,总时长就从原来的三小时压缩到四十五分钟左右。如果还不够,再配合fullyParallel: true在单台机器内多线程跑用例,可以进一步压缩。
还有一个小细节:报告里一定要包含失败用例的 trace 和视频。默认配置trace: 'on-first-retry'只会在第一次重试失败时录 trace,含义是“没重试就失败说明问题足够稳定可复现,不需要录也能查;重试了还失败,说明存在环境性或波动性问题,我需要更详细的数据”。这个配置不是随便定的,它是 trace 文件体积和排查能力之间的平衡点。如果空间允许,也可以直接改成on,但报告体积会大不少。
报告汇总建议用 Playwright 官方 HTML reporter 或接入 Allure。HTML report 对团队沟通最友好,点开就能看到测试步骤、失败截图、网络请求和 console 报错。至少要做到:开发收到一条失败通知时,不需要跑一遍复现就能判断“这是不是我的改动造成的”。
6. 做了几年E2E,我的几个真实体会
前面讲了方法、代码和流程,最后聊点个人感受。这些体会写在任何官方文档里都显得有点“软”,但对长期维护 e2e 的团队来说,可能比工具技巧更重要。
6.1 E2E不是越多越好
我见过最夸张的一个项目,e2e 用例超过 2000 条,每次全量跑要 6 个小时。维护它的测试团队每天上班第一件事就是修昨天的失败用例,修到周末又刷出几十条新失败。问题出在哪里?出在“把 e2e 当成了万能测试”。真正该做的是把大部分业务验证下沉到接口测试和单元测试,e2e 只保留用户可感知的核心链路。少即是多,这条定律在 e2e 上体现得比任何其他测试层都明显。
6.2 E2E Profile1应该跟着发布计划走,而不是跟着用例数量走
我后来慢慢发现,profile1 的组成不应该一成不变,它应该随着产品阶段和发布计划调整。新功能上线前,profile1 可以加入那条“新用户从注册到完成首单”的用例;功能稳定后,这条用例又可以降级到 profile2。永远维持 profile1 在一个“太慢不加、太脆不加、太边缘不加”的状态,它不是用例库的缩减版,而是发布风险清单的自动化表达。
6.3 E2E的结果要为下一次迭代服务
很多时候我们只关注“红了还是绿了”,却忽略了“红是脚本问题还是业务问题”。如果红的是业务问题,这就是一次真实的 bug 发现;如果红的是脚本问题,说明测试设计和环境治理还有改进空间。所以我在团队里有一个不成文的约定:每条 e2e 失败都必须写一个一句话结论,要么“产品 bug,已提单”,要么“测试脚本问题,已修复”,要么“环境问题,已处理”。连续一个月统计下来,如果第二条占比超过一半,那说明不是用例太脆,而是你的 e2e 稳定维护机制需要升级了。
E2E 是个门槛不高但天花板很高的领域。从“全流程点一遍”到“设计 profile 并让它在 CI 里稳定护航”,中间隔的不是工具熟练度,而是对业务风险的理解。如果你正准备从零起步,先别急着写用例,按这篇的思路把核心链路圈出来,把 profile1 跑稳,你会在一次真实的发布事故里体会到这份投入的价值。