ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

e2e测试从概念到工程化落地:Playwright实战与profile配置指南

e2e测试从概念到工程化落地:Playwright实战与profile配置指南

1. 从“e2e”这三个字母说起:它到底指什么

第一次看到“e2e”这个标题,很多人会愣一下——三个字母,没有上下文,没有正文,没有关键词,像是谁在键盘上随手敲出来的。但如果你在软件工程、测试开发或者质量保障这条线上待过一段时间,就会知道这三个字母的分量有多重。e2e 是 end-to-end 的缩写,中文一般叫“端到端”。它描述的是一种测试视角,也是一种系统思维:从用户真正发起操作的那一端开始,一路穿过前端界面、网络请求、业务逻辑、数据存储,直到最终结果返回并呈现在用户面前的那一端,整条链路是否真的跑通了。

这件事听起来简单,做起来极其复杂。单元测试可以只盯着一个函数,输入 A 期望输出 B,边界清晰、速度快、定位准。集成测试稍微大一点,验证两个模块之间的接口是否对得上。但 e2e 测试不一样,它不关心你内部怎么拆分、怎么调用、用了什么设计模式,它只关心一件事:用户点下那个按钮之后,该发生的事情有没有发生。这种“黑盒到底”的思路,决定了 e2e 测试天然具备最高的业务置信度,同时也天然具备最高的维护成本和最慢的反馈速度。

那为什么还要做 e2e?因为单元测试全绿、集成测试全绿,线上依然可能挂。这不是段子,是很多团队真实经历过的事情。模块之间的契约在 mock 环境下成立,不代表在真实浏览器、真实网络、真实数据库、真实第三方服务下也成立。e2e 的价值就在于,它是最后一道用“真实”堆出来的防线。它不替代单元测试和集成测试,但它承担的是“整个系统作为一个整体能不能用”这个问题的最终回答。

“e2e profile1”这个热词的出现,其实透露了一个很实际的信号:大家不只是在讨论 e2e 要不要做,而是在讨论 e2e 怎么配置、怎么分环境、怎么管理不同的执行档案。profile 这个词在测试工具链里通常指一组配置集合——跑哪些用例、连哪个环境、用什么浏览器、开不开并行、失败重试几次、截图和视频怎么存。换句话说,讨论已经从“概念”下沉到了“工程化落地”。这也是这篇内容想聊清楚的事情:e2e 到底该怎么搭、怎么跑、怎么维护,以及那些只有真正跑过几百条 e2e 用例的人才会知道的坑。

2. e2e 测试的边界:它该管什么,不该管什么

2.1 为什么 e2e 不能替代单元测试

刚接触测试金字塔的人容易走两个极端。一个极端是觉得单元测试没意义,反正最后都要跑 e2e,不如直接写 e2e。另一个极端是觉得 e2e 太慢太脆,干脆全写单元测试,e2e 一条不写。这两种做法都会出问题,但第一种更危险,因为它会在项目中期直接把 CI 拖垮。

e2e 测试的运行成本是单元测试的几十倍甚至上百倍。一条单元测试跑完可能只要几毫秒,一条 e2e 要启动浏览器、加载页面、等待网络、执行交互、断言结果,几秒到几十秒都算正常。如果你把本该用单元测试覆盖的逻辑分支全部塞进 e2e,用例数量会爆炸,执行时间会从几分钟变成几小时,失败定位会从“第 37 行那个判断写反了”变成“某个页面某个元素没出现但不知道哪一层出了问题”。测试金字塔之所以是金字塔,就是因为底层要厚、顶层要薄。e2e 应该覆盖的是核心用户旅程和关键业务闭环,而不是每一个 if-else。

那什么算核心用户旅程?举几个典型例子:用户注册并完成首次登录、用户搜索商品并下单支付、用户上传文件并看到处理结果、用户修改密码后旧密码失效。这些链路的共同特点是——跨多个模块、涉及真实数据流转、一旦断了业务就受影响。这些才是 e2e 该盯住的东西。

2.2 e2e 覆盖率的真实含义

很多人会问“e2e 覆盖率做到多少合适”。这个问题本身有点陷阱,因为 e2e 覆盖率不像代码覆盖率那样有明确的分子分母。你说覆盖了 80% 的页面,但页面里的分支组合可能只覆盖了 20%。你说覆盖了 100% 的核心接口,但接口之间的时序依赖可能没测到。

更实用的衡量方式是按业务场景算。把系统里所有对用户有价值的行为列出来,标记哪些是“必须每次发版都验证”的,哪些是“可以隔几天跑一次”的,哪些是“只在重大改动时手动验证”的。第一类做成 e2e 自动化,第二类做成定时任务,第三类保留手动清单。这样算下来的“场景覆盖率”比拍脑袋定一个百分比要靠谱得多。一般来说,一个中等规模的 Web 应用,核心 e2e 场景在 30 到 80 条之间是比较健康的区间。少于 30 条可能漏掉关键路径,多于 80 条就要考虑是不是把太多细节塞进来了。

2.3 哪些场景不适合放进 e2e

有几类场景我踩过坑之后坚决不放进 e2e。第一类是验证复杂计算逻辑的,比如税率计算、折扣叠加、积分规则。这些用单元测试覆盖边界值又快又准,放进 e2e 只会让你在页面里绕来绕去。第二类是验证异常分支的,比如网络超时、服务返回 500、数据库连接失败。这些在 e2e 里模拟成本极高,而且不稳定,用集成测试配合 mock 更合适。第三类是纯 UI 样式验证的,比如按钮颜色、字体大小、间距。这些用视觉回归工具或者组件测试更直接,e2e 去断言 CSS 属性属于杀鸡用牛刀。

注意:e2e 测试的断言应该聚焦在“用户能感知的结果”上,比如页面出现了某个文本、跳转到了某个 URL、列表里多了一条记录。不要断言内部实现细节,比如某个 div 的 class 名、某个接口被调用了三次。前者稳定,后者一改就碎。

3. 搭一套能跑的 e2e:从选型到第一个用例

3.1 工具选型:为什么我最终选了 Playwright

e2e 工具这些年换了好几茬。最早大家用 Selenium,后来 Puppeteer 火了一阵,再后来 Cypress 成了很多前端团队的首选,现在 Playwright 的声量越来越大。我不打算说哪个“最好”,因为选型要看团队情况,但我可以分享一下我为什么从 Cypress 迁到了 Playwright。

Cypress 的开发者体验确实好,实时重载、时间旅行调试、自动等待,写起来很舒服。但它有几个硬伤:一是跨域和多标签页支持一直别扭,二是浏览器支持以 Chromium 为主,三是并行执行在免费版里受限。Playwright 在这几点上更开放,原生支持 Chromium、Firefox、WebKit,跨域和多标签页是内置能力,并行和分片在开源版里就能用。而且 Playwright 的自动等待机制做得更细,很多在 Cypress 里需要手动 cy.wait 的场景,在 Playwright 里直接 locator 就能搞定。

当然,如果你的团队已经有一套稳定的 Cypress 体系,没必要为了追新而迁移。工具是手段,不是目的。但如果是从零开始搭,或者现有工具已经明显拖慢节奏,Playwright 值得认真考虑。

3.2 环境准备中最容易忽略的三个细节

第一个细节是浏览器版本和系统依赖。Playwright 安装时会下载对应版本的浏览器二进制,但在 CI 环境里经常因为缺少系统库而启动失败。比如在精简的 Linux 镜像里跑,需要额外装 libnss3、libatk-bridge2.0-0、libdrm2 这些包。我建议直接用官方提供的 Docker 镜像,省去手动装依赖的麻烦。

第二个细节是测试数据的隔离。e2e 跑起来会真实地写数据库,如果多个用例共用同一份数据,就会出现“单独跑通过、一起跑失败”的经典问题。解决办法有两种:一种是每个用例生成唯一标识的数据,比如用时间戳或随机字符串作为用户名;另一种是每个用例跑完后清理自己产生的数据。前者更简单,后者更干净,我通常混着用——核心数据用唯一标识,辅助数据跑完就删。

第三个细节是等待策略。新手最容易犯的错就是到处写固定等待,比如 sleep 三秒。这在本地可能没问题,到了 CI 上要么不够要么浪费。正确的做法是用“等待某个条件成立”,比如等待某个元素可见、等待某个网络请求完成、等待 URL 变成预期值。Playwright 的 locator 自带自动等待,大部分场景不需要手动加等待,但涉及异步加载的列表或弹窗时,还是需要显式等待某个状态。

3.3 第一个 e2e 用例:登录流程的完整拆解

登录是几乎所有系统最核心也最适合做 e2e 的入口。我拿它当第一个用例,不是因为简单,而是因为它串联了前端表单、网络请求、后端鉴权、会话管理和页面跳转,是一个微缩的完整链路。

用 Playwright 写一个登录用例,大致结构是这样的:

const { test, expect } = require('@playwright/test'); test('用户使用正确凭证登录后进入首页', async ({ page }) => { await page.goto('/login'); await page.fill('[name="username"]', 'testuser'); await page.fill('[name="password"]', 'correct-password'); await page.click('button[type="submit"]'); await expect(page).toHaveURL('/dashboard'); await expect(page.locator('.welcome-message')).toContainText('欢迎'); });

这段代码看起来简单,但每一行背后都有讲究。page.goto会等待页面加载完成,但“完成”的定义是 load 事件触发,如果页面有异步渲染的内容,还需要额外等待。page.fill会先等待元素可编辑,再清空并输入。page.click会等待元素可点击。最后的toHaveURL和toContainText都是自动重试的断言,默认会等五秒,期间不断检查条件是否成立。

如果你用 Cypress 写,结构类似,但等待逻辑更多依赖命令队列的自动重试。不管用哪个工具,核心思路是一样的:描述用户做了什么,然后断言用户看到了什么。

3.4 跑通之后立刻要做的三件事

第一个用例跑通的那一刻很爽,但别急着写第二个。先做三件事,后面会省很多事。

第一件是把配置抽出来。URL、用户名、密码这些不要硬编码在用例里,放到配置文件或环境变量里。这样本地、测试环境、预发环境可以用同一套用例跑,只换配置。

第二件是加上失败时的截图和视频。Playwright 和 Cypress 都支持在失败时自动截图,Playwright 还能录视频。这个功能在排查 CI 上的偶发失败时是救命的,因为你看不到当时的页面,只能靠截图和视频还原现场。

第三件是确定断言策略。登录成功要断言什么?只断言 URL 变了够不够?不够,因为有些系统 URL 变了但页面还是白的。还要断言页面上出现了只有登录后才能看到的内容,比如用户名、退出按钮、某个专属菜单。断言越贴近用户感知,越不容易误报。

4. e2e profile 到底在配什么:一份可复用的配置思路

4.1 profile 的本质:一组环境与行为的绑定

“e2e profile1”这个说法,我理解它指的是一套命名的配置档案。在不同的测试框架里,profile 的叫法和形式不一样,但本质是一样的:把“跑哪些用例、连哪个环境、用什么浏览器、开多少并行、失败怎么办”这些决策打包成一个可切换的单元。

为什么需要 profile?因为 e2e 不是只有一种跑法。开发本地跑的时候,你可能只想跑自己改过的那几条,用 headed 模式看着浏览器跑,方便调试。CI 上跑的时候,你要跑全量,用 headless 模式,开高并行,失败重试两次。发版前跑的时候,你可能要跑全量加冒烟,连预发环境,生成完整报告。这三种场景如果每次都要手动改一堆参数,迟早会出错。profile 就是把这些参数固化下来,用名字切换。

4.2 一份典型的 profile 配置包含哪些字段

以 Playwright 的配置文件为例,一个 profile 通常包含这些维度的设置:

配置维度本地调试 profileCI 全量 profile发版前 profile
执行模式headedheadlessheadless
并行度142
重试次数021
超时时间60s30s45s
截图策略仅失败仅失败全部
视频策略关闭仅失败保留
用例范围当前文件全量全量+冒烟
目标环境localhoststagingpreprod

这张表不是标准答案,但展示了 profile 的思路:不同场景下,你对速度、稳定性、可观测性的取舍是不一样的。本地调试要的是快和直观,CI 要的是稳和可并行,发版前要的是全和可追溯。

4.3 环境变量与密钥管理

profile 里最敏感的部分是环境地址和凭证。我的做法是:环境地址放在 profile 配置文件里,因为不同 profile 对应不同环境是合理的;但具体的用户名、密码、API key 放在环境变量或密钥管理服务里,不写进代码仓库。

在 CI 里,这些变量通过流水线的 secrets 注入。在本地,通过 .env 文件加载,并且 .env 要加进 .gitignore。这样既方便切换,又不会泄露。有些团队会用专门的配置中心,原理一样,只是把存储位置从文件换成了服务。

提示:永远不要在 e2e 用例里硬编码生产环境的凭证。即使你觉得“只是测试账号”,一旦仓库泄露或被不当访问,风险是实打实的。测试账号也应该遵循最小权限原则,只开必要的功能。

4.4 profile 切换时的常见坑

第一个坑是环境差异导致的选择器失效。测试环境和预发环境的页面结构可能因为版本不同而不一样,同一个选择器在一个环境能定位到,在另一个环境就找不到。解决办法是尽量用稳定的属性做选择器,比如>

RELATED READING

延伸阅读

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