ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

E2E测试稳定实践:从Playwright到profile场景配置

E2E测试稳定实践:从Playwright到profile场景配置

做端到端测试这几年,我踩过的坑比写过的用例还多。今天不聊那些面试八股文式的概念,就说说一个真实项目里,我是怎么把E2E测试从一团乱麻梳理成一套能稳定跑、能真正拦住线上事故的体系的。如果你也在为“测试用例老是不稳定”、“跑一次全量要一个多小时”、“上线前手点一遍流程才敢发”这些问题头疼,这篇应该能给你一些参考。

1. 内容整体设计与思路拆解

1.1 先搞清楚E2E测试到底在测什么

很多人一提端到端测试,就觉得是“把用户能点的都点一遍”。这话对,但不全对。我更喜欢把E2E测试理解为模拟真实用户完整走完一条业务链路,验证系统的最终产出是否符合预期。它不是功能点的大杂烩,而是用户故事的有序串联。

比如一次电商下单流程,单元测试关心的是“优惠券计算函数能不能正确算出金额”,接口测试关心的是“下单接口收到参数后能不能正确返回订单号”,而E2E测试关心的是“用户从搜索商品、加购、登录、填地址、选支付、下单到最终看到订单成功页面,这整个链路是否真的跑通了”。

这个区别很关键。E2E测试的价值不在于发现某个函数写错了,而在于发现模块之间的协作是否顺畅。说得难听点,就算每个微服务单独测都没问题,联调的时候照样能给你出幺蛾子——不是参数对不上,就是字段少了一个,再不就是某个页面根本打不开。

1.2 为什么说E2E测试是质量保障的“最后一道防线”

我见过不少团队对E2E测试的态度很矛盾:明知道重要,但又嫌弃它慢、不稳定、维护成本高。这种矛盾其实源于一个根本误解——把E2E测试当成了发现bug的工具。

真正的E2E测试,更大的价值在于防止回归。当系统已经稳定运行,新需求要上线时,如何确保改动没有破坏已有的核心路径?靠人工回归测试当然可以,但人的注意力和耐心都是有限度的。我更倾向于把一些公司最核心的业务链路用E2E用例固化下来,每次发版前自动跑一遍,确认没有改变用户的核心体验。

打个比方,E2E测试就像家里看门的摄像头。它不是用来抓小偷的(那是警察的事),而是用来保证你不在家的时候,家里没进陌生人。你把核心业务流比作“家门”—一但被人撬开,损失巨大,所以这个摄像头必须24小时在线。

1.3 选择合适工具链背后的取舍逻辑

工具选型是E2E测试的第一步,也是最容易翻车的一步。我试过Selenium、Cypress、Playwright,最后稳定地站在了Playwright这边。但这不是说Playwright天下第一,而是说它最契合我目前项目的实际诉求。

先说说Selenium。它是老牌工具,生态成熟,支持Java、Python、C#等几乎所有主流语言。但它的缺点是慢,尤其是用了Selenium Grid做分布式执行后,那网络开销和等待时间抱在一起,真能把人跑脱力。还有它处理动态页面比较费劲,写出来的选择器动不动就爆红,维护成本极高。

Cypress在开发体验上做了很多优化,自带的测试运行器非常好用,能看到每一步的实时状态,拍错也很方便。但Cypress跑不了多标签页,也不支持多域名场景,这对一些复杂的业务流就力不从心了。如果你只是测一个纯前端应用,Cypress很合适;要测的系统涉及多个子域名的跳转,就得慎重考虑。

Playwright是近几年才冒头的新玩家,但它的设计思路就明显更像“现代页面”的产物。它支持Chromium、Firefox、WebKit三大内核,天生就是为了处理复杂Web应用而生;多页面、多域名的场景它天然支持;自动等待机制比Selenium智能得多,基本不用手动sleep;还有极其好用的codegen(代码生成)功能,能直接把浏览器里的操作录制成代码。***

我现在的默认选择:新项目直接用Playwright,老项目如果已经用Cypress跑得稳定,也没必要非要迁移。

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

2.1 测试场景选取的“二八原则”

刚开始搭E2E的时候,我们组的踩坑路径是:恨不得把系统所有功能都写成E2E用例。结果就是维护成本暴增,一有迭代就嚷嚷改不动。后来我总结出一条“二八原则”:80%的收益,来自20%的核心场景。

做优先级排序时,我通常会考虑这几个维度:

  • 该链路是否是用户最高频的操作路径?
  • 该链路是否涉及真金白银的业务?比如支付环节
  • 该链路是否跨越了多个子系统或微服务?
  • 该链路是否经常出现在线上事故反馈中?

按这个标准,我们当时排出来的Top级场景就是:注册登录、商品搜索加购、下单支付、订单状态流转。这四个场景撑起了全公司一大半的线上核心流程。至于后台用户管理、权限配置、报表导出这些,要么交给接口测试,要么按低频告警处理,没必要占用宝贵的E2E执行时间。

2.2 测试数据管理:不只是“造数据”那么简单

E2E测试之所以不稳定,很大一部分原因是测试数据在“互相打架”。每个用例都向环境里插数据,跑完不清理,下次再跑就可能撞上脏数据。

我踩过最典型的一个坑:测试环境的数据库里积累了海量垃圾订单,导致列表接口越查越慢,潜在地拖垮了整套用例的执行速度。后来整改,推行了一组“数据隔离”策略:

  • 用例之间不共享可变数据。每个用例必须自造自己的数据,哪怕是0.01元的测试订单
  • 跑完即清。在用例的afterAll钩子里删除自己创建的数据或用API清理
  • 只读数据统一从“种子数据池”读取。比如登录账号、商品ID这类固定数据,提前准备好一批,不与数据变更操作混在一起
  • 时间敏感数据用相对时间。不要硬编码“2024-01-01”这种日期,用当天或相对时间才能避免过期失效

这是一场“垃圾分类运动”——麻烦但必须做,做完了清爽得能闻到香味。

2.3 选择器策略:别让细节毁了稳定性

说到E2E测试的“细节”,选择器是绕不开的一道坎。很多新手喜欢用XPath,因为它在浏览器里右键复制即可得到,太方便了。但这类选择器有一个致命的缺点:只要前端结构稍微一变,比如某个div嵌套层数调整,XPath就失效了。

我的建议是分层制定选择器策略:

  • 优先为关键交互元素添加>e2e/ ├── config/ │ └── playwright.config.ts ├── pages/ │ ├── login.page.ts │ ├── product.page.ts │ └── checkout.page.ts ├── tests/ │ ├── login.spec.ts │ ├── order.spec.ts │ └── payment.spec.ts ├── utils/ │ ├── api.helper.ts │ ├── data.generator.ts │ └── db.cleaner.ts └── package.json

    这种结构把页面操作(pages)、用例(tests)、辅助方法(utils)分得清清楚楚。如果用例想读操作步骤,能直接到pages里去读;想读数据怎么造,去utils里找。

    3.2 初始化项目与基础配置

    建立一个新项目很简单,但有几个关键配置我得单独说一下。

    npm init -y npm install -D @playwright/test npx playwright install

    然后是playwright.config.ts的写法:

    import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 60000, fullyParallel: true, reporter: [['html', { open: 'never' }], ['list']], use: { baseURL: process.env.E2E_BASE_URL || 'https://staging.example.com', trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', locale: 'zh-CN', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], });

    这里每一项配置都有讲究:

    • fullyParallel: true:有多个测试文件的时候,每个文件会独立跑在独立进程中,速度更快,效率更高。
    • timeout: 60000:单个测试的最长执行时间为60秒。超过了就判定失败,不至于让整个流水线卡死在某个页面加载上。
    • trace: 'retain-on-failure':失败时自动抓取追踪信息,包含网络请求、控制台日志、页面快照。排查问题的时候简直是救命稻草。
    • locale: 'zh-CN':因为我测的是中文系统,让浏览器用中文语言环境启动,避免出现区域设置不符导致的展示异常。

    3.3 实操演示:完整的下单支付链路

    我先写一个页面对象,把某电商系统下单页封装起来:

    import { Page, Locator } from '@playwright/test'; export class CheckoutPage { readonly page: Page; readonly addressInput: Locator; readonly paymentMethodRadio: Locator; readonly submitOrderButton: Locator; readonly orderSuccessTitle: Locator; constructor(page: Page) { this.page = page; this.addressInput = page.locator('input[data-testid="address"]'); this.paymentMethodRadio = page.locator('input[name="paymentMethod"]'); this.submitOrderButton = page.locator('button[data-testid="submitOrder"]'); this.orderSuccessTitle = page.locator('h1[data-testid="orderSuccess"]'); } async fillAddress(address: string) { await this.addressInput.fill(address); } async selectPaymentMethod(method: string) { await this.paymentMethodRadio.check(); // 上面这样写是错的,radio是多选一,所以要按value来选 } selectPaymentMethodByValue(method: string) { return this.page.locator(`input[name="paymentMethod"][value="${method}"]`).check(); } async submitOrder() { await this.submitOrderButton.click(); await this.orderSuccessTitle.waitFor({ state: 'visible', timeout: 15000 }); } }

    这段代码里有几个值得注意的细节:

    我虽然定义了paymentMethodRadio,但实际选支付方式的方法名是selectPaymentMethodByValue,因为radio不像文本框那样用fill,它是要勾选的,勾哪个取决于value。直接对一个radio调用.check(),浏览器是懵的,它会报一个不明确的错误。这也是我一个新手同事踩过的坑,他花了快半小时才弄明白为什么明明正确点了radio,断言却一直不过。

    还有submitOrder里那句waitFor({ state: 'visible' })。点击提交订单后,系统要走网络请求、逻辑校验、返回成功页,这个过程不是瞬间完成的。这里等待15秒的成功页可见,是为了保证断言前页面已经加载完成,而不是断言跑得太快,拿到了空白页面。

    3.4 数据生成与API预置

    E2E不能只靠前端页面在界面上一步步造数据,那样太慢了,也太容易出错了。我的做法是:能用API预置的数据,绝不多花页面操作。

    import { request } from '@playwright/test'; export async function createUserViaApi(userData: any) { const context = await request.newContext({ baseURL: process.env.E2E_API_URL, }); const response = await context.post('/api/users', { data: userData, }); if (!response.ok()) { throw new Error(`创建用户失败: ${response.status()}`); } return response.json(); }

    底层逻辑是:有些数据其实是测试用例的“前置条件”,而不应该成为被测对象。比如下单前要有一个已登录用户、一件在售商品。这些完全可以通过API直接造好,再让E2E用例从造好的数据状态下开始,等于把E2E的人力聚焦在核心链路验证上。把时间烧在登录表单的反复输入上,纯属自虐。

    3.5 执行与报告解析

    跑一次全量:

    npx playwright test

    只看某个测试文件:

    npx playwright test tests/order.spec.ts

    带界面调试模式运行:

    npx playwright test --headed

    跑完之后,HTML报告会直接给出哪些用例通过、哪些失败、失败日志、截图和trace。我一般先看失败用例的截图,如果能一眼看出是页面没加载出来,那就是环境问题;如果是断言文本不对,那就是功能真的坏了。

    一定要花时间看的那份报告,叫trace viewer。它能回放整个浏览器的操作过程,包括每一步的DOM快照和网络请求状态。排查那种“本地过、CI挂”的薛定谔式问题,这份报告能省你半天。

    4. 常见问题与排查技巧实录

    4.1 测试不稳定:今天绿明天红,怎么办

    这是E2E团队的“第一大敌”。用例自己跑没问题,一上流水线就翻车,大概率是环境资源紧张导致响应慢。针对这个我总结过一套排查清单:

    • 先看失败时的截图:是白屏的话,多半是前端资源没加载好。
    • 再看trace里的网络请求:是否某个接口请求超时或返回500?是后端问题。
    • 如果截图和trace都正常,再看是不是数据冲突:尤其是用了共享账号,前一个用例改坏了数据。
    • 最后确认等待策略:如果用了waitForTimeout(3000)这种硬编码等待,它必然会在某个环境下跑挂。正确的做法是断言等待,用expect(locator).toBeVisible()这种自动重试的等待机制,让测试自己判断。

    还有个很土但非常有效的方法:把用例重跑3次,看失败的是不是同一个用例的同一个位置。如果是,大概率是逻辑问题;如果每次错的位置都不同,基本可以断定是环境或数据问题。

    4.2 定位“偶发性失败”的黄金组合

    我现在的标准配置是:

    use: { screenshot: 'only-on-failure', trace: 'retain-on-failure', video: 'retain-on-failure', }

    三个一起上,基本能把所有偶发问题钉死在证据里。尤其是video,用来补截图的盲区。有的问题在静态截图里看不大出来,但一播放视频就能看出它到底是点击前就失败,还是点击后跳转失败了。

    4.3 执行时间越来越长,怎么优化

    E2E跑全量越来越慢是必然趋势,别慌,属于正常现象。优化手段在我这有等级的:

    • 一级优化:多浏览器并行(把项目里启用的浏览器从1个扩到2~3个)
    • 二级优化:跳过不必要重跑的用例,比如变更只涉及后端接口,那就只跑非强依赖前端的功能路径
    • 三级优化:把稳定的用例标记为慢用例,单独放一批在夜间跑,白天只跑冒烟集

    冒烟集的概念值得单独说:它是一组耗时最少但覆盖最核心逻辑的用例。我们一般控制在8~10分钟内,保证在开发自测阶段能快速反馈关键链路是否OK。全量用例留给nightly schedule。这样既保证速度,又不漏重点。

    4.4 页面对象模式的重构与收敛

    到后期,页面对象这个类会越来越庞大。一开始我没管好,结果每个页面的类都膨胀到几百行,什么方法都有,明显失控了。后来我做了一次重构,把页面对象按“操作意图”收拢:

    • 凡是动作类的方法都统一命名,如openPage、searchProduct、addToCart
    • 凡是状态类的查询都统一命名,如isUserLoggedIn、getCartCount、getOrderStatus

    这样一来,用例代码读起来特别像一份用户指南,测试维护的时候不用猜方法在干吗了。

    4.5 跨环境执行配置:本地和CI跑同一套用例

    最后讲一个容易被忽视的配置维度:环境切换。测试环境的地址、测试账号、API地址在不同场景下基本都不同,如果把这些硬编码进代码里,换环境就要改代码后再提交,麻烦得要命。

    我用环境变量的方式:

    E2E_BASE_URL=https://staging.example.com npx playwright test

    在playwright.config.ts里读的是process.env.E2E_BASE_URL,没设置时给一个默认值。这样本地跑用默认值,CI上通过secret配置注入真实地址,代码完全不用改。

    5. 关于E2E profile1与场景配置的进一步思考

    聊完上面这些具体操作,再回到搜索热词里提到的“e2e profile1”。在我们实际工作里,这个词往往对应着某种测试环境配置档案。一条业务链路可能在多个profile(配置组合)下运行,比如profile1对应标准浏览器环境、普通用户权限、正式账号池;profile2则可能对应弱网环境、旧浏览器版本或特殊权限组合。

    我建议每个团队在搭建E2E测试体系时,都建立自己的“profile矩阵”。这样做的好处是,避免把所有用例都绑定在一个环境配置上。比如前端经常有A/B实验或功能开关,同一条订单链路,在开启某个新功能开关时,页面交互可能就变了;如果不为这种开关单独维护profile,测试就会真假掺半。

    具体操作上,我在Playwright项目里就是用一个配置文件定义多组baseURL、多套cookies、多个用户角色,然后在用例描述里标注它需要哪个profile。跑的时候选择对应的project即可。

    projects: [ { name: 'profile1-chromium', use: { ...devices['Desktop Chrome'], baseURL: 'https://staging.example.com' } }, { name: 'profile2-firefox', use: { ...devices['Desktop Firefox'], baseURL: 'https://staging2.example.com' } }, ]

    这个模式用了很久,稳定且清晰,强烈推荐。

    再说回稳定性。无论用哪个profile,数据独立性永远是第一位的。有些团队把“共享登录状态”写在全局hook里,看似省时间,实则埋雷。一个用例改了密码,后面全线崩盘的事我见过不止一回。所以即便加了profile,我还是坚持用例独立的登录逻辑。慢那几秒钟,远远划算过半夜爬起来处理故障的代价。

    最后再分享一个小技巧:跑之前先保存一份“黄金镜像”数据集。用固定的种子数据生成一批基础账号和商品目录,每次E2E执行都重置数据库到该镜像的初始状态,保证数据环境完全一致。这个习惯,把我们的用例稳定率从86%拉到了98%以上。你有空可以试试。

RELATED READING

延伸阅读

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