ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于Playwright的E2E测试实践:从配置管理到稳定性调优

基于Playwright的E2E测试实践:从配置管理到稳定性调优

最近在复盘项目里的质量流程时,我翻了一下发版记录:每次提测前,光核心链路的手工回归就要花掉35分钟左右,照着checklist点一遍注册、登录、下单、退款,中间还得停下来等页面加载、切换账号。35分钟看着不算长,但一个月发四五次版,搭上环境部署和人工沟通,时间其实很可观。更让我印象深刻的是一件小事:仓库里有一份叫profile1的e2e配置文件,某个同事为了本地调试改了里面的baseURL,顺手就把改动提交到了develop分支。结果那天的CI上,所有端到端用例统统跑到了他的本地服务上,全部失败。配置文件的一次误改就能造成全团队回归瘫痪,这件事让我下决心把e2e测试的配置和用例重新梳理一遍,也才有了这篇分享。


1. e2e测试到底在补什么空子:研发视角的回归成本

1.1 单测和接口测都覆盖不到的那一层

很多团队其实已经有单元测试和接口测试,但发布前依然心里没底。原因在于这些测试是分层验证的,彼此之间没有串起来。单元测试验证一个函数、一个组件在给定输入下的输出;接口测试验证请求参数、响应结构和数据库变更。可用户真正操作的是一整条流程:填表单、点按钮、等跳转、看结果。这个流程里的前端逻辑、后端接口、第三方服务、权限判断一旦衔接出问题,前面两层测试往往是绿的,谁都没发现。

举个例子,注册页面同时调用前端校验、后端用户接口和短信验证码服务。前端单测覆盖了校验逻辑,接口测试覆盖了用户接口的幂等性,两边都绿。但真正在浏览器里操作时,因为短信服务在灰度环境返回了异常响应,页面卡在验证码环节,用户根本走不到注册成功。这类问题只有e2e测试能在发布前拦住。e2e的核心价值不是"多了一种测试手段",而是"把一个真实用户从入口走到出口的完整路径用代码固化下来"。它站在最高的抽象层,替产品经理、替运营、替最终用户把那条最核心的业务流守住。

1.2 profile1配置引发的事故,暴露了e2e的高敏感体质

项目里的profile1本来是一份很普通的配置文件,里面记录了测试环境和一套测试账号信息,谁都没太当回事。直到那次误改,大家才意识到,e2e测试脚本本身不复杂,难的是它依赖的外部条件太多了。e2e脚本是贴着用户视角跑的,所以它天然对网络、权限、时间、页面结构都非常敏感。baseURL改错了,脚本连测试环境都找不到;账号登录态过期,整组用例跟着挂;页面里某个接口延迟3秒,用例就可能在下一步点了个寂寞。

这个特性决定了,想用好e2e测试,必须把配置管理、测试隔离、框架选型当成一等公民来设计,而不是等到用例写多了再回头补课。下面的内容,就是我从选型到落地、从profile设计到稳定性调优的完整实践记录,大部分内容都是基于我们项目里的真实做法整理出来的,你可以直接拿去做参照。

2. 测试基础设施搭建:为什么选了Playwright

2.1 选型不是跟风:Playwright vs Cypress vs Selenium

能做e2e的框架不少,选型时我重点考察了三个:Selenium、Cypress、Playwright。先看一组核心差异对比:

维度PlaywrightCypressSelenium
浏览器支持Chromium、Firefox、WebKit以Chromium为主几乎所有浏览器
语言支持JS/TS、Python、Java、.NETJS/TSJava、Python、C#、JS等
多标签页/多用户原生支持,能开多个context较弱,多标签页受限原生支持,但API偏底层
自动等待web-first断言,智能等待内置重试,部分场景需手动需要开发者自己编写显式等待
调试能力Trace回放,录屏+网络+控制台Time-travel调试相对原始
网络拦截支持route/fulfill/abort支持需要搭配代理层

选Playwright最重要的理由是它对多标签页和不同浏览器内核的支持很从容。我做的项目里有很多场景需要打开新页面、切换用户身份,Cypress在这一块比较挣扎。Selenium虽然都能做,但写起来啰嗦,等待策略全靠个人经验堆出来的显式等待,新人上手容易写出"sleep战士"。Playwright的web-first断言,比如expect(locator).toBeVisible()这类写法,会自动等到元素可见再继续,直接把一大类时序问题消解掉了。团队技术栈本来就在TypeScript上,Playwright用起来无缝衔接,配置也是声明式的,门槛比另外两个低不少。

2.2 目录结构:把脚本、配置、夹具分开,不要揉成一团

基础设施不用一开始弄得很花哨,但目录边界要划清楚。我第一次搭的时候还把配置、测试、工具函数全放在一起,后来维护起来非常痛苦。痛定思痛,最后落地的目录大致是这样:

e2e/ ├── config/ │ ├── profile1.ts # 环境一:测试环境配置 │ ├── profile2.ts # 环境二:预发环境配置 │ └── env.ts # 读取环境变量的统一入口 ├── fixtures/ │ ├── users.ts # 测试账号数据 │ └── data.ts # 下单用的商品数据 ├── utils/ │ ├── login.ts # 登录工具函数 │ └── db.ts # 造数/清数工具 ├── tests/ │ ├── register.spec.ts │ ├── order-flow.spec.ts │ └── payment.spec.ts ├── playwright.config.ts └── package.json

config下面放的就是类似于开头提到的profile1文件。这个设计的核心思路是:环境相关的信息(域名、账号权限、超时时间)全部沉淀在config里,tests里只写业务步骤,fixtures里放数据。这样换一个环境跑,只改profile文件或传参,用例文件一行都不用动。第一次跑的时候不要贪多,先把两条核心链路跑通、把目录规范立起来,后面加用例是水到渠成的事。拿我们项目来说,这套结构承载了后续近百个用例,几乎没有因为目录问题返工过。

3. profile1配置:让一套脚本在多个环境间自由切换

3.1 一份profile里到底该放什么

很多人以为e2e配置就是写个baseURL。实际上进入实战之后你会发现,profile要承载的信息远比想象中多。下面这份配置是从我们当时使用的profile1中提炼出的关键结构:

// config/profile1.ts export const profile1 = { name: 'profile1', baseURL: process.env.E2E_BASE_URL || 'https://staging.example.com', users: { admin: { username: 'admin_e2e', password: process.env.ADMIN_PWD }, buyer: { username: 'buyer_e2e', password: process.env.BUYER_PWD }, guest: { username: '', password: '' } }, timeouts: { navigation: 30_000, action: 15_000 }, viewport: { width: 1440, height: 900 }, testData: { productId: 'SKU_888', couponCode: 'E2E_SALE' } };

baseURL不用多说,最关键的是不要写死在测试用例里,要能通过环境变量覆盖。这里有很多团队容易踩的坑:有人把测试环境的完整地址直接写在spec文件顶部,一旦环境迁移,所有用例都得改一遍。users这项对多角色测试特别关键,注册、下单、退款这些用例往往需要不同权限的角色,如果每个用例都临时注册一个新账号,时间成本和数据污染都受不了。所以我把常规的admin、buyer、guest账号固化在profile里,CI里通过环境变量注入密码,避免明文落库。timeouts和viewport属于"环境特性"——测试环境机器慢,UI操作超时就得留足余量;预发环境快一些可以收紧。这些参数一并放在profile里,切换环境时连执行节奏一起切换。

3.2 执行时如何拿到对应的profile

profile文件本身只是一份数据,关键是要在playwright.config.ts里把它接起来。我的做法是让配置文件根据命令行传入的项目名动态读取:

// playwright.config.ts const projectName = process.env.E2E_PROFILE || 'profile1'; const profile = require(`./config/${projectName}`).default; export default defineConfig({ testDir: './tests', timeout: profile.timeouts.navigation, use: { baseURL: profile.baseURL, viewport: profile.viewport, trace: 'retain-on-failure' }, projects: [ { name: projectName, use: { storageState: getStorageState(profile) } } ] });

这段代码里有几处值得重点圈出来。第一,projectName来自环境变量E2E_PROFILE,缺省就是profile1,所以本地开发者不配任何参数也能直接跑测试环境,减少上手成本。第二,storageState是用来承载登录态的:先登录一次拿到cookie存下来,后续用例复用它,能为整批测试省下一大半登录时间。第三,trace在失败时保留完整执行过程,这个能力后面排查问题时极其有用。

3.3 profile1不是唯一:多profile时如何防止误用

事故的根源往往不是没有配置,而是配置文件放得太散、命名太随意。吃过一次亏之后,我给仓库加了两条硬约束。第一条:所有profile文件必须放在config目录下,不允许在测试用例里new一个配置对象塞baseURL,也不允许提交时出现"本地地址覆盖线上配置"的情况。第二条:profile命名按环境职责来,不按同事姓名或机器来命名。后来我们加了profile2对应预发环境,profile3对应压测环境(关掉部分校验逻辑),命名直接表明用途。

执行前用一条命令列出所有可用的profile,也是一种廉价的自检手段:

npm run test:e2e -- --list

这条命令会打印出所有测试文件和可用的project。每次跑之前先list一眼,基本上能避免"又跑错环境"的低级失误。如果担心有人在本地IDE里用错了profile,可以在CI脚本里强制规定:没有显式指定E2E_PROFILE时,默认走profile1,并且对非预期profile路径直接报错。经过这轮约束,团队里再没有出现过"莫名其妙连到别人电脑上"的尴尬状况。

4. 核心业务场景用例拆解与元素定位策略

4.1 不要全量回归:高价值链路筛选法

上e2e测试最容易被带偏的方向是"把所有的功能都e2e一遍"。我见过有人一口气写了300条用例,结果每次跑都是红色瀑布,最后根本没人看,这套体系就被架空了。正确的做法是先画出主流程地图,然后挑出那些"坏了之后没人能及时发现、影响面又大"的链路。以我做的电商项目为例,第一版只挑了4条:

  1. 未登录用户浏览首页,搜索商品并查看详情。
  2. 登录用户加购并进入结算页,填写地址后提交订单。
  3. 订单提交后进入支付页,模拟支付回调成功后跳转订单详情。
  4. 管理员在后台将商品下架后,前端商品详情页立即出现下架状态。

这4条链路覆盖了项目里最核心的转化路径和权限边界,加起来不到50个用例。等这4条稳定跑绿两周,再去补退款、优惠券、发票等复杂场景,而不是一口吃成胖子。这条经验后来帮我们避免了很多无意义的维护成本:低价值用例对质量的贡献有限,却会在每次页面改动时出来刷存在感。

4.2 定位元素的最优解:统一约定data-testid

e2e用例里最容易崩的地方就是元素选择器。id和class会随样式重构而变,xpath长得可怕且一碰DOM就碎,CSS层级深了以后谁也看不懂。我们团队达成了一个硬性约定:凡是涉及交互的关键元素,按钮、输入框、跳转链接,统一在代码里加上><button>await page.getByTestId('submit-order').click();

用data-testid的好处是,前端做样式重构时只要不删这个属性,e2e用例就完全不受影响。从工程协作上这也是个很好的信号:产品、前端、测试三方对"核心交互节点"达成了一致,哪些组件对业务是关键的,在代码里有了显式标记。坚持这套约定后,我们几乎没再因为前端改class名导致用例报废。

4.3 第一个完整用例:从登录到提交订单

拿下单链路的前半段演示一下整体写法,这基本是当时跑通的第一条核心用例:

test('已登录用户可以从搜索到提交订单', async ({ page }) => { await page.goto('/'); await page.getByTestId('search-input').fill('机械键盘'); await page.getByTestId('search-button').click(); await page.getByTestId('product-card').first().click(); await expect(page).toHaveURL(/\/detail\/\d+/); await page.getByTestId('add-to-cart').click(); await page.getByTestId('cart-count').toContainText('1'); await page.getByTestId('go-to-checkout').click(); await page.getByTestId('address-input').fill('测试市测试区测试路88号'); await page.getByTestId('submit-order').click(); await expect(page.getByTestId('order-success')).toBeVisible(); });

这段代码里有几个细节是新手最容易忽略的。第一,page.goto('/')配合baseURL使用,而不是写死完整地址,这样才能让用例在不同profile之间自由迁移。第二,搜索结果页的断言用toHaveURL匹配正则,而不是去等一个不确定的加载动画,可靠性和速度都更好。第三,加购成功后的断言不是看接口返回,而是看购物车角标的文本变化,这是从用户视角做校验的思路,更贴近实际体验。整条用例没有sleep,全靠web-first断言驱动。

5. 稳定性调优:从"时好时坏"到"闭眼跑"

5.1 flaky的三大来源:网络、时序、状态残留

e2e最劝退的点就是flaky。用例跑十次绿八次,红得莫名其妙,最难受的是重跑又过了,查日志也查不出所以然。根据我这段时间的排查经验,flaky基本集中在三个来源:

  • 网络问题:测试环境带宽不稳定,静态资源加载慢,接口偶发timeout。
  • 时序问题:页面跳转过程中断言抢跑,元素还没挂载完就点了下一步。
  • 状态残留:上一个用例污染了全局状态,登录态、LocalStorage、后端数据,下一个用例踩雷。

针对这三个来源,调优策略也要对应着做:网络层面的问题靠retries兜底,时序问题靠web-first断言根治,状态残留靠测试隔离和独立测试数据解决。下面分开说。

5.2 等待策略:永远优先用web-first断言

很多从Selenium时代过来的人习惯用固定等待,比如page.waitForTimeout(3000)。这在慢的测试环境里反而会放大问题:等短了稳定不了,等长了拖慢整个套件。Playwright官方推荐的web-first断言,本质上是在轮询等一个条件变成真,效率远高于固定sleep。举个例子,下单成功页出现的按钮,固定等待是"等3秒再说",web-first断言是"最多等15秒,每0.1秒检查一次它是否可见,一旦可见立刻继续"。遇到加载依赖接口数据的组件,直接:

await expect(page.getByTestId('order-list')).toBeVisible();

如果组件里还有正在loading的骨架屏,就先断言骨架屏隐藏,再断言真实内容可见。这套策略落地之后,原来大量"时好时坏"的用例基本都稳定了。实测下来,同一批用例的执行时间反而缩短了20%左右,因为不用在每个环节都预留最长等待时间。

5.3 测试隔离:让每个用例都活在"干净房间"里

e2e测试最怕"孤军奋战全绿,合在一起就红",原因多半是用例之间没有隔离。我做的隔离处理分三层。第一层是登录态隔离:每个用例用独立的storageState,或者明确注销后再登录,不允许复用上一个用例留下的token。第二层是数据隔离:下单用例固定用测试账号,每个用例自己造数据、自己清数据,比如用接口在setup阶段创建一条专属订单,测试结束后调用清理接口删掉。第三层是环境隔离:同一份脚本跑profile1时绝不能碰到profile3的脏数据,测试库和预发库分离。

做这层隔离的成本不算高,但收益极为明显。我印象最深的一次是排查某个"随机失败":跑了半天,最后发现是上一个用例在购物车里留下了两件商品,把优惠券门槛给顶高了。数据清干净之后,这类问题彻底消失。

5.4 失败重试与Trace:不要怕失败,怕的是不可复现

即便做了隔离和等待优化,仍然会遇到个别用例因为第三方服务波动而失败。我的态度是:允许失败,但不能允许"失败原因看不到"。配置里把retries打开:

export default defineConfig({ retries: process.env.CI ? 2 : 0, reporter: [['list'], ['html', { open: false }]] });

retries在CI环境设为2次,本地调试不重试,保证快速反馈。同时trace默认retain-on-failure,用例一旦失败,会保留完整的浏览器操作录像、网络请求和控制台报错。排查这类问题时,我只要打开trace回放,就能看到到底是哪一步点慢了、哪个接口返回500、哪个元素没出现。很多原先完全没法查的失败,在这个能力下变成了一眼定位。

6. 实测数据与避坑总结

6.1 落地后的数据变化

经过两周的集中建设,第一版e2e测试正式承担起了回归职责。当时统计的一组数据可以参考:

指标落地前落地后
核心链路手工回归耗时约35分钟约5分钟(冒烟由脚本执行)
发版前发现联调问题次数靠人肉碰运气10次中有7次在CI阶段被发现
e2e用例数048条
平均执行时长无约12分钟(含重试)
flaky率(24小时内)无8%左右

48条用例对应的是前面说的4条核心链路。执行时间12分钟乍看不短,但对于发布前的质量保障来说,已经比人肉点一遍快得多,而且是机器在做,不会因为疲惫漏步骤。flaky率最高的时候到过8%,集中在第三方支付回调模拟和图片懒加载两个场景,这个比率在可接受范围内。后来我们专门针对这两块做了优化,把flaky率压到了5%以下。

6.2 踩坑清单:下面每个坑都花过时间

整理几个真正踩过的、容易复现的坑,给准备上e2e的团队当参考。

坑现象解法
profile配置误改CI上所有用例连到错误环境配置与代码分离,敏感信息用环境变量注入,CI校验profile
登录态过期整批用例报401后全红global setup里增加登录态有效性探测,过期自动重登
支付流程iframe找不到收银台元素使用frameLocator进入iframe后再定位元素
断言选择器错误用例"假绿"核心结果必须断言用户可感知的变化,比如按钮状态、文案变化
本地与CI时区不一致时间戳断言偶发失败统一在配置中固定timezoneId

第一个坑就是开头提到的profile1事件,解法上我做了配置与代码分离,并把敏感信息全部改为环境变量注入。第二个坑是登录态过期,storageState能撑多久取决于token的有效期,过期后整批用例直接红,后来在global setup里加了"登录态有效性探测",一旦发现401就重新登录再跑。第三个坑是支付流程的iframe,第三方收银台通常嵌在iframe里,用page对象直接操作是碰不到的,必须用frameLocator进入后再定位元素。第四个坑最隐蔽,断言写错了选择器但用例仍然通过,比如用expect(page.locator('h1'))去断言一个必然存在的静态标题,等于没有断言。我对团队的要求是:核心结果必须断言到用户可感知的变化,比如提交按钮从可点击变为不可用、订单状态文字变化。第五个坑是时区不一致,生成订单时间戳断言偶发失败,最后统一在配置里固定timezoneId解决了。

6.3 给想上e2e的团队三个建议

如果团队还在观望,我的经验总结下来就是三条。第一,不要一开始就铺全量用例,挑出Top 5的核心路径做深做透,优先保住最值钱的业务流,等跑顺了再扩展。第二,配置和隔离机制先于用例建设,否则用例越多维护成本越高,最终一定会被弃用。第三,把e2e测试接入发布流水线的必经环节,让它承担"每次发版前的最后一道关卡",而不是有人想起来才手动跑一次。只有把它放在流程里,团队才会真正重视它的结果。


这套体系跑了两个月后,最大的变化其实不是"bug变少了",而是团队对发版的信心变了。以前发版前总觉得哪里会炸,现在至少核心链路有机器在兜底,人只需要关注少数的异常失败。如果你也在项目里纠结要不要引入e2e,我的建议就一句:先挑一条核心链路跑通再说,别等所有条件都完美。跑通了,后面的事情会一环扣一环地顺起来。

RELATED READING

延伸阅读

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