ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

e2e测试实战:基于Playwright的配置方案与稳定性优化

e2e测试实战:基于Playwright的配置方案与稳定性优化

做后端的时候我对e2e测试一直不太上心,总觉得接口测试过了、单元测试绿了,系统就没问题。直到有一次改了一个接口字段名,把reason改成reasonDetail,后端联调也通过了,结果前端详情页还拿旧字段渲染,用户提交售后单后页面一直报错,后台却已经创建成功。这种前后端各说各话的问题,单测测不出来,接口测试也未必能覆盖完整交互,最后线上反馈炸了,大家才意识到:真正该在提测前跑的,是一套站在用户视角、把整条核心链路走通的端到端测试,也就是我们常说的e2e。

这篇文章不会讲太虚的概念,就聊聊我在实际项目里怎么规划、选型、落地的 e2e 测试方案,包括一套可以直接抄的配置结构(我习惯叫它e2e profile1)和一些从失败里总结出来的排查经验。如果你正在被“测试用例老飘”“CI跑半小时就为了验证一个按钮”之类的问题困扰,这篇文章应该能帮到你。

1. 为什么项目里必须有一套 e2e 测试

1.1 单测和接口测试覆盖不到的地方

单元测试的粒度是函数和类,它保证的是“这段逻辑在给定输入下输出正确”;接口测试的粒度是协议,它保证的是“两个服务之间能按约定通信”。但它们都默认了一个前提:调用者会按预期方式使用系统。

真实用户根本不按你的预期走。他们从落地页进来,可能先点开某个活动弹窗,再登录,然后加购物车、改数量、填地址、选优惠券、下单、支付、跳转回订单详情——每一步都涉及路由守卫、权限判断、前端状态管理、后端接口返回、数据渲染等多个环节。任何一个环节的隐性问题,都会直接断掉整条链路。e2e 测试要覆盖的,正是这条完整路径上的集成质量。

1.2 e2e 是什么,它和手工测试的区别

e2e 测试是用自动化脚本模拟用户在真实浏览器或真实设备上的操作,以“黑盒”方式验证整个系统从入口到出口的行为是否符合预期。它和手工测试的区别在于两点:一是可重复性,同样的路径每次跑结果一致;二是作为回归兜底,发版或重构后可以快速验证核心链路。

我经常用一句话解释:e2e 测试是在替你读一本厚厚的用户手册,并且严格按照手册一步步执行,执行完还要检查每一步的结果是否和手册写的一样。它不需要知道内部实现,只需要知道“用户在这个页面做了什么,系统应该给出什么反馈”。

1.3 什么时候该上 e2e,什么时候别急着上

不是所有项目都适合一上来就铺 e2e。我的建议是看两个指标:核心流程是否稳定,迭代频率是否够高。

如果项目还在探索期,UI 三天一小改五天一大改,这时候写 e2e 纯属给自己添堵,selector 天天要修。更合理的做法是先把接口测试和关键单测补上,等核心页面结构和流程稳定了,再逐步引入 e2e。反过来,如果项目已经处于维护期,每次发版频繁调整后端逻辑、前端组件重构,那 e2e 就是刚需,它能让你在凌晨发版时不用拉着测试同事加班手点。

我踩过的最典型的坑是:团队刚开始做 e2e 时想把所有流程都自动化,结果用例数量冲到 300 多条,每次跑完要一个多小时,失败后光排查就花半天。后来把用例砍到“核心链路 20 条 + 重要分支 30 条”,反而稳定多了。别把所有验证都压到 UI 层,e2e 贵,贵在维护成本和执行时长,要让它用在刀刃上。

2. 工具选型:从 Cypress 到 Playwright 的对比

2.1 主流 e2e 工具横向对比

现在做 Web 端 e2e,绕不开几个选择:Selenium WebDriver、Cypress、Playwright,偶尔还有 TestCafe。我自己的选型经历是:早年用 Selenium,后来切到 Cypress,最后在 Playwright 稳定后完全迁移过去。

对比维度Selenium WebDriverCypressPlaywright
安装和维护成本需要驱动管理,环境配置繁琐相对简单一条命令装浏览器和驱动
内置等待策略较弱,基本靠显式等待自动重试断言和等待Web-first 定位器 + 自动等待,最省心
多浏览器支持支持 IE/Chrome/Firefox/Safari初期只支持 Chrome 系Chromium、Firefox、WebKit 全支持
网络模拟和 Mock较弱支持 cy.intercept路由拦截功能丰富,支持请求/响应修改
调试体验截图和日志较基础时间旅行式调试非常直观Trace Viewer 能看到完整运行轨迹
并行执行依赖 grid,配置复杂商业版才有并行原生支持并行 worker,CI 友好

Selenium 的优势是生态老、资料多,但它的定位更偏向“通用自动化”,不是专门为前端测试设计的。Cypress 的开发者体验很好,尤其它的调试面板,点击一条断言就能看到当时页面的快照,这点我非常喜欢。但 Cypress 在多标签页、多浏览器支持上始终差点意思,而且官网很多特性需要付费版才流畅。

Playwright 能做到“开箱即用加一条命令跑完整套”,对现代 Web 技术栈的兼容性也更好。它的自动等待策略很聪明——不是说让脚本傻等 3 秒,而是等元素出现、等条件满足、等请求完成,大大减少了 flaky 测试。

2.2 为什么我最终选了 Playwright

选择 Playwright 核心原因是它能解决我团队里两个最痛的问题:CI 稳定性和调试效率。

Playwright 的定位器(locator)是基于用户可见文本和 ARIA 角色的,比如getByRole('button', { name: '提交订单' }),而不是像传统做法那样依赖脆弱的 CSS 类名和 DOM 结构。这样页面样式调整后,测试代码基本不需要跟着改。它还内置了断言重试机制,断言失败时会自动轮询等待一段时间,只要最终条件满足就算通过,这对前端加载慢、动画未结束的场景非常友好。

调试方面,Playwright 的 Trace Viewer 是一个杀手锏。每次测试失败,它会自动生成一个 trace 文件,包含页面快照、网络请求、控制台日志、DOM 快照时间线。我可以在 trace 里看到“点击按钮后请求是否发出”“返回的 JSON 是什么”“页面上有没有报错”,就像看回放一样,排查效率比对着截图猜高太多了。

2.3 e2e profile1:一套按需切换的配置方案

这里我把标题里的“e2e profile1”落地成一个可执行的概念:profile 就是一套命名好的配置组合。

在实际项目中,不可能是所有环境都跑一样的用例。开发环境网络不稳、测试环境数据不干净、生产环境不能乱写数据。所以我把常用场景抽成几个 profile:

  • profile1:核心冒烟。覆盖登录、主流程、关键支付链路,大概 10-20 条,每次提测必跑,要求 3 分钟内跑完。
  • profile2:全量回归。覆盖所有核心用例和重要分支,每周跑一次或在发版前跑,运行时间在 30 分钟左右。
  • profile3:新功能专项。只跑本次迭代涉及的新功能用例,需要开发自己在分支上执行。

具体做法是通过环境变量E2E_PROFILE来控制要加载的测试文件列表、浏览器项目、并发 worker 数和重试策略。下面会给出详细的配置代码。

3. 核心细节解析与实操要点

3.1 测试用户旅程怎么拆

筛选 core journey 是写 e2e 的第一步。我的原则是“对收入和安全影响最大、跨系统协作最多、出问题后恢复成本最高”的优先级最高。拿电商系统举例,最重要的用户旅程就三个:登录并保持状态、商品搜索加购物车和下单、支付结果回调。

拆解之后要画一张简单的操作流图,不是给机器看的,是给人看的。比如登录这个步骤,细分下去可能是:

  • 打开登录页
  • 输入账号密码
  • 点击登录
  • 断言跳转到首页并展示用户名

每一步都要能对应一个明确的断言标准。如果某一步只能等 5 秒、没有反馈,说明这条用例本身设计就有问题——用户都不知道系统在干嘛,自动脚本更不知道。

3.2 测试数据准备与清理

e2e 测试最耗时的不是写代码,而是准备数据。我的原则是:所有测试数据都不能依赖手工预置,必须在用例里自己创建,并且用完清理。

常见的做法有两种:一种是通过后端 API 直接造数据,速度最快,适合测试数据本身不是测点的情况;另一种是通过 UI 操作制造数据,适合验证“用户上传”“用户提交”等真实操作链路。我一般混合使用:主流程用 API 造账号和商品数据,再用 UI 走完整流程;造完后通过数据库或接口做归档清理。

这里有三个容易踩的坑:

  • 测试账号必须隔离,多 worker 并行时不能用同一个账号,否则会互相顶登录状态。
  • 数据要幂等可重建,每次跑之前清空旧的脏数据,否则第二次跑会因为订单号重复失败。
  • 用户支付接口尽量走沙箱,不要连真实通道。

3.3 定位器选择与等待策略

我认为 e2e 用例写得稳不稳,一半看定位器选得好不好,一半看等待策略合不合理。

定位器的优先级,我是按下面的顺序来用的:

  1. 用户可见文本:getByText('提交订单')
  2. ARIA 角色:getByRole('button', { name: '提交订单' })
  3. 表单标签:getByLabel('手机号')
  4. 测试专属属性:>await expect(page.getByText('支付成功')).toBeVisible({ timeout: 10000 });

    3.4 断言设计:断言用户结果,不断言实现细节

    断言的常见误区是验证“接口返回了什么”或“某个内部状态变成了什么”,而不是验证“用户看到了什么”。e2e 的价值在于结果,不在过程。

    我举一个例子:用户提交订单后,接口返回{ code: 0, orderId: '123' },这时候应当断言页面上出现了“订单提交成功”和对应的订单号,而不是断言后端返回的 JSON 里的 code 为 0。

    但如果支付回调涉及服务端逻辑链路过深、异步处理时间较长,还是需要等待后端状态的。我的做法是把这类等待封装成一个自定义断言的 helper,例如轮询接口查询订单最终状态,拿到终态以后再断言页面表现。这样既保证了真实场景,又不会因为后端中间态太慢导致超时。

    3.5 可重复性与稳定性设计

    e2e 做得越多越明白,稳定性要比覆盖度更重要。一条用例跑 10 次挂 5 次,比没有用例更折磨人。

    提升稳定性的几个实操手段:

    • 把每个用例都设计成独立可执行的,不要依赖前一个用例留下的状态。
    • 给每个 worker 分配独立的测试账号,或者至少每条用例用一套隔离的数据。
    • 对网络请求使用路由拦截,把不稳定的第三方字体、统计脚本和广告类资源屏蔽掉。
    • 设置合理的超时,不要一味地加大全局 timeout,否则失败后排查成本会变高。

    4. 实操过程:一个订单流程用例从零到跑通

    4.1 搭建最小可用环境

    实际搭建时,我用 Playwright 作为主框架。第一步是初始化项目并安装依赖:

    mkdir e2e-demo && cd e2e-demo npm init -y npm install --save-dev @playwright/test npx playwright install

    接下来规划目录结构,我的标准布局是这样:

    e2e/ ├── config/ │ ├── playwright.config.ts │ ├── profile1.config.ts │ ├── profile2.config.ts │ └── global-setup.ts ├── fixtures/ │ └── auth.setup.ts ├── helpers/ │ ├── data.ts │ └── order.ts ├── tests/ │ ├── login.spec.ts │ ├── order-flow.spec.ts │ └── payment.spec.ts ├── test-data/ └── utils/

    之所以把配置文件单独放一层,是为了让几个 profile 的差异一目了然,后面谁接班了也知道去哪里改执行范围。

    核心的playwright.config.ts大致长这样:

    import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 30000, fullyParallel: true, retries: process.env.CI ? 2 : 0, reporter: [['html', { open: 'never' }], ['trace', {}]], 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: { browserName: 'chromium' } }, { name: 'firefox', use: { browserName: 'firefox' } }, ], });

    4.2 e2e profile1 配置:冒烟测试怎么选

    profile1 的核心目标是“快”。我把它的表现定义成这样:

    • 只跑 core journey,大概 10-20 条用例。
    • 只在 Chromium 浏览器跑,不跨浏览器并行。
    • 并发 worker 数拉满,优先级是“先跑完再跑全”。
    • 单独指定测试标签,小于等于 3 分钟。

    具体实现方式,我习惯独立出一个profile1.config.ts,通过传标签和配置文件来选择测试范围:

    // profile1.config.ts import baseConfig from './playwright.config'; import { defineConfig } from '@playwright/test'; export default defineConfig({ ...baseConfig, testMatch: /.*(login|order-flow|payment).*\.spec\.ts/, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], workers: 4, retries: 1, grep: /@smoke/, });

    然后在 package.json 里增加脚本:

    { "scripts": { "e2e:profile1": "E2E_PROFILE=profile1 playwright test --config=config/profile1.config.ts", "e2e:profile2": "E2E_PROFILE=profile2 playwright test --config=config/playwright.config.ts", "e2e:localhost": "E2E_BASE_URL=http://localhost:3000 npx playwright test --grep @smoke" } }

    这里面的核心思路是用标签@smoke来标记哪些用例属于 profile1。给用例打标签在代码里是这样写的:

    test('用户提交订单流程完整可用', { tag: '@smoke' }, async ({ page }) => { // 测试逻辑 });

    这样无论未来用例怎么增加,只要新用例不打@smoke标签,profile1 的时间就基本不会失控。

    4.3 一条用例的完整实现示例

    以“登录-搜索-加入购物车-提交订单-支付成功”这条链路为例,我贴一段实际风格的 TypeScript 代码,注意看数据准备和断言的写法:

    import { test, expect, Page } from '@playwright/test'; import { createTestUser, cleanTestUser } from '../helpers/data'; test.describe('订单核心流程', () => { let page: Page; test.beforeAll(async () => { // 通过 API 预置测试用户并缓存登录态 await createTestUser('e2e_smoke_user', 'Test@123456'); }); test.beforeEach(async ({ browser }) => { const context = await browser.newContext({ storageState: 'auth.json' }); page = await context.newPage(); }); test.afterAll(async () => { await cleanTestUser('e2e_smoke_user'); }); test('用户可完成从搜索到支付的完整链路', { tag: '@smoke' }, async () => { // 搜索一件商品 await page.goto('/'); await page.getByPlaceholder('输入商品关键词').fill('测试商品A'); await page.getByRole('button', { name: '搜索' }).click(); await expect(page.getByText('测试商品A')).toBeVisible(); // 加入购物车 await page.locator('[data-testid="product-card"]', { hasText: '测试商品A' }) .getByRole('button', { name: '加入购物车' }) .click(); await expect(page.getByText('已加入购物车')).toBeVisible(); // 进入购物车并提交订单 await page.getByRole('link', { name: '购物车' }).click(); await page.getByRole('button', { name: '提交订单' }).click(); // 选择支付方式并确认支付 await expect(page.getByText('订单确认')).toBeVisible(); await page.getByLabel('微信支付').check(); await page.getByRole('button', { name: '确认支付' }).click(); await expect(page.getByText('支付成功')).toBeVisible({ timeout: 15000 }); }); });

    这里的细节要注意几点:

    • hasText和getByRole组合使用,能精确命中目标商品,而不是页面里所有商品卡片。
    • storageState: 'auth.json'是从首次登录后提取出来的,能省掉重复执行登录步骤的时间,稳定性也更好。
    • 最后支付成功的断言给了 15 秒超时,因为支付回调可能会依赖后端异步通知。

    4.4 提取登录态和使用全局 Setup

    如果每条用例都走一次 UI 登录,大概会多花几十秒,用例多了时间会翻几倍,而且登录页这个环节本身就是最容易被并发环境干扰的。所以我习惯用一个全局 setup 提取登录态。

    global-setup.ts里做一次真实登录,然后保存 storageState:

    import { test as setup } from '@playwright/test'; setup('登录并保存认证态', async ({ page }) => { await page.goto('/login'); await page.getByLabel('账号').fill('e2e_global_user'); await page.getByLabel('密码').fill('Test@123456'); await page.getByRole('button', { name: '登录' }).click(); await page.context().storageState({ path: 'auth.json' }); });

    然后在每个测试中声明storageState来复用这份状态。对于不同 profile,全局 setup 也可以抽离成不同账号和不同入口,这就是 profile1 和 profile2 在执行速度上拉开差距的关键手段之一。

    4.5 接入 CI:让 e2e 成为发布流程的一部分

    本地能跑通只是第一步,价值要在 CI 上跑才体现得出来。我用 GitHub Actions 为例,提供一个可以直接参考的 workflow:

    name: e2e on: push: branches: [main] pull_request: 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: npm run e2e:profile1 env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/

    接入 CI 有几点经验:一是在 CI 里重试次数可以设为 2 次,因为首次跑容易出现环境初始化慢的问题,重试能减少偶发失败;二是失败后一定要上传 trace 和报告,否则排查只能靠猜;三是不要和单元测试放在同一个 job 里跑,e2e 耗时较长,应该独立成 job,避免阻塞其他检查。

    5. 踩坑记录与稳定性优化

    5.1 6 个高频问题速查

    现象根因解决方案
    偶发找不到元素前端渲染靠异步数据,没有等待用 web-first 断言,不要固定 sleep
    多个 worker 互相顶登录态测试账号共用每个 worker 独立账号,或 storageState 隔离
    重复执行失败订单号重复数据没有清理用唯一标识前缀,执行前清理旧数据
    定位器匹配到多个元素文本或元素不唯一用locator().filter()或nth()精确定位
    弹窗或 Toast 遮挡点击动画或消息未消失等“可点击”状态,用toBeEnabled
    请求超时但仍报错后端长链路异步无终态轮询终态接口后再断言页面

    5.2 排查定位:Trace 和视频怎么用

    跑挂之后我第一件事不是去翻代码,而是打开 trace 文件。Playwright 的 trace 记录了每一个操作前后的 DOM 快照、网络请求、控制台输出、页面变化。我通常按这个顺序排查:

    • 先看操作时间线,找到哪个步骤失败。
    • 再看失败步骤前后的网络请求,看接口是否返回异常状态码。
    • 然后看控制台日志,有没有 JS 报错。
    • 最后看元素拍板,确认定位器找到的是不是预期元素。

    有一次排查“支付成功页偶发空白”的问题,就是用 trace 看到支付确认请求发出去后并没有返回成功,前端误判为失败直接跳转,其实是后端回调数据库中状态更新慢导致的。如果没有 trace,这种问题光盯测试代码是永远查不出来的。

    5.3 稳定率优化的几个有效手段

    我现在维护的 e2e 套件稳定率可以做到 98% 以上,靠的都是细节。

    第一是屏蔽无用资源。前端页面经常会加载字体文件、统计脚本、第三方广告、视频封面等,这些资源会拖慢测试速度甚至导致误超时。用路由拦截直接屏蔽:

    await context.route('**/*.{png,jpg,jpeg,svg,woff2,ttf}', (route) => route.abort());

    第二是把断言的最小粒度定义清楚。断言文字出现、元素可见这样的大粒度就够,不要断言button的class是否包含某个样式类,那是把单元测试的思路带到了 e2e。

    第三是控制并发粒度。并行能提速,但过于激进的并发会打爆测试环境和后端服务。跑 profile1 时我会把 worker 数设为 4,全量回归设为 2,这个数是根据团队后端负载反复试出来的。

    5.4 团队协作里的隐性成本

    最后再提一个容易被忽视的点:e2e 测试不是写好就完了,后面要维护。如果团队里没有人持续跟进用例的稳定性和页面变化,e2e 套件会在三周内腐化到不可信。

    我见过一个项目,e2e 用例跑红了三个月,所有人都在点“重试”,最后没人敢信测试结果。要避免这种情况,除了控制用例数量外,还要在 CI 上做防退化机制:连续失败超过一定次数就报警,让代码作者而不是运维去处理。测试代码和产品代码一样要做 Code Review,定不了级的时候宁可删掉也不要留一个“总是失败但没人敢删”的坏用例。

    我自己现在的习惯是每次提测前,本地跑一遍 profile1 核心链路,把完整回归交给 CI。跑挂了就先看 trace,而不是直接点重试,多数“偶发失败”背后都藏着真实的问题。自动化测试不是目的,快速发现回归才是,把 e2e 的反馈控制在可信任的范围内,比堆用例数量重要得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进