做了这么多年测试,我发现自己越来越难跟新人解释清楚什么叫做 e2e。说简单也简单,端到端三个字就完了;说麻烦也麻烦,因为 e2e 里藏着的坑,远比大多数人想象的多。今天想聊的,恰好是我最近在一套新项目里重新落地 e2e 自动化时的一整套思路,包括那个经常出现在配置文件里的 e2e profile1,到底该怎么理解、怎么用,以及真正跑起来之后会遇到哪些破事。
这篇文章不是我抄文档,也不打算给你一段能点亮的PPT。我讲的每一块都来自实际工程里的取舍,适合那些准备从零搭建 e2e 测试、或者已经在写但总觉得不稳的团队参考。无论你是测试开发、前端工程师,还是被交付逼着补自动化的人,应该都能从里面捞出一点能直接用起来的东西。
1. 万物皆为e2e:先从“端到端”说起
1.1 为什么必须做端到端测试
很多团队其实是被动开始做 e2e 的。单元测试覆盖率已经堆得挺高,接口测试也天天在跑,但每次上线前,核心业务流程还是得靠人肉点一遍。于是 e2e 被提上了日程,本质诉求就一句话:让机器替我走一遍真实用户的路。
但很多人没想明白一件事,e2e 不是为了追 KPI,也不是为了在技术汇报里多一个亮眼的词。它真正解决的是“局部正确、整体翻车”的问题。单元测试只能证明一个函数输入输出对得上,接口测试只能证明某一个服务之间的交互没问题,可用户的视角是完整的:登录、搜索、点详情、加购物车、结算、付款,中间任何一环的颜色不对、按钮被遮挡、接口字段对不上,用户感受到的就是“整个产品坏了”。
这种视角只有 e2e 能给。它用的是用户的操作路径,站在离产品最近的位置去检查系统是否真的可用。所以我的看法一直很明确:单测保证零件没坏,e2e 保证整机能跑,两者不是替代关系,而是互补关系。
1.2 “e2e profile1”到底是个啥
再说说“e2e profile1”这个说法。第一次接触到这个词的同事大概率会愣一下:e2e 我知道,profile 我也知道,拼一起怎么就有点看不太懂了。
其实这里的 profile 指的是“一套端到端运行环境所对应的配置画像”。profile1 就是第一个、也是基础的一套端到端配置。你可以把它理解成一份“哪些环境、哪种用户身份、哪组测试数据”的组合快照。比如在同一个项目里:
| profile 名称 | 目标环境 | 运行账号 | 测试数据组 | 典型用途 |
|---|---|---|---|---|
| profile1 | staging | tester01 | 基础商品集合 | 日常回归 |
| profile2 | staging | tester02 | 促销商品集合 | 活动验证 |
| profile3 | production | smoke_user | 线上最小集 | 发布后冒烟 |
我后来在这个项目里的 profile1,就承担了“最基本的日常回归”职责:用固定的测试账号、固定的测试商品、固定的网络拦截规则,跑一条最核心的购买链路。为什么这么设计?因为测试用例跑得越多久越容易受环境波动影响,把最核心的路径固定下来,价值通常是最高的——真出问题的时候,它能在前 5 分钟就告诉你系统是好的还是坏的。
1.3 端到端测试与其他测试层级的分工
我见过很多项目把 e2e 当成“万能药”,写完几个用例就觉得质量无忧了。这种心态只会让你失望。e2e 有个最难受的特点:它跑得慢、依赖多、还容易受外部环境影响。你要是把所有鸡蛋都放进 e2e 这个篮子里,等 CI 跑到第 20 分钟挂掉的时候,光排查环境问题就能耗掉你一上午。
所以正确的做法是先分清楚测试金字塔里每一层该干什么。单元测试管的是逻辑细节,接口测试管的是服务契约,e2e 管的是用户主流程。我对团队的要求是:能用单元和接口解决的事,绝不上 e2e;只有像登录、下单、支付这种多模块串联的场景,才值得用 e2e 去守。
这样一分工,e2e 用例的数量不会多,但每一条都足够重。我始终认为,e2e 的价值不在于多,而在于准。跑一遍能覆盖住用户最核心的 10 条路径,比堆 100 条互不相关的零散用例有用得多。
2. 工具选型与整体设计
2.1 Playwright、Cypress、Selenium怎么选
选工具这件事,我前后折腾过三轮。最早一批写 e2e 用的是 Selenium,那时候生态少,没得选。Selenium 的问题是慢、不稳定,而且 WebDriver 那套对现代前端应用越来越力不从心。后来 Cypress 火起来,上手确实快,API 也很友好,但它的多标签页、跨域处理、以及无法自由操作原生事件这些限制,到了复杂业务里会让人无语。
现在我的默认选项是 Playwright。不是因为它最流行,而是它解决了我在立项初期最担心的几个实际问题:
- 它自带的自动等待机制比手写 sleep 优雅太多,默认情况下 Playwright 会等元素可操作再继续。
- 多浏览器并发跑同一套用例是原生支持的,Chromium、Firefox、WebKit 都能跑。
- 支持拦截网络请求、修改响应、模拟移动端设备,这些在业务里特别常见。
- 每个测试用例自带独立的浏览器上下文,天然隔离数据。
当然,工具没有绝对的好坏。小团队快速验证业务逻辑,Cypress 依然很香;如果你要的是更底层的控制和更完整的自动化能力,Playwright 更合适。我这次的项目选的就是 Playwright,配的是 TypeScript,这样配置和用例都能有类型提示,改起来不容易手滑。
2.2 搭一个能跑起来的E2E工程
工程化是 e2e 能不能长期跑下去的关键。很多人第一步就输了:在项目里手动开一堆浏览器,一个个点过去,然后把截图贴在群里,说自己做了 e2e 自动化。这确实能跑,但没法复用、没法回归、没法集成到 CI,等于白做。
我习惯的工程结构是这样拆的:
e2e/ ├── config/ │ ├── playwright.config.ts │ └── profiles/ │ ├── profile1.ts │ ├── profile2.ts │ └── index.ts ├── fixtures/ │ └── extended-fixture.ts ├── pages/ │ ├── login.page.ts │ ├── product.page.ts │ └── checkout.page.ts ├── tests/ │ ├── login.spec.ts │ ├── purchase.spec.ts │ └── smoke.spec.ts ├── utils/ │ ├── api-helper.ts │ └── db-cleaner.ts └── reports/pages 目录放页面对象,把页面上的定位器和操作封装成方法,比如loginPage.login(user, password)。这样测试用例里不会出现一堆page.locator的细节,改版只改 page 层就够了,这是页面对象模式的核心价值:把变化挡在用例之外。
fixtures 目录也很重要。Playwright 的 fixture 机制可以在每个用例前注入自定义上下文,比如带上数据库清理动作、准备测试数据的 API 调用。我发现很多人忽略这个,导致用例之间互相残留数据,跑两遍就挂了。
2.3 profile1文件的组织思路
这份 profile1 配置,最终长下面这个样子。我不想把它复杂化,但它承载的信息确实比普通配置文件多一些:
export const profile1 = { name: 'profile1', baseURL: 'https://staging.internal.example.com', users: { buyer: { username: 'e2e_buyer_01', password: 'e2e_buyer_01_pwd', }, }, data: { product: 'SKU-FIXED-1001', coupon: 'E2E_COUPON_100', }, network: { blockedHosts: ['*.analytics.example.com', '*.ads.example.com'], }, launchOptions: { headless: true, viewport: { width: 1280, height: 720 }, }, };你在 profile1 里看到的这些字段,并不是随便拍的。baseURL 指向 staging 是为了避免在正式环境上搞乱数据;blockedHosts 拦截第三方埋点和广告,是为了减少外部依赖对测试的干扰,让结果更可控。
把这类静态信息抽到 profile1 里,最大的好处是换环境、换账号、换测试数据时不需要改测试代码。你只需要在 CI 里传一个环境变量E2E_PROFILE=profile1,代码里做一次动态读取,就能在一套代码里跑多套场景。
3. 实操过程与核心环节实现
3.1 初始化项目和依赖
下面我直接把这次落地的完整过程拆开讲,照着抄就能跑。
先初始化目录:
mkdir e2e-demo && cd e2e-demo npm init -y npm install -D @playwright/test npx playwright install --with-deps--with-deps这个参数我第一次跑的时候没加,结果在 Linux CI 机器上缺了一堆系统库,折腾了半小时。这个参数会顺手把浏览器依赖的系统包装好,建议从一开始就带上。
然后写playwright.config.ts:
import { defineConfig } from '@playwright/test'; import { profile1 } from './config/profiles/profile1'; export default defineConfig({ testDir: './tests', timeout: 60_000, fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [['list'], ['html', { outputFolder: 'reports' }]], use: { baseURL: profile1.baseURL, headless: true, screenshot: 'only-on-failure', video: 'retain-on-failure', trace: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { browserName: 'chromium' }, }, { name: 'firefox', use: { browserName: 'firefox' }, }, ], });有几个细节值得展开。screenshot: 'only-on-failure'和video: 'retain-on-failure'平时看着没啥用,但用例挂了之后这些资产能帮你快速定位是页面崩了还是断言错了。retries在 CI 里设成 2 是一种常见策略,允许偶发环境问题自动重试,但本地调试时设成 0,避免掩盖真实错误。
3.2 编写首个E2E用例
核心用例我选了一条“登录 → 搜索商品 → 加购物车 → 结算 → 确认订单”的完整链路。这条链路基本覆盖了一个电商用户最自然的购买路径,也最能体现 e2e 的价值。
先写一个页面对象,把“登录页”封装起来:
import { Page } from '@playwright/test'; export class LoginPage { constructor(private page: Page) {} async goto() { await this.page.goto('/login'); } async login(username: string, password: string) { await this.page.getByLabel('用户名').fill(username); await this.page.getByLabel('密码').fill(password); await this.page.getByRole('button', { name: '登录' }).click(); await this.page.waitForURL('**/home'); } }注意这里我用的都是getByLabel、getByRole这类基于可访问性的定位方式,而不是#username这种脆弱的 CSS 选择器。为什么?因为按钮的文案变了、label 和别人不一样了,测试会立刻挂掉,这其实是好事——说明 UI 的可访问性出了问题。而 id 变了这种事,跟用户可感知的行为无关,不该让 e2e 在意。
然后写主用例:
import { test, expect } from '@playwright/test'; import { LoginPage } from '../pages/login.page'; import { profile1 } from '../config/profiles/profile1'; test('用户可完成一次标准购买流程', async ({ page }) => { const { buyer } = profile1.users; const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login(buyer.username, buyer.password); await page.getByPlaceholder('搜索商品/品牌').fill('保温杯'); await page.getByRole('button', { name: '搜索' }).click(); await page.getByText('不锈钢保温杯 500ml').click(); await page.getByRole('button', { name: '加入购物车' }).click(); await page.getByRole('button', { name: '去结算' }).click(); await page.getByRole('button', { name: '提交订单' }).click(); await page.getByRole('button', { name: '确认支付' }).click(); await expect(page.getByText('支付成功')).toBeVisible(); await expect(page).toHaveURL(/order-success/); });写这个用例的时候我一直在克制“把所有页面都封装成 Page Object”的冲动。如果只是一次点击、一个断言,直接写在用例里反而更流畅。页面对象的价值在于复用,而不是把每个动作都裹一层。
3.3 网络、登录态和接口mock的实战处理
e2e 绕不开三个实际问题:外部请求怎么隔离、登录状态怎么复用、接口不稳定怎么办。这次我处理方式如下。
拦截第三方请求。在 profile1 里已经配了 blockedHosts,配置如下:
use: { ..., launchOptions: { headless: true, }, // 在 config 里启用路由拦截过滤 // 业务代码里也可以用 page.route 做更精细的控制 }但要注意,Playwright 推荐在测试里用page.route做更精细的拦截。比如把埋点请求全部 abort 掉:
await page.route('**/*.analytics.example.com/**', route => route.abort());登录态复用。如果你每个用例都重新走一遍登录流程,那 50 个用例你就登录了 50 次,慢不说,还容易被风控系统盯上。常见的解法是用storageState:先跑一次登录,把登录后的 cookies 和 localStorage 存成文件,后续用例直接复用。
// 登录并保存状态 test('存储登录态', async ({ browser }) => { const context = await browser.newContext(); const page = await context.newPage(); await page.goto('/login'); await page.getByLabel('用户名').fill(profile1.users.buyer.username); await page.getByLabel('密码').fill(profile1.users.buyer.password); await page.getByRole('button', { name: '登录' }).click(); await page.waitForURL('**/home'); await context.storageState({ path: 'config/state/buyer.json' }); });然后在 config 里给每个项目指定:
use: { storageState: 'config/state/buyer.json', }这样用例短了一大截,稳定性和速度都提升了。但要注意,登录态文件不能提交到 Git 仓库,尤其是生产环境的,毕竟那是真实凭证。
接口 mock。如果某个第三方接口在 staging 环境经常超时,那它就不该成为 e2e 的拦路虎。我习惯的做法是把可变的、外部的接口用page.routemock 掉,让测试更关注前端行为和主流程:
await page.route('**/api/v1/promotion/list', async route => { await route.fulfill({ json: { result: [], status: 0 } }); });3.4 跑通、报告和CI集成
本地跑一遍:
npx playwright test --project=chromium如果只想临时看某个用例:
npx playwright test tests/purchase.spec.ts --headed --debug跑完 HTML 报告在reports目录里,直接npx playwright show-report就能打开。报告里能看到每一步的状态、DOM 快照、网络请求和失败时的 trace。我一般先看 trace,因为它是时间线视图,能一眼看出操作到哪一步开始不对劲。
CI 集成我用的是 GitHub Actions,核心步骤就一个:
name: e2e-test on: workflow_dispatch: schedule: - cron: '0 2 * * *' jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npx playwright install --with-deps - run: npx playwright test - uses: actions/upload-artifact@v4 if: always() with: name: report path: reports retention-days: 7我建议让 e2e 作为 nightly 任务和发布前的预检任务,不一定要挂在每个 commit 上。原因是 e2e 慢,跑一条核心链路就要一两分钟,全量跑下来不是小数。挂太频繁会拖慢研发节奏,反而不利于坚持。
4. 常见问题与排查技巧实录
4.1 元素定位不稳定
定位不稳定是 e2e 新手遇到最多的坑。今天能过,明天就挂;本地能过,CI 就挂;Chrome 能过,Firefox 就挂。根本原因通常不是测试写错了,而是定位器选错了目标。
经验法则如下:
| 情况 | 推荐方案 | 不推荐方案 |
|---|---|---|
| 有文字内容的元素 | getByRole('button', { name: '提交' }) | page.locator('.btn-submit') |
| 表单项 | getByLabel('手机号') | page.locator('input') |
| 不稳定的动态文案 | getByTestId('order-id') | 正则匹配变化内容 |
| 列表项 | 用filter结合可见文本 | 用nth(2)依赖顺序 |
我特别想强调一点:>await page.waitForTimeout(5000);
这个写法我是真不建议。它治标不治本,还会让执行时间无限膨胀。假设一个用例里写了三次waitForTimeout(5000),那你一次回归就多花 15 秒。十个用例就是 150 秒,代价太大了。
Playwright 的定位器默认有超时等待,比如click、fill这些动作,都会自动等到元素“可操作”。我们应该利用这种机制,而不是绕过它。真正需要手动等待的场景只有这些:页面跳转后的 URL 变化、自定义弹出的异步请求、图表加载。
在这些场景里,优先使用:
await page.waitForURL('**/home'); await expect(page.getByText('加载完成')).toBeVisible(); await page.waitForResponse('**/api/v1/order/list*');如果全部用了这些策略仍然偶发超时,再去调testTimeout和actionTimeout。我这次项目的基准值给的是:测试超时 60 秒,单个动作超时 15 秒。
4.4 环境异构与profile配置的坑
环境异构问题在 e2e 里属于“不跑不知道,一跑吓一跳”的类型。同样一套用例,在本地 Mac 上很正常,到 Linux CI 上就开始抽风;同样一个选择器,在 staging 上稳定,在 production 上却找不到元素。
这里有一个我特别想强调的体会:不要把不同环境之间的差异都靠“写代码兼容”去抹平。比如 staging 环境有测试造的假支付单,production 环境没有,这种差异最好通过数据构造去解决,而不是在断言里做一堆if (env === 'prod')的分支。
profile1 最大的价值在这里:它把环境差异集中到一个文件里去管理。环境地址、账号、测试商品、可用优惠券,全部通过 profile 去切换,测试代码不做分支判断。这样所有用例读到的信息是一致的、可预测的。
美中不足的是,profile1 还远不是银弹。有个绕不开的问题是 staging 数据和 production 数据结构不一致时,同一个用例在这两个环境的断言必须不同。这种情况我建议拆用例,而不是硬写兼容逻辑。e2e 最忌讳“写得花哨”,它需要的是老老实实、步骤清晰。
5. 一些经验心得与后续扩展
5.1 我踩过的坑
第一个坑:试图把 e2e 用例写得“极其干净”,以至于封装了太多层。最后调试的时候,从定位器到方法名,翻了好几层文件才找到真正断言的语句。现在我要求团队:用例正文里的每一步,最多只能嵌套一层页面对象,再往下的细节都应该是可忽略的。
第二个坑:直接把生产环境暴露给 e2e 跑。有一次我以为自己加的环境变量是对的,结果一条用例在 production 上创建了一堆真实订单,最后人工清了两天才清干净。从那时候起,我对 e2e 的去向要求变得非常严格:无特殊许可,只允许跑 staging 和专门的测试环境。
第三个坑:忽略了报告留存。一开始跑失败用例,我只能凭控制台里的几行英文去猜原因。后来启用了 trace 和 video,再配合 HTML 报告,排查时间直接缩了一半。现在我连新人的培训素材都从失败的 trace 里挑,比讲文档有用多了。
第四个坑:把 e2e 用例当成一次性测试脚本写,而不是当成长期资产维护。前三个月还好,半年后前端改了样式、改了文案,用例就成片挂掉。后来我把“e2e 用例要像产品代码一样对待”写进了团队规范里,要求 MR 里改 UI 时同步更新对应测试,这才稳下来。
5.2 可以往哪条线继续深挖
如果上面的内容你已经消化得差不多,这里有几条扩展路线可以接着玩。
第一条线:把 e2e 和性能监测结合起来。Playwright 能拿到页面性能条目,比如 TTFB、LCP。可以在核心购买流程跑通后,顺手断言 LCP 小于某个阈值,这样每次回归都能顺带发现性能回退。不追求精准的基准测试,但当个长周期预警是够用的。
第二条线:从端到端向“全链路质量看板”扩展。把 e2e 结果、接口测试结果、单元测试覆盖率聚合到一个报告里,按天、按周统计。这样谁改动引入了问题,第二天早上就能在数据上看到信号。
第三条线:把 e2e 的“影子模式”跑起来。也就是用生产环境的流量来触发测试路径,不影响真实数据的判定,这种方式在高级别的稳定性建设里很常见。但它的复杂度相当高,没有专人维护的话慎入,更适合团队有成熟基础设施之后再推进。
做 e2e 这件事,我的心态经历过几轮变化:一开始觉得它是个可有可无的任务,后来发现它是守住用户主流程的底线,再后来意识到它本质上是一套需要持续经营的测试资产。它不会因为你写了 50 个用例就自动变得有用,只会因为你的用例覆盖了真实用户的核心路径,并且每条都能稳定、快速、可诊断地运行,才真正体现出价值。
最后分享一个小技巧:每隔一段时间,把 e2e 用例的执行耗时列出来,按耗时从高到低排序,然后把最慢的几条逐个拆解,看是等待问题、数据准备问题还是断言设计问题。我每次做这件事都能揪出几处可以优化的地方,优化完整体执行时间能降不少。e2e 的价值不只体现在抓 bug 上,它也是推动整个工程质量改进的一个杠杆点。