上个月一个后台项目发版,我照例先跑了 E2E 回归。命令行里看到 profile1 这套用例全部通过,才放心把构建推到生产环境。我做端到端自动化测试已经五年,这类项目从零搭过、从乱麻里救过,今天把最有借鉴价值的部分整理出来,希望能给正在搞 E2E 或者准备引入 E2E 的团队一点参考。
E2E 全称 end-to-end,也就是端到端测试。简单说,就是让测试脚本像真人一样,从界面入口开始操作:输入账号、点按钮、填表单,一路走到后端接口、数据库甚至第三方服务,最后再回到界面验证结果。它解决的问题是“系统对外承诺的用户旅程到底还能不能走通”,适合测试开发、前端工程师以及所有想把手工回归成本压下来的研发团队。
很多团队会把 E2E 用例按重要程度分成几个 profile,profile1 是我们内部的第一档冒烟集合,专门守用户最常走的几条主链路。这篇文章与其说在讲 E2E 原理,不如说是在分享我围绕 profile1 搭建、维护一套稳定端到端测试的真实经验。
1. 端到端测试的定位与设计思路
1.1 E2E到底在测什么
很多刚接触 E2E 的人会把它简单理解成“自动化点按钮”,这是一个危险的想法。如果只为了点按钮,那单元测试和组件测试早就能做了,何必用一个跑起来慢、维护成本又高的东西。E2E 真正要测的,是各个层级组合在一起之后的行为。
我习惯用开车来类比:单元测试是检查发动机某个零件的材质,集成测试是把发动机、变速箱装到台架上测试它们能配合工作,而 E2E 是整车路测,你得真的握方向盘、踩油门、过弯道,才能知道这辆车开起来是不是顺手,会不会在某个路口熄火。对应到系统里,就是浏览器里的点击操作要真实触发网络请求,后端要真的读写数据库,如果接入了第三方服务,也要验证回调回来之后整个链路是否闭环。
所以我们做 E2E 时,不能只盯着界面上有没有元素,更应该关注数据有没有在请求之间正确流转、权限在每一步是否会拦截、异常分支是不是真的出现了友好提示。E2E 不擅长发现深埋在后端角落里的小 bug,它能告诉你的往往是“用户根本走不到那一步”。这也是为什么它要放在发版前,而不是替代单元测试。
1.2 为什么要有profile机制
如果所有 E2E 用例不分角色、不分级别,全部塞到一个集合里跑,很快就会出现两个问题:时间越来越长,失败越来越难解释。比如你有几百条用例,跑了 40 分钟,中间有一半是因为测试数据互相污染挂掉的,这时候就算标红了,团队也未必能第一时间判断是线上问题还是脚本问题。
所以要把测试集合按风险和价值切片。我见过团队把 E2E 分成 smoke、sanity、regression,本质上也是一种 profile 机制。我们内部叫 profile1、profile2、profile3,作用类似。profile1 是最高优先级的冒烟集合,覆盖登录、商品列表、下单、支付回调这类核心主链路,数量控制在 10 到 15 条左右,要求 5 分钟内跑完,并且必须稳定;profile2 负责常规业务回归,可以定时跑;profile3 专门跑跨浏览器兼容,比如 WebKit、Firefox 这些非主力浏览器。
| 配置档位 | 目标 | 用例数量 | 执行频率 | 运行时间示例 |
|---|---|---|---|---|
| profile1 | 核心主链路冒烟,作为合入门禁 | 10-15 条 | 每次 PR、每次发版前 | 3-5 分钟 |
| profile2 | 常规业务回归 | 80-200 条 | 每夜或每个迭代 | 20-40 分钟 |
| profile3 | 跨浏览器/设备兼容 | 核心用例的子集 | 每周或大版本前 | 30-60 分钟 |
从成本角度看,profile1 是最值得投资的。它就像一个门店的试营业,先把最赚钱、最容易被顾客投诉的流程跑一遍,有问题立刻停业调整,而不是等正式开业那天让客人满世界找入口。
1.3 哪些场景适合E2E,哪些不适合
这些年我踩过最大的坑,就是试图让 E2E 干所有事。同事曾把日期选择器的每个月份切换都写成 E2E,结果每次日历组件一升级,测试就要跟着改一遍,最后大家都不敢动日历组件的代码了。这明显是过度使用。
适合放进 E2E 的场景通常有这几个特征:用户频繁使用、跨系统跨服务、出现问题影响大。
- 注册登录:账号体系挂了,后面所有流程都是空话。
- 核心购物链路:搜索、详情、加购、结算、支付回调。
- 权限隔离:不同角色登录后看到的菜单和数据不同。
- 第三方集成:登录鉴权、短信回调、支付通知。
- 多步骤表单:申请、审批、流转状态每一步都有数据变化。
不太适合放 E2E 的场景也有不少:
- 复杂算法和纯逻辑计算:单元测试更合适。
- 海量数据校验:用数据校验任务,而不是让浏览器一页页翻。
- 像素级视觉比对:截图对比工具更专业。
- 一次性活动页面:存活两周的活动,用例刚写好就要删,纯浪费。
我以前总结过一个判断标准:如果一条 E2E 用例每周要修超过一次,那说明它要么不应该被自动化,要么设计有问题。正常的代码改动不应该让 E2E 频繁失效,除非你把不该放进去的场景放错了层。
2. 工具选型与基础配置
2.1 主流框架对比
选 E2E 框架时,很多人会陷入“谁名气大选谁”的误区。其实主流框架各有特点,核心要看你的项目形态和团队熟悉程度。现在用得最多的三套是 Selenium、Cypress 和 Playwright。
| 能力 | Playwright | Cypress | Selenium |
|---|---|---|---|
| 跨浏览器支持 | Chromium、Firefox、WebKit | 主要是 Chromium 系,Firefox 支持有限制 | 全浏览器,但需要依赖驱动 |
| 自动等待 | 内置,无需手动 sleep | 内置,较友好 | 需要自己封装显式等待 |
| 多标签页/多窗口 | 支持较好 | 有限,官方推荐 iframe | 支持但是代码繁琐 |
| Trace 录制 | 原生支持,失败可回放 | 有截图和视频 | 一般借助第三方 |
| 语言支持 | JS/TS、Java、Python、C# | JS/TS | 多语言 |
| 易用性 | 学习曲线适中 | 上手快,API 友好 | 偏重,模板代码多 |
我最终选了 Playwright + TypeScript,主要原因是它把自动等待做得比较靠谱,不需要在代码里到处写sleep或者自定义waitForElement。另外 Trace 功能对排查 flaky 问题帮助极大,后面我会专门讲。Cypress 对前端测试非常友好,但如果团队需要同时跑 WebKit 这样的浏览器,或者经常处理多标签场景,Playwright 会更省心。
选择工具还有一个隐性成本:社区讨论量。遇到奇怪问题时,搜索引擎能搜到的答案数量很重要。Playwright 这几年社区活跃度很高,绝大多数问题都有现成解决方案,这一点在维护期会体现得特别明显。
2.2 理解并定义profile1配置
很多团队第一次接触“e2e profile1”时,以为它只是一个环境变量或者一份静态配置。我的理解不太一样:profile1 是一套配置快照,包含环境地址、测试账号组、功能开关、浏览器组合和用例过滤规则。单独跑 profile1 应该是很容易的事,一条命令就能复现整套冒烟测试。
在 Playwright 里面,我们可以通过 project 和 grep 来实现这个效果。先看一个最小的playwright.config.ts:
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests/e2e', timeout: 30_000, expect: { timeout: 5_000 }, fullyParallel: false, retries: process.env.CI ? 2 : 0, reporter: [['html', { outputFolder: 'playwright-report' }]], use: { baseURL: process.env.E2E_BASE_URL ?? 'https://staging.example.com', trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', viewport: { width: 1280, height: 720 }, }, projects: [ { name: 'profile1-chromium', grep: /@profile1/, use: { browserName: 'chromium', ...devices['Desktop Chrome'] }, }, ], });这里的核心是grep: /@profile1/。它会让这个 project 只运行名字或标签里带@profile1的用例。这样 profile1 不需要靠目录隔离,而是靠标签选择。好处很明显:一条用例可以同时属于 profile1 和 profile2,只需要加上@profile2就行,用的时候用不同 project 调用。
另一个关键点是baseURL用环境变量控制。profile1 可以对着 staging 跑,也可以对着本地启动的测试环境跑。只要环境变量变了,用例不用动。我会把密文类信息(密码、Token)放在 CI 的 secrets 里,不让它们出现在仓库代码里。
2.3 关键参数怎么调(避免超时误判)
E2E 跑挂,一半以上的原因跟超时和重试有关。我见过不少人一遇到用例超时,就把 timeout 从 30 秒改成 60 秒,结果问题不但没解决,反而把页面白屏太久这种性能问题掩盖了。用户可不会在提 bug 前等你 60 秒。
我的经验是给超时设一个“不舒适阈值”:单步操作 30 秒已经很长,断言在 5 秒内应该能等到结果。如果一个操作经常超过 30 秒,优先考虑是不是页面慢、接口慢,而不是把超时调大。调参应该是最后手段。
| 参数 | 建议值 | 说明 |
|---|---|---|
| timeout | 30_000 | 单条用例内每个操作的最大等待时间 |
| expect.timeout | 5_000 | 断言等待元素出现的最大时间 |
| retries | CI 2,本地 0 | 重试会消耗时间,本地重试没意义 |
| workers | profile1 建议 1-2 | 并发过高容易踩测试数据冲突 |
| fullyParallel | false | profile1 保持串行为主,避免资源竞争 |
workers是很多人忽略的参数。profile1 用例不多,没必要开 8 个 worker 去抢资源。我曾经开过 4 个 worker 跑 profile1,结果每个用例都在创建订单,数据库里出现一堆脏数据,然后互相影响。后来老老实实把 workers 调成 1,稳定得一塌糊涂。并发留给 profile2 去玩,冒烟测试求的是确定性。
3. 实操过程:从零搭建profile1用例
3.1 项目初始化与目录结构
如果你也是从零开始,最简单的方式是执行npm init playwright@latest,它会帮你创建playwright.config.ts并安装浏览器。如果你不想用脚手架,也可以手动装:
npm init -y npm i -D @playwright/test npx playwright install chromium我习惯把目录组织成下面这样:
project/ playwright.config.ts package.json tests/ e2e/ profile1/ auth.spec.ts order.spec.ts utils/ auth.ts test-data.ts fixtures/ extended.ts这里有个容易忽略的点:profile1 目录只是一个组织方式,真正决定它是否属于 profile1 的是标签@profile1。目录可以帮助人维护,标签可以帮助机器选择。两者不要混为一谈。
公共逻辑一定要抽出来。比如登录操作,我会放到utils/auth.ts里,而不是在每个用例开头重复一段获取用户名密码、填表单的代码。重复代码一旦遇到登录页改版,所有用例都要跟着改一遍,那时候你就知道抽公共逻辑有多重要了。
3.2 编写核心用例:登录、导航、核心动作、断言
我们拿一个“正常下单全流程”来举例。这是 profile1 里最典型的场景,也是大多数电商后台必须保证的链路。
import { test, expect } from '@playwright/test'; import { login } from '../utils/auth'; test.beforeEach(async ({ page }) => { await login(page, process.env.E2E_USER!, process.env.E2E_PASS!); }); test('profile1: 正常下单全流程', async ({ page }) => { await page.goto('/products'); await expect(page.getByRole('heading', { name: '商品列表' })).toBeVisible(); await page.getByRole('link', { name: '无线机械键盘' }).click(); await page.getByRole('button', { name: '加入购物车' }).click(); await page.getByRole('button', { name: '去结算' }).click(); await page.getByPlaceholder('收货人姓名').fill('张三'); await page.getByPlaceholder('手机号').fill('13800138000'); await page.getByRole('button', { name: '提交订单' }).click(); await expect(page.getByTestId('order-success')).toHaveText('订单提交成功'); });这段代码每一步都有讲究。第一步page.goto('/products')没有加 baseURL 前缀,因为配置文件里已经设好了。登录用的是beforeEach,保证每条用例的起点都是已登录状态。这里没在beforeEach里断言登录是否成功,是因为接下来商品列表的toBeVisible()已经隐式检查了页面是否正常渲染,如果登录失败,这一步必然超时,这里相当于把两次等待合并了一次,缩短了单条用例的时间。
进入详情页后,我用的是getByRole('link', { name: '无线机械键盘' }),而不是page.locator('text=无线机械键盘'),因为前者可以精确定位到可点击的链接,不会因为页面其他地方出现同一文案而报错。填表时用 placeholder 定位已经是比较稳定的方式,前提是前端别乱改 placeholder。
最后断言用的page.getByTestId('order-success')是测试专属标记。我一般在项目里和前端约定好,给关键成功态、错误态节点加>const prefix = `e2e_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
第二,尽量通过 API 去创建前置数据,而不是通过 UI 一步一步点进去。比如我要测订单列表的分页,先调用后端接口为当前测试账号创建 20 条订单,再打开页面验证分页。如果依赖 UI 去创建 20 条订单,用例不仅慢,还容易在这中间挂掉。
第三,设定清理策略。afterAll里把本测试创建的数据清掉,但如果用例失败,我不会强行清理。因为失败现场的数据往往就是排查原因的关键线索,保留原样比强行删除更有价值。等到 trace 看完、问题定位完,再通过数据库任务清理即可。
不要指望一个测试账号从 morning 跑到 night 都是干干净净的,也不要用“每次测试前清库”这种粗暴方案,那会导致多个并行任务互相影响。数据隔离是设计出来的,不是抱怨出来的。
3.4 在CI中接入profile1
代码写出来不等于能持续发挥作用,把它接入 CI 才是真正解放手工的开始。我们在 GitHub Actions 里用一个简单 workflow 跑 profile1:
# .github/workflows/e2e.yml name: E2E profile1 on: pull_request: paths: ['src/**', 'tests/**', 'playwright.config.ts'] jobs: profile1: 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 chromium - run: npx playwright test --project=profile1-chromium env: E2E_BASE_URL: ${{ secrets.STAGING_URL }} E2E_USER: ${{ secrets.E2E_USER }} E2E_PASS: ${{ secrets.E2E_PASS }} - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/几个细节比较关键。paths过滤让 workflow 只在相关代码变更时触发,减少无谓运行。如果不加过滤,每次改一行文档也要等 5 分钟 E2E,团队迟早会对自动化产生反感。
npx playwright install --with-deps chromium一定要加--with-deps,否则在新环境上可能出现缺系统库导致浏览器启动不了。运行时把环境变量从 secrets 注入,账号密码不用写进代码。
失败后上传playwright-report是必须的。否则遇到失败,本地能跑通、CI 跑挂,你都不知道 CI 上的证据在哪。现在 Playwright 的 HTML 报告里能看到完整 trace 和 console 报错,排查效率提升不是一点半点。
4. 常见问题与排查技巧实录
4.1 测试不稳定(flaky)的典型原因和对策
很多人刚开始跑 E2E,最头疼的不是功能写不出来,而是“这次过了,下次挂了,再跑又过了”。这种随机性会让团队对自动化失去信心。我统计过自己项目的 flaky 原因,典型的有下面几类。
| 原因 | 表现 | 对策 |
|---|---|---|
| 等待方式不对 | 手动 sleep 导致有时快有时慢 | 换成内置自动等待,或显式等待元素状态 |
| 动画/过渡影响 | 元素存在但仍被遮挡 | 等待动画结束,采用toBeEnabled断言 |
| 网络请求不稳定 | 接口偶尔超过 500ms | 提高接口响应稳定性,而不是盲目加 timeout |
| 测试数据串扰 | 多个用例操作同一账号 | 独立账号 + API 造数据 |
| 定位器不规范 | CSS 层级一变化就找不到 | 使用 role/testid 定位 |
排查 flaky 的第一步永远是打开 trace。Playwright 配置里我开了trace: 'retain-on-failure',每次失败都会录制完整的浏览器执行过程和页面快照。从 trace 里你能看到每一步操作前页面长什么样、接口返回了什么、元素是在哪一步开始失踪的。如果没有 trace,盲目重跑十次再瞎猜,基本就是在油锅边打转。
我自己有个额外经验:发现 flaky 后,不要急着改代码,先在本地连续跑五次,看失败率。如果五次都过,再回到 CI 跑几次。有时候问题只出现在 CI 环境,比如并发资源不够、网络慢,这些在本地根本复现不出来。
4.2 定位器写不对是最大的时间黑洞
E2E 出错,一半以上是定位器问题。我见过最糟糕的一种写法是这样:
await page.locator('#app > div > div.card-content > button').click();这串选择器今天能用,明天前端同事在中间加了一个<div>,测试就立刻挂了。其实用户根本不会关心按钮外面包了多少层 div,他们只关心这个按钮说“提交订单”并且能点。
我的定位器优先级是:
- 首选:
getByRole('button', { name: '提交订单' }),语义清晰。 - 其次:
getByTestId('order-success'),需要和前端约定。 - 再其次:
getByPlaceholder,适合表单输入框。 - 兜底:CSS 类名,但要避免依赖多层父子结构。
不要图省事用page.locator('text=提交订单')。如果页面上有两个按钮都叫“提交订单”,比如一个在弹窗里、一个在底部栏,这个定位器会直接报 strict mode violation,错误信息倒是很清晰,但你已经浪费了一次运行时间。
我后来把这条要求写进了团队测试规范:不允许在 E2E 用例中出现类似div > div > span > button的层级链。这看起来像洁癖,其实是为了减少维护成本。前端改结构是非常正常的事,如果每一次结构改动都要连带改测试,那自动化就变成了项目的额外负担。
4.3 测试数据隔离与并发冲突
profile1 推荐 low 并发,但你还是可能遇到多个 worker 并行跑同一套用例的情况。最典型的冲突是:两个 worker 同时用同一个登录账号,第二个 worker 登录成功之后,第一个 worker 的会话被踢下线,然后它下一步操作全部因为未登录而失败。
解决办法有几个层次:
- 如果账号资源充足,给每个 worker 配独立账号,比如用户名带
workerIndex。 - 如果账号有限,老老实实把 workers 设为 1,串行执行。
- 共享数据不要用 UI 创建,通过 API 初始化。
workerIndex的使用可以这样:
const username = `user_${test.info().workerIndex}`;但这个方案依赖账号体系允许动态创建用户。如果测试环境不能动态建号,那还是回到限制并发最稳妥。说到底,E2E 的价值在于稳定,而不是跑得多快。profile1 只有 10 条用例,串行也就多两分钟,换来的确定性非常值。
还有一类问题是公共数据被改。比如有一个商品,A 用例把它下架了,B 用例还要用它来加购,结果 B 必然失败。这种情况需要让 A 用例在完成后恢复现场,或者在数据设计上不让用例互相依赖。我喜欢把这类用例明确标注成“会修改共享数据”,放到 profile2 单独串行执行,而不是掺进 profile1。
4.4 profile1的维护纪律(如何避免“经常红”)
最后想聊聊和代码关系不太大、但决定能不能长期坚持下去的事情:维护纪律。很多团队的 E2E 项目头一个月做得风生水起,三个月后所有用例全红,然后大家就假装它不存在了。原因基本都是“只进不出,只写不养”。
profile1 尤其要做减法。我要求团队遵守几条简单的规矩:新增一条 profile1 用例,必须先能稳定连续跑 10 次;如果一周内这条用例红过三次,就要复盘是产品逻辑变了、数据不稳定,还是它根本不该放 profile1。不要让 profile1 里的用例数量无限膨胀,它只服务最高优先级的主链路。
遇到失败时,不要急着点重跑。重跑确实能让你暂时绿掉,但核心问题没解决,下次还会来。正确的流程是打开 trace、看失败步骤、判断是环境问题还是用例问题、修复或调整,再重跑。
我个人在实际操作中的体会是,profile1 最怕的不是写不出来,而是写得太快。把一条真正核心的用户旅程打磨到连续跑 20 次都不失败,再考虑加第二条。另外一个小技巧:每次改完公共组件,先手动跑一下 profile1 再提交代码,五分钟出结果,比在代码评审里争论半天有效得多。现在这套 profile1 已经成了我们团队合入代码的门禁,坚持几个月后,发版前的手工回归时间从两小时压缩到了十分钟,这才是端到端测试真正应有的价值。