☰
一次线上事故引发的端到端测试实践:Playwright、环境配置与流水线
2026/10/27 4:31:56 网站建设 项目流程

上周有个做电商后端的朋友找我,说他们上线了一个新促销活动,结果当天晚上用户付款成功后订单状态卡在“处理中”,客服直接被问爆。我去看了一眼代码:不是支付网关的问题,也不是数据库的问题,而是前端提交订单时调用了一个刚拆出来的结算接口,异常分支里返回了200,却漏掉了状态同步。单元测试覆盖了结算逻辑,接口测试也验证了输入输出,可没有一条用例跑过“用户从商品页点到订单页再完成支付”的完整链路,问题就这么溜上线了。

这种情况我见过太多次。很多团队不是不知道e2e测试,而是觉得它太重:环境要搭一整套,脚本容易碎,跑起来动不动就挂,所以一直拖。这篇文章不打算从概念课讲起,也不只是丢给你一个工具链接。我想从这次事故引出几个绕不开的问题:e2e到底应该在什么场景下出现、工具怎么选、测试环境怎么管、用例怎么写才不容易碎、最后怎么塞进流水线。如果你正在给团队搭端到端测试,或者已经被用例的稳定性搞到头秃,这篇里的经验应该能直接抄一部分。

1. 端到端测试真正要解决的事:一次线上事故的复盘

1.1 单测和接口测试都过了,订单还是卡在“处理中”

那次事故里,后端同事其实很委屈。他说结算服务单测覆盖率高,联调用例也有,为什么还能出问题?因为问题根本不在一段代码里,而在几段代码的衔接处。

真实场景是这样的:下单接口在促销活动里增加了一个“赠品校验”的步骤,校验失败时返回了200和一段提示文案,前端拿到之后跳过了原本的状态查询逻辑。单测测的是校验函数本身,入参出参都对;接口测试测的是直接调用下单接口,看返回体是否符合预期;可整个页面上从点击“提交订单”,到前端根据返回码决定要不要继续查询订单状态,这中间的行为没有谁去验证。

e2e测试擅长抓的就是这种问题。它不关心你是200还是201,它关心的是用户看到的结果:“下单之后,订单到底是不是真的变成了支付成功”。如果页面上显示还是“处理中”,那这条链路就是坏的,不管中间哪一环声称自己没问题。

1.2 从浏览器到数据库,e2e把一个完整用户旅程当实验对象

很多人理解e2e,以为就是“用自动化脚本模拟点几下按钮”。这个理解方向对,但程度不够。模拟点击只是手段,真正的价值在于把一条用户路径从头到尾跑通:注册、登录、搜索、加购物车、下单、支付、回调、查看订单状态,这里面每一步都涉及前端路由、浏览器存储、网络请求、后端接口、数据库状态,甚至是第三方服务。

你可以把e2e想象成一场完整的婚礼彩排。单测相当于确认戒指准备好了,接口测试相当于确认司仪台词没记错,但真正会不会出乱子,要看全部环节配合走完一遍。线上环境每次发布都是一场正式婚礼,可如果从来不做彩排,出问题几乎是概率事件,只是时间早晚。

1.3 为什么每个e2e项目都需要“profile”这套环境概念

我刚开始做e2e的时候,最头疼的不是写脚本,而是不知道现在在测哪套环境。今天连的是本地,明天连的是测试环境,后天一疏忽连到了预发布。不同环境的域名、账号、数据状态完全不一样,脚本没过,根本不知道是代码坏了还是环境不对。

所以后来我引入了一个概念:给每个运行环境定义一个profile,也就是一套完整的运行配置。在Playwright里叫project,在Cypress里可能是配置文件里的一个环境块,本质是一样的。

你可以简单地理解成:profile就是一个盒子,里面装好了“这次e2e要连哪里、用哪个账号、模拟什么样的浏览器、要不要保存登录状态”这些参数。我这边最常用的有profile1、profile2、profile3三套:

  • profile1:本地联调环境,对应localhost,跑得最快,适合开发自测
  • profile2:集中测试环境,连staging,数据由脚本自己准备,适合每天定时回归
  • profile3:预发布环境,只在发布前跑,跑完还需要看数据清理情况

在后面写用例的部分,我会用profile1作为示例,给你看一个实际的配置长什么样。这里先记住结论:没有profile这套东西,e2e跑起来就是碰运气;有了它,同一份用例才能稳定复现。

2. 工具选型与环境基建:我为什么把Playwright设为默认答案

2.1 Playwright、Cypress、Selenium三者的差异和选择维度

聊工具之前,先明确一点:e2e工具没有绝对的好坏,只有适不适合你当前的场景。我拿一个常见的对比表格来说明,后面再讲我的选择逻辑。

对比项PlaywrightCypressSelenium WebDriver
语言支持JavaScript/TypeScript、Python、Java、.NET仅JavaScript/TypeScript多种语言
多标签页/多浏览器上下文原生支持,方便模拟多用户较弱,很多场景要插件基础支持但配置繁琐
自动等待机制内置actionability检查,基本不用手动sleep内置重试,理念接近主要靠显式等待,容易写出一堆等待代码
调试能力trace viewer、录像、截图、浏览器上下文快照时间旅行式调试,出名好用依赖第三方工具和日志
并行执行原生支持多worker、多项目并行付费版才有较强并行能力要自己搭网格
移动端模拟内置设备模拟,包含PWA交互相对受限需要Appium配合,复杂

我最初用Selenium写过不少用例,后来切到Cypress,再后来把主力方案换成了Playwright。原因不是Cypress不好,而是我团队的用例需要同时覆盖三个浏览器,且要在同一个交付流水线里按profile并行跑很多套环境。Playwright能让我把“环境配置”和“用例代码”彻底分离,换一个配置就能换一套环境跑同一份用例,这对维护成本的影响是决定性的。

选型还有一个容易被忽略的维度:团队里谁在维护用例。如果主要是前端开发维护,JavaScript和生态最丰富;如果主要是QA维护且更熟悉Java,那Selenium未必不能选。工具只是手段,可持续维护才是目的。

2.2 独立测试环境:没有稳定的数据,就没有稳定的用例

很多e2e用例跑着跑着就挂了,第一个原因就是数据污染。你以为你点了下单按钮,页面弹出了商品信息,可实际上那件商品已经被上一个用例抢购完了。你以为可以用固定账号登录,可那个账号的订单记录累积了几百页,每次定位元素的速度都不一样。

所以环境准备里我最看重三件事:环境独立、数据可控、账号隔离。

环境独立,指的是e2e用的测试环境最好和生产、开发日常环境分开。最轻量的办法是用docker compose把一套服务和数据库在CI机器上单次拉起,跑完直接销毁。这样每次跑用例,都是从一张白纸开始的,不会受别人调试数据的影响。

数据可控,指的是测试数据不应该靠人工预先准备,而是由脚本自己通过API或数据库seed生成。比如下单用例需要一件有库存的商品,那就在用例前置步骤里先调用商品服务创建一个商品,再进入页面购买。这套数据相当于每个用例的“专属道具”。

账号隔离,指的是不要去共享同一个管理员账号。并发用例里A在改配置、B在用这个账号登录,状态一冲突,谁也说不清是谁干的好事。现在我的做法是,测试环境里放一批专用账号,profile配置里给每个用例模块分一个区段的账号。

2.3 用profile1这样的配置同时管理多套环境

直接给你看一段我在Playwright项目里的profile1配置,读完你应该能理解我刚才说的“环境封装”是什么意思。

// e2e/config/profile1.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ timeout: 30_000, expect: { timeout: 10_000, }, use: { baseURL: 'http://localhost:3000', viewport: { width: 1280, height: 720 }, locale: 'zh-CN', storageState: './auth/profile1.json', screenshot: 'only-on-failure', video: 'retain-on-failure', trace: 'retain-on-failure', actionTimeout: 15_000, }, projects: [ { name: 'chromium', use: { browserName: 'chromium' }, }, ], });

这段配置里有几个字段特别关键:

  • baseURL:所有page.goto('/products/123')里的相对路径都会拼到这个域名上。切换环境只要换这个字段。
  • storageState:从一个指定的JSON文件里恢复登录状态,其实就是一个套餐cookie和token的保存文件。跑登录用例时把它记录下,之后的用例就不需要每次从头登录,速度提升非常明显。
  • timeout和expect.timeout:定的是用例整体超时和断言重试等待。这两个值宁可给大一点,也不要在不稳定网络上硬抠速度。
  • video和trace:全设为retain-on-failure,也就是只在失败时保留录像和追踪。这是后面排查失败问题的重要素材。

profile2、profile3在结构上和profile1完全一样,只是baseURL指向的域名不同,storageState里装的账号不同。跑的时候通过命令行指定:

npx playwright test --config=config/profile1.ts

这就是为什么我在前面强调profile这套机制。你的用例代码可以一句不改,环境变了,换配置文件就行;用例挂了,看录像和trace就能定位问题。这套体验是Selenium时代难以想象的。

3. 手写一个可维护的e2e用例:下单支付主流程

3.1 目录结构和用例粒度:一个业务链路对准一条用例

先说说目录怎么摆。一个清晰的项目结构,能让你三个月后回来看代码时不用靠猜。

e2e/ config/ profile1.ts profile2.ts profile3.ts auth/ profile1.json profile2.json pages/ login.page.ts cart.page.ts checkout.page.ts data/ products.ts tests/ login.spec.ts order.spec.ts utils/ api-helper.ts pom.xml # 或package.json

我见过很多e2e项目死在滥用用例上:一个页面被拆成十个用例,每个用例只验证一个按钮能不能点、一个文案显不显示。这样看起来数量很多,覆盖很全,实际上用例之间互相依赖,公共步骤重复执行,跑一次半小时起步。

在我的标准里,一条e2e用例应该对应用户的一条完整业务链路。比如“完成一次下单支付”,它应该覆盖从进入商品页到最终看到支付结果的全过程。业务链路内的小步骤,用测试内局部函数拆开,而不是拆成独立用例。这样每个用例的可读性高,失败时的定位成本也低。

3.2 步骤拆解:等按钮、交订单、等结果而不是睡三秒

下面是一条order.spec.ts的示例,拿Playwright风格写的,核心流程就是用户下单:

import { test, expect } from '@playwright/test'; test('用户从商品页完成下单并看到支付状态', async ({ page }) => { // 来到商品详情页 await page.goto('/products/1001'); // 加入购物车并进入结算 await page.getByRole('button', { name: '加入购物车' }).click(); await page.getByRole('link', { name: '去结算' }).click(); // 填写收货信息 await page.getByLabel('收货人').fill('张三'); await page.getByLabel('手机号').fill('13800000000'); await page.getByRole('button', { name: '提交订单' }).click(); // 等待支付接口响应,并校验页面最终状态 const paymentResponse = page.waitForResponse( (res) => res.url().includes('/api/payment') && res.status() === 200 ); await page.getByRole('button', { name: '确认支付' }).click(); await paymentResponse; await expect(page.getByText('支付成功')).toBeVisible({ timeout: 10_000 }); });

这段代码有三个细节值得展开说。

第一个细节是选择器。这里没有用#app .btn-pay这种CSS路径,也没有用>

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

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

立即咨询