☰
从零搭建e2e测试体系:Playwright实战与踩坑指南
2026/10/26 6:59:24 网站建设 项目流程

前段时间我刚接手一个电商项目,最怕听到的话就是“接口测过了啊,为什么线上还是有问题”。那天订单详情页做了一次改版,后端接口全绿,单测覆盖率也不算低,结果上线后用户反馈按钮点了没反应、列表偶尔加载不出来、支付成功页面跳转丢单——一连串问题全冒出来了。我把那段时间的教训总结成一句话:你缺的其实不是更多单元测试,而是一套真正站在用户视角、把整条业务链路跑通验证的e2e测试。

e2e(end-to-end,端到端)测试,简单说就是模拟真实用户操作,从打开页面、点击、输入、提交,到最终看到预期结果,完整地走一遍流程。它解决的是“每个零件单独都是好的,但装到一起却跑不动”的问题。这篇文章我不打算讲太多概念,重点分享我是怎么从零开始把e2e体系搭起来、怎么选型、怎么写第一个用例、以及踩过的那些坑。

1. 为什么我决定给项目补上e2e这套安全网

1.1 接口测试全绿,线上还是出了问题:我的实际遭遇

那次订单详情页的故障让我印象特别深。后端接口用Postman手动测是通的,自动化接口测试也跑过了,前端组件单元测试基本覆盖了主要渲染逻辑。但用户实际使用时的场景是:先登录、再进入列表页、点开详情、切换SKU、加入购物车、跳转结算、发起支付——中间有几十个前后端交互步骤,任何一步的时序、状态、缓存出错都可能导致异常。

后来定位发现,问题出在前端对接口返回状态的处理上。某个字段在特定条件下返回空数组,前端拿不到数据后没有显示兜底文案,导致按钮点击后无响应。单测覆盖的是“组件接收到正常数据后的渲染结果”,接口测试覆盖的是“后端在参数正确时返回正确结果”,但“前端在异常返回下如何表现”这一层根本没有测试覆盖。这就是典型的测试断层。

1.2 单元测试、集成测试、端到端测试到底分别守护什么

很多人会把e2e理解成“多写几个自动化脚本”,但它的定位和单测、接口测试完全不同。用建筑来类比:单元测试是在检查每一块砖头是否合格,集成测试是看几面墙砌在一起会不会歪,e2e测试则是找个人住进这栋房子,从进门、开灯、做饭、洗澡全程走一遍,看能不能正常生活。

具体职责大概是这样的:

  • 单元测试:验证单个函数、组件、类的输入输出逻辑,追求的是快速和精准,跑一圈只要几分钟。
  • 集成测试:验证几个模块之间的交互,比如前端调用接口后数据是否正确传递、状态管理是否更新,通常需要mock外部依赖。
  • e2e测试:启动完整应用(包括真实后端、数据库、第三方服务),通过浏览器模拟用户操作,验证端到端业务链路是否符合预期。

e2e最大的价值是“以用户视角兜底”。很多跨模块的问题,只有把整个系统跑起来才能暴露。比如登录态过期后跳转是否正确、多个异步请求并发时UI是否错乱、浏览器前进后退会不会导致页面白屏,这些都不是单测能发现的。

1.3 e2e不是银弹:什么项目暂时不该上

但我也要泼一盆冷水。e2e测试成本高、运行慢、稳定性难维护,如果项目处于早期原型阶段,页面天天改、业务流程还没定型,这时候硬上e2e会变成沉重的负担。我见过团队花了两周写了几十条e2e用例,结果业务方向一变,一半用例直接报废,运维成本比收益还高。

什么样的项目适合上e2e?我自己的判断标准有几个:

  • 核心业务流程稳定,且高度依赖“跨页面、跨系统”的完整链路。
  • 回归测试频率高,每次发版都要手动点一遍主流程,人力成本已经让人想哭。
  • 业务价值足够大,比如支付、结算、下单、登录注册,出一次严重故障的影响远大于测试维护成本。
  • 团队有专人愿意持续维护测试脚本,而不是“写完就再也不管”。

如果你的项目还在频繁调整UI和交互流程,我的建议是先不要急,至少等核心链路稳定了再逐步引入e2e。它应该是质量体系的最后一道防线,而不是第一道。

2. e2e技术选型:Playwright、Cypress、Selenium,我为什么选Playwright

2.1 三套方案横向对比

市面上主流的e2e自动化工具无非就是Selenium、Cypress、Playwright三家,我当年也纠结了很久。先列一张实际对比表,再讲我的选择理由:

对比维度SeleniumCypressPlaywright
支持语言Java、Python、JS等多语言主要JavaScript/TypeScriptJavaScript/TypeScript、Java、Python、.NET
浏览器支持Chrome、Firefox、Safari等,但需要driver主要Chrome系,Firefox支持有限Chromium、Firefox、WebKit一网打尽
自动等待基本没有,大量靠手动sleep内置自动等待,比较好用内置智能自动等待,最省心
网络请求控制能力弱,需要中间层代理可以拦截但有一定限制原生支持路由拦截、mock接口,功能强大
调试体验截图、日志,一般时间旅行调试,比较直观Trace Viewer录制完整操作轨迹,非常强
并行执行依赖外部框架支持并行但受限于浏览器数量原生支持多worker、多浏览器并行
测试隔离依赖自己封装每个case自动清理通过browser context实现隔离,设计优雅

2.2 我选Playwright的私心与临界场景

我自己平时主要用JavaScript/TypeScript,所以Cypress和Playwright都在考虑范围内。真正让我倒向Playwright的有几个点。

第一个是浏览器覆盖能力。我们的产品经常要兼容不同浏览器内核,甚至需要跑WebKit内核做兼容验证。Selenium配Safari很折腾,Cypress对非Chrome系浏览器支持一直不太顺,Playwright直接把Chromium、Firefox、WebKit都打包好了,一条命令就能全跑。

第二个是网络请求的拦截能力。e2e最怕依赖第三方支付、短信等不稳定服务,Playwright的page.route()可以在测试里mock掉这些外部接口,让用例跑得又快又稳。这在做支付类场景时几乎是刚需。

第三个是browser context这套设计。每个测试用例都可以开一个全新的隔离环境,cookies、localStorage、权限状态互不干扰,这比Cypress的清理机制干净得多。后面讲数据隔离时我会详细说。

2.3 安装与初始化时容易被忽略的两个细节

Playwright安装本身很简单,一条命令搞定:

npm install -D @playwright/test npx playwright install

但这里有两个坑我必须提醒。

第一,npx playwright install默认会下载Chromium、Firefox、WebKit三套浏览器,体积不小,而且在国内网络环境下经常下载失败。如果你只在CI的Linux容器里跑Chrome,可以只装Chromium:

npx playwright install chromium npx playwright install --with-deps chromium # 自动安装系统依赖

CI环境里一定要加--with-deps,否则缺一堆.so库文件,启动浏览器时会报各种莫名其妙的错误。

第二,Playwright版本和浏览器内核版本是配套的,升级@playwright/test后必须同步执行npx playwright install重新下载匹配的浏览器版本。我见过有人只升级npm包,没重新下载浏览器,结果本地跑得好好的,CI里一跑就挂,查了半天才发现是版本错配。

3. profile到底是个什么概念:从热词“e2e profile1”说起

3.1 e2e里的profile:环境配置、设备配置、用户状态三类

如果你搜过e2e相关资料,可能会看到“e2e profile1”这种说法。这里的profile翻译过来就是“配置档”或“运行方案”,在e2e测试体系里通常指三样东西:

  • 环境配置:测试跑在哪个环境上,是dev、staging还是production,对应的baseURL、接口地址、账号权限都不同。
  • 设备/浏览器配置:用桌面Chrome、移动端Safari还是某种特定视口大小运行,不同设备下UI的表现可能有差异。
  • 用户状态配置:是否携带登录态、是否已授权地理位置、是否关闭了通知弹窗等。

比如“profile1”可以约定为“staging环境 + 桌面Chrome + 已登录用户”,而“profile2”是“dev环境 + 移动端 + 未登录用户”。这种命名方式不是硬性标准,核心目的是把一套可复用的运行参数打包成一个名字,方便在命令行、CI、配置文件里引用。

3.2 用project和profile组合管理多套运行方案

Playwright里最接近profile概念的就是projects配置。你可以给一个项目定义多个project,每个project本质上就是一套完整的profile:

// playwright.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', timeout: 30_000, retries: 2, use: { baseURL: process.env.E2E_BASE_URL || 'https://staging.example.com', trace: 'retain-on-failure', screenshot: 'only-on-failure', }, projects: [ { name: 'profile1-desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 }, storageState: './e2e/.auth/user.json', // 复用登录态 }, }, { name: 'profile2-mobile', use: { ...devices['Pixel 5'], storageState: './e2e/.auth/user.json', }, }, ], });

这样跑npx playwright test --project=profile1-desktop就只跑桌面端,跑--project=profile2-mobile就只跑移动端。如果只想跑某个用例文件,也可以叠加grep参数。

我习惯把环境相关的配置都放到环境变量里,而不是写死。比如E2E_BASE_URL指到staging还是dev,CI里用一套、本地用一套,互不影响。profile的价值就在这里:测试代码不需要改,只需要换一个配置文档,就能在完全不同的环境下运行同一批用例。

3.3 什么时候需要自定义浏览器上下文profile

除了用project管理运行方案,Playwright还允许你通过browser.newContext()手动创建自定义上下文,把一些固定状态提前塞进去。最常见的需求就是登录态复用。

如果每个用例都从登录页开始走一遍,跑一次全量回归要多花好几分钟。我的做法是先用Playwright的storageState把登录后的cookies、localStorage保存成文件,后续用例直接加载这个文件:

// global-setup.ts import { chromium } from '@playwright/test'; export default async function globalSetup() { const browser = await chromium.launch(); const context = await browser.newContext(); const page = await context.newPage(); await page.goto('https://staging.example.com/login'); await page.getByLabel('账号').fill('e2e_account'); await page.getByLabel('密码').fill('e2e_password'); await page.getByRole('button', { name: '登录' }).click(); await page.waitForURL('**/dashboard'); await context.storageState({ path: './e2e/.auth/user.json' }); await browser.close(); }

这个方案我从一开始就在用,实测下来每条用例至少省了10到20秒。但有一个前提:测试账号不能在生产环境乱碰,所以我只会让e2e跑在staging或独立的测试环境上,而且会用单独的测试账号,防止污染真实用户数据。

4. 从登录到下单:一个完整e2e用例的落地全过程

4.1 先拆场景,再写代码:用例设计步骤

很多新手写e2e最容易犯的错是“打开页面然后随便点几下,看看会不会报错”。正确的做法是先把业务场景拆成一个个可验证的步骤,每个步骤都要有明确的输入、操作、预期结果。

以“从登录到下单成功”为例,我一般这样拆:

  1. 打开登录页,输入测试账号密码,点击登录。
  2. 断言登录成功后跳转到首页,并显示用户昵称。
  3. 在搜索框输入商品关键词,点击搜索。
  4. 断言搜索结果页出现目标商品。
  5. 点击进入商品详情页,选择规格,点击加入购物车。
  6. 断言购物车角标数量从0变成1。
  7. 进入购物车页,点击结算。
  8. 在结算页确认订单信息,点击提交订单并支付。
  9. 断言出现支付成功页,并显示订单号。

每个步骤的“预期结果”就是后面写expect断言的依据。没有断言的e2e等于白跑——页面崩了它可能还报“测试通过”,因为根本没有任何校验。

4.2 编写第一个稳定可跑的e2e用例

这里我用Playwright写一个简化版的下单用例,覆盖上面的核心步骤,同时加了一个很实用的技巧:mock掉真实的支付请求,避免测试真的去调用第三方支付接口。

// e2e/checkout.spec.ts import { test, expect } from '@playwright/test'; test.use({ storageState: './e2e/.auth/user.json' }); test('用户可以从登录态完成下单', async ({ page }) => { // 1. 进入商品列表页 await page.goto('/products'); await expect(page.getByRole('heading', { name: '热销商品' })).toBeVisible(); // 2. 搜索商品 await page.getByPlaceholder('搜索商品').fill('机械键盘'); await page.getByRole('button', { name: '搜索' }).click(); await expect(page.getByText('机械键盘')).toBeVisible(); // 3. 进入详情页并加入购物车 await page.getByText('机械键盘 K870').click(); await page.getByLabel('选择颜色').selectOption('黑色'); await page.getByRole('button', { name: '加入购物车' }).click(); await expect(page.locator('.cart-badge')).toHaveText('1'); // 4. 去结算,mock支付接口 await page.getByRole('button', { name: '去结算' }).click(); await page.route('**/api/pay', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ orderId: 'E2E-ORDER-001', status: 'paid' }), }) ); await page.getByRole('button', { name: '提交订单并支付' }).click(); // 5. 断言成功页 await expect(page).toHaveURL(/\/order\/success/); await expect(page.getByText('支付成功')).toBeVisible(); await expect(page.getByText('E2E-ORDER-001')).toBeVisible(); });

这段代码里值得展开说的有三点。

第一,所有元素定位都用getByRole、getByLabel、getByPlaceholder这类用户可感知的定位方式,而不是XPath或CSS路径。原因是这些定位方式更接近真实用户的理解,元素结构调整时不容易断。如果你的页面没有合适的ARIA标签,退一步用>await page.locator('.loading-mask').waitFor({ state: 'hidden' }); await page.getByRole('button', { name: '提交订单并支付' }).click();

第二种情况是断言写在数据返回之前。最初我用expect(page.locator('.cart-badge')).toHaveText('1'),但偶尔加入购物车后接口返回特别慢,角标还是0就断言失败了。Playwright的toHaveText本身有自动重试,理论上不会因为慢而失败,但问题出在我加了一个await page.waitForTimeout(2000)这种固定等待在中间,反而让时序变得不稳定。后来我把这种固定sleep全部删掉,改成依赖自动等待和显式等待,用例稳定性立刻提升了不少。

第三种情况是搜索列表的接口偶发返回慢,商品卡片的图片都出来了但文字还没渲染。这里我原本用await page.getByText('机械键盘').click()直接点击,结果文字要晚几百毫秒才出现。我把断言和动作解耦,先等待目标商品卡片整体可见,再执行点击:

const productCard = page.locator('.product-card', { hasText: '机械键盘 K870' }); await productCard.waitFor({ state: 'visible' }); await productCard.click();

经过这两轮修复后,这条用例连续跑了大概20次都稳定通过,我才敢把它加进CI流水线。我的原则是:新用例必须先本地跑10次以上稳定,才有资格进CI,不然只是把不稳定带到流水线里。

5. 我踩过的坑和沉淀下来的操作清单

5.1 超时的根源:不是等太久,而是没等对东西

e2e新手最爱用的就是全局把timeout调大,或者写一堆waitForTimeout(5000)。但这治标不治本,还会让用例越跑越慢、越跑越随机。超时的本质是“你等的东西没等到,或者等错了东西”。

Playwright的自动等待是基于元素状态的:元素附加到DOM、元素可见、元素稳定、元素可接收事件。大部分场景下它已经足够智能,真正需要你手动干预的是那些“页面外部条件”,比如某个接口请求完成、某段数据写入localStorage、某个iframe加载完毕。

我的习惯做法是:

  • 等待接口响应,用page.waitForResponse()而不是sleep。
  • 等待路由跳转,用page.waitForURL()而不是waitForTimeout。
  • 等待元素出现,用locator.waitFor({ state: 'visible' })。
  • 等待异步数据变化,用expect.poll()或toHaveText这类自动重试断言。

一个典型例子:支付成功后,页面会先跳转到一个过渡页,再跳到订单详情页。如果只等toHaveURL(/order/success/),偶尔会因为过渡动画期间URL还没变化而超时。改成page.waitForURL('**/order/success', { timeout: 10000 })之后,问题就消失了。

5.2 数据清理与测试隔离

e2e测试最怕“上一次运行留下的数据影响下一次运行”。比如测试下了一个订单,第二次跑的时候购物车里还残留着上次加购的商品,角标断言从1变成了2,用例直接挂掉。

Playwright的browser context隔离能解决浏览器状态层面的干净,但解决不了服务端数据库里的数据污染。要保证用例可重复执行,有几个思路:

  • 每次测试开始前,通过API清理目标账号的订单、购物车数据。比如调用DELETE /api/users/{id}/cart/items之类的接口。
  • 测试账号独立使用,不与开发、验收账号混用,避免别人的操作干扰你的数据。
  • 对第三方接口统一mock,避免真实数据写进测试环境。
  • 如果实在无法清理,就在断言上设计得更具弹性,比如用expect(locator).toHaveText(/[1-9]/)而不是精确断言为1。

我之前吃过一个亏:测试账号只有一个,团队里好几个人同时在跑e2e,结果一个跑下单、一个跑退单,数据互相踩,天天收到红的跑失败的邮件。后来花了一天时间维护了一套独立的测试数据初始化脚本,每条用例跑之前先把账号重置到已知状态,世界才安静下来。

5.3 并行执行和CI集成:怎么跑得又快又不互相干扰

e2e测试速度不慢,但让几十条用例串行跑下来也要十几分钟。Playwright自身支持多worker并行,默认会按CPU核数分配,CI里也能用shard分片把任务分散到多台机器上。

但并行会引入一个新的麻烦:如果多个worker共用同一个测试账号,就会互相顶掉登录态。我的做法是:

  • 每个worker进程使用独立的浏览器上下文,cookies互不相通。
  • 如果用storageState复用登录态,文件读取是并发的,但不会互相修改,所以是安全的。
  • 如果测试需要写数据,尽量用mock或每个用例独立的随机数据(比如订单号加时间戳),避免争抢。

CI集成也很简单,以GitHub Actions为例,核心配置大概是:

name: e2e on: push: branches: [main] jobs: test: 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-desktop - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/

这里有个容易被忽略的点:CI机器上通常没有显示器,Playwright默认用headless模式没问题,但如果有人手滑改成了headful,跑到一半会报找不到display。所以CI里务必显式设置headless: true。

另外,retries配置我一般设置成2,第一次失败会带着trace自动重试两次。重试机制对网络波动引起的偶发失败很有效,但要注意不能把重试当成兜底——如果同一个用例在CI里靠重试才能过,那它本身就是不稳定的,该去修用例而不是盲目加次数。

最后再分享一个经验

如果让我回到刚开始搭e2e的时候,我最想告诉自己的就是:不要把e2e想象成“写脚本”,它更像是在养一个需要长期照看的孩子。刚开始跑通一个用例并不难,难的是它在三周后、三个月后还能稳定地告诉你“业务是好的”还是“业务坏了”。一套好的e2e体系,核心价值不是测试覆盖率数字有多好看,而是团队能放心地把发版按钮按下去。

我个人的操作习惯是:每写完一个新用例,先跑10遍看稳定性,然后强制要求自己看一遍trace,想象自己是那个正在操作页面的用户,检查每一步是否符合真实使用逻辑。这听起来慢,但恰恰是省下后续几个月排障时间的最好投资。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询