E2E端到端测试实战:用Playwright搭建稳定profile1冒烟用例

发布时间:2026/10/26 15:16:27
E2E端到端测试实战:用Playwright搭建稳定profile1冒烟用例

上个月一个后台项目发版,我照例先跑了 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。

能力PlaywrightCypressSelenium
跨浏览器支持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 秒,优先考虑是不是页面慢、接口慢,而不是把超时调大。调参应该是最后手段。

参数建议值说明
timeout30_000单条用例内每个操作的最大等待时间
expect.timeout5_000断言等待元素出现的最大时间
retriesCI 2,本地 0重试会消耗时间,本地重试没意义
workersprofile1 建议 1-2并发过高容易踩测试数据冲突
fullyParallelfalseprofile1 保持串行为主,避免资源竞争

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 已经成了我们团队合入代码的门禁,坚持几个月后,发版前的手工回归时间从两小时压缩到了十分钟,这才是端到端测试真正应有的价值。