聊到 e2e,可能一千个测试工程师心里有一千种看法。有人觉得它不过是把用户最长的旅程在浏览器里跑一遍,有人觉得它是自动化测试里最容易崩盘的部分。最近我在整理测试工程时,看到一份命名为 e2e profile1 的配置,里面写着环境地址、账号信息、浏览器矩阵和一整套测试标记。这让我想起很多团队对 e2e 的态度:想上,又不敢上。这篇文章就结合我的实际经历,聊聊 e2e 测试到底怎么搭、怎么配、怎么跑,尤其想拆一下这种基于 profile 的配置方式,看看一份看似简单的配置背后藏着哪些关键决策。如果你是刚接手公司自动化测试体系、或者正在纠结 Playwright 和 Cypress 怎么选,这篇应该能给你一些可复用的思路。
1. 先想清楚:e2e到底要解决什么问题
1.1 端到端测试和单测、接口测差在哪
很多人刚接触 e2e 时,会把它理解为“在页面上点一圈”。其实它本质上是把整个业务闭环拉起来验证:用户从浏览器页面发起操作,经过前端路由、接口调用、后端服务、数据库读写,最后回到页面展示,任何一个环节出错,这条链路都会断。
单元测试验证的最小单元往往是函数或组件,它只关心“输入 A 能不能得到输出 B”;接口测试关心的是“请求 X 能不能拿到正确返回的 Y”。这两层测试能挡住很多低级错误,但挡不住模块之间的协作问题。我举一个真实例子:接口层返回的数据格式是对的,但前端在某个浏览器版本里解析 Date 对象时出现了时区偏移,导致用户在界面上看到的日期比实际少了一天。单测跑的是模拟数据,接口测跑的是真实接口,但只有 e2e 会真的用 Chromium、Firefox 各跑一遍完整页面流程,把这类问题暴露出来。
但 e2e 也是最容易“翻车”的测试。它依赖的东西太多:测试环境是不是干净、账号数据是不是隔离、第三方服务是不是稳定、浏览器进程会不会被系统杀掉。稍有风吹草动,用例就红给你看。所以团队里对 e2e 的常见评价是“投入大、产出不稳定”,但换个角度想,如果做到稳定,它带来的价值同样也是其他层级测试给不了的:你可以在发版前一个小时,用一条命令确认核心业务还能跑。
1.2 “e2e profile1”这个命名背后藏了哪些信息
我看到的这个 e2e profile1,并不是某个工具的强制要求,而是团队的一种配置约定。你可以把它理解成“一套完整的端到端测试场景快照”。它一般包含环境信息、登录账号、测试数据、要跑的浏览器、以及重试策略等。
为什么要用 profile 这个概念?因为它解决的是多环境、多场景并存的问题。同一个 e2e 工程,你不可能只在测试环境跑,还得跑预发环境、生产冒烟环境;也不可能只有一套账号,有时需要普通用户,有时需要管理员,有时需要新注册用户。如果把这些硬编码到测试用例里,每换一个环境就要改一遍代码,维护成本非常高。
配置驱动的思路是:把“跑什么环境、用哪个账号、测哪些浏览器”这些变量从代码里抽出来,放到 profile 文件里。当你执行E2E_PROFILE=profile1 npx playwright test时,测试工程会自动加载对应的配置。这样一来,测试用例代码不需要感知具体的环境地址和账号密码,它们只需要拿到一个 baseURL 和一组登录态即可。
profile1 如果对应的是“冒烟场景”,那么里面可能配置了 fastest 的环境地址、核心账号、Chromium 单浏览器、失败重试 2 次、只跑带 @smoke 标记的用例。profile2 如果对应的是“全量回归”,则可能换成完整浏览器矩阵、多个账号、更长超时时间。这种用配置区分场景的方式,特别适合维护一套代码跑多种场景,省掉重复造轮子。
2. 核心细节:测试工程的关键组成与配置设计
2.1 框架选型:为什么我最后选了 Playwright
先聊工具。E2E 框架这这几年变化很大,Selenium 属于老牌选手,Cypress 靠开发体验火过一阵,Playwright 则是后起之秀。我三个都实际用过,说下自己的体感。
Selenium 足够灵活,支持 Java、Python、JS 等各种语言,但它的 WebDriver 架构决定了你写定位、等待、跨浏览器兼容需要大量额外努力。尤其是隐式等待和显式等待混在一起时,那种“测试时好时坏、没人敢改”的痛,踩过的都懂。Cypress 的调试体验确实惊艳,可以在时间轴上回放每一步,但早期它对多标签页、iframe、多浏览器的支持有限,当时我在一个需要操作两个页签的业务场景里被卡了很久。Playwright 解决了这些问题:自动等待让“sleep(3 秒)”这种临时方案几乎可以绝迹;每个测试用例独立开浏览器上下文,隔离性做得很好;一次 API 调用就能拿到 Chromium、Firefox、WebKit 三套浏览器。
当然,工具没有绝对好坏,得看团队现有技术栈。如果团队已经有一套 Cypress 用例跑得不错,没必要为了追新而迁移。但从零搭建一个新工程,我更愿意推荐 Playwright。它提供了 codegen 录脚本、trace 查看器、UI 模式调试这些能直接提升效率的能力,最重要的是:它对“并行执行”和“失败重试”的支持很成熟,这恰恰是 E2E 稳定性的关键。
2.2 profile配置到底在配什么:一份配置拆给你看
我之前用过的 profile 文件,结构并不复杂,但每一项背后都有原因。你可以把 profile 想象成一份“点单配置”:测试工具是厨师,profile 就是告诉厨师今天在哪个厨房、用哪套餐具、做几道菜。
config/ profile1.js profile2.js base.jsbase.js 存放公共部分,比如测试目录、通用超时、报告格式;profile1.js 只写差异部分。拿 profile1 举个例子:
module.exports = { baseURL: 'https://staging.example.com', testMatch: /.*smoke.*/, browser: ['chromium'], viewport: { width: 1280, height: 720 }, use: { storageState: './data/profile1-login-state.json', screenshot: 'only-on-failure', trace: 'retain-on-failure', }, retries: 2, workers: 2, };baseURL 我不会在用例里写死,而是从配置读取。这样 profile1 指向 staging,profile2 指向预发环境,用例代码一行都不用改。storageState是 Playwright 的“已登录状态”,先把登录后的 cookie 和 localStorage 保存成文件,测试用例直接从登录态开始跑,能少写大量登录逻辑。testMatch用来控制跑哪些用例,冒烟 profile 只跑标记为 smoke 的文件,全量回归 profile 则跑全部文件。
很多时候配置写得不清楚,不是工具的问题,而是没有想清楚“到底哪些东西会变”。一旦变量很多,配置也要跟着调整。我的建议是:把“环境地址、账号状态、浏览器、并发数、重试策略、报告形式”这六类罗列出来作为 profile 的最小字段集,其他高级选项按需再加。
2.3 这些参数不调好,你的e2e一开始就在裸奔
配置里的每一项参数都不是凭空存在的。我见过很多团队在 playwright.config 里保留了默认值,结果跑起来全是不稳定的超时和误报。下面几个参数是我每次写配置文件都会重点盯的。
| 参数 | 推荐值 | 为什么这样调 |
|---|---|---|
| timeout | 30_000 | 单个用例的全局超时,30 秒足够覆盖大部分用户操作,又不至于让失败等太久 |
| expect.timeout | 5_000 | 断言等待超时。点击后页面要跳转、要渲染,给它 5 秒;如果 5 秒内断言仍不满足,基本是业务真的出了问题 |
| retries | CI 上 2,本地 0 | 本地希望快速反馈,失败就立刻看;CI 上给两次重试机会,过滤偶发网络抖动 |
| headless | 本地 false,CI true | 本地要看到浏览器在做什么,CI 无人值守必须隐藏界面 |
| workers | CI 上按 CPU 核数的一半 | 并发开太多容易被系统资源拖垮,尤其是容器环境里经常因此失败 |
| trace | retain-on-failure | 用例失败时自动保存完整操作轨迹,点开就能看到每一步的截图和网络请求,排错效率高很多 |
这里面expect.timeout是最容易被忽略的。很多人只看总的 timeout,但真正的等待瓶颈其实在断言。比如一个按钮点击后要等列表接口返回,如果expect只等 1 秒,那不稳定的概率极高;如果给到 5 秒,配合自动重试,稳定性会明显提升。
还有一个要提醒的:不要无脑使用waitForTimeout固定等待。前端渲染快的项目可能 500ms 就好,慢的时候要 3 秒,写死sleep(2000)只会让测试要么慢要么飘。现代框架的自动等待机制能帮你等元素可交互、等请求返回,这些机制比任何固定时间都可靠。
3. 实操过程:从零搭建一套e2e跑起来
3.1 目录结构:搭一个不会越用越乱的骨架
我见过不少团队的 e2e 项目,所有文件堆在根目录,spec、截图、临时脚本混在一起,两星期后就没人敢动。宁可前期多花十分钟把目录搭清晰,也不要后面在混乱里挣扎。
用一个常见的 Playwright 工程举例:
e2e/ config/ base.js profile1.js profile2.js tests/ smoke/ login.spec.js purchase.spec.js full/ user.spec.js fixtures/ test-data.json utils/ request-helper.js playwright.config.js package.json初始化命令同样简单:
npm init -y npm i -D @playwright/test npx playwright install@playwright/test里已经包含了测试运行器、断言库和报告器,不需要额外再装一堆依赖。npx playwright install会下载 Chromium、Firefox、WebKit 三个浏览器的二进制文件,这一步在公司内网环境里可能比较慢,建议提前下载好或配置镜像源。
package.json 的 scripts 我会写成这样:
{ "scripts": { "test:e2e:smoke": "cross-env E2E_PROFILE=profile1 playwright test --project=profile1", "test:e2e:full": "cross-env E2E_PROFILE=profile2 playwright test --project=profile2" } }用cross-env是为了兼容 Windows 和 macOS/Linux 的环境变量写法,很多坑都是因为命令行里直接写E2E_PROFILE=xxx在 Windows 下不生效导致的。
3.2 第一条用例:从登录到完成关键业务
写第一条用例时,别急着覆盖所有功能,先把“核心闭环”跑通。比如一个后台管理系统,最关键的就是登录后创建一个资源并看到它在列表里出现。用 Playwright 的写法大致是这样:
const { test, expect } = require('@playwright/test'); test('用户登录后能创建项目', async ({ page }) => { // 使用 profile 里存储的登录态,直接进入首页 await page.goto('/projects'); // 点击新建按钮 await page.getByRole('button', { name: '新建项目' }).click(); await page.getByLabel('项目名称').fill('e2e-demo'); await page.getByRole('button', { name: '保存' }).click(); // 等待列表出现新项目 await expect(page.getByText('e2e-demo')).toBeVisible(); });为什么我要用getByRole和getByLabel,而不是一上来就page.locator('xpath=//div[@class="el-button"]')?因为端到端测试模拟的是真实用户的感知方式,用户看得见的是按钮上的文字、输入框的标签,而不是一眼看不到的 DOM 层级。用面向用户的定位器写出的用例,即使前端组件库从 Element UI 换成 Ant Design,只要文案和语义不变,用例也能继续跑。
如果页面里没有合适的语义标签,再退而求其次加>const base = require('./config/base'); const profile = require(`./config/${process.env.E2E_PROFILE || 'profile1'}`); module.exports = { ...base, ...profile, use: { ...base.use, ...profile.use, }, };
这样执行下面的命令时:
cross-env E2E_PROFILE=profile1 npx playwright test --project=profile1测试运行器会读取 profile1 里的配置,只跑smoke目录下的用例,用 Chromium、并发 2、失败重试 2 次,并且失败时保留 trace 和截图。
跑完之后控制台会输出每一条用例的耗时和状态,同时生成 HTML 报告。如果出现失败样例,打开 trace 文件后你会看到每一步操作对应的页面截图、DOM 快照和网络请求,甚至还能直接查看浏览器控制台报错。这个能力帮我定位过很多“明明看到按钮了却点不上”的问题,最终发现是某个遮罩层阻挡了点击事件,这类问题看 trace 一眼就能确认。
4. 常见问题与排查技巧实录
4.1 三座大山:慢、乱、不稳定
第一座山是“慢”。E2E 天然比单元测试慢,但慢得离谱就不正常了。常见原因包括:用例不加标记导致全量跑、缓存策略没配好、并发数太小、环境没有预热。我的做法是把用例分成冒烟和全量两级,profile1 专跑冒烟,控制在 1-3 分钟内;夜间再跑全量。而不是所有场景都堆在一个 profile 里跑一小时。
第二座山是“乱”。这里主要指测试代码和配置混乱。定位器乱七八糟、页面对象模型随意扩张、配置里藏着大段注释掉的代码,最后没人愿意维护。解决办法是定规矩:定位器必须用 getByRole/getByLabel 优先,必要时用>outputDir: `test-results/${process.env.E2E_PROFILE || 'profile1'}`,
这样 profile1 和 profile2 失败时生成的截图、trace 就不会混在一起了。
4.3 稳定上线的几个“土办法”
技术层面之外,我真正想强调的是“流程”和“习惯”。E2E 不是写完脚本就结束的,它是需要长期喂养的系统。
我会在项目里做三件事。第一,失败用例必须做“失败归因”。每次跑完看报告,如果是因为环境没有部署导致失败,不能简单地点 re-run;要标记为环境问题并通知运维。如果是因为测试数据被改动导致失败,就要找测试数据的管理人。分清楚“测试代码问题”“测试环境问题”“业务代码问题”这三个类别,才能避免所有红灯都被当成“测试不稳定”而忽略。
第二,给每个失败的用例设置“重试上限”。本地跑的时候我不开重试,因为这会影响调试速度;但 CI 上我会开两次重试,并且要求重试成功后用例仍然要保留失败现场的截图,否则你永远不知道它曾经为什么失败。很多不稳定问题就是靠重试现场的 trace 才被确认的。
第三,设立一个“明天必须修”的清单。如果某天冒烟用例还有红色,我会在第二天上午第一件事去处理,绝不拖到周末。E2E 用例一旦变红,后面所有人都会对它失去信任,最后变成“反正它会红,出了问题再说”,这样测试系统就废了。
真正开始有价值的判断标准
最后说一个我踩了好几次坑才养成的习惯:不管团队多忙,我都会在本地跑一遍 profile1 对应的那几条冒烟用例再去提代码合并。不是不相信 CI,而是 e2e 出问题时,本地能直接启动 UI 模式一步步看,比在远程环境里翻日志高效得多。如果你也刚开始搭建 e2e,我建议不要一上来就追求把全部功能都做成端到端,先挑一个最核心的用户旅程,用 profile1 这种最小的配置跑通,每天坚持跑一周。只有当这组用例可以连续一周每次命中、没有人需要手动点“重新跑”,这套 e2e 才算是真正有价值了。