从零搭建端到端测试体系:工具选型、实践与稳定性治理

发布时间:2026/10/27 12:22:25
从零搭建端到端测试体系:工具选型、实践与稳定性治理

1. e2e 到底在测什么——重新理解端到端测试

做开发这几年,我见过太多类似的场景:单测覆盖率号称 80% 以上,接口测试也铺了一大片,CI 全绿,结果每次发版前手工点点点,还是能从某个犄角旮旯里发现一个能让主流程直接断掉的 bug。产品经理一脸困惑,开发小哥一脸委屈,最后所有人把目光投向我这个测试工程师。这种时候我一般会反问一句:你们 e2e 覆盖了吗?

e2e 这个词是 End-to-End 的缩写,端到端测试。它和其他测试层级最本质的区别在于:单测验证的是一个函数、一个方法在隔离环境下的正确性,接口测试验证的是服务端逻辑在给定输入下的响应,而 e2e 测试是从真实用户视角出发,把整个系统当作一个完整的黑盒来验证。用户从打开页面开始,输入账号密码、点击按钮、跳转页面、提交表单、看到结果,这一整条链路里涉及到的前端渲染、网络请求、后端接口、数据库读写、第三方服务回调,任何一个环节出问题,e2e 都会如实呈现出来。

很多团队对 e2e 的认知存在一个偏差,觉得它就是"用 Selenium 录个脚本,模拟点点点"而已。早几年的工具生态确实是这样,但现在的 e2e 已经发展成一个完整的技术体系,从用例设计、环境管理、数据隔离、并发执行、CI 集成到稳定性治理,每一环都有成熟的方案。本文想做的,就是把我这些年从零搭好一套 e2e 测试体系的经验完整梳理一遍,包括工具选型背后的思考、用例场景的落地方式、我在真实项目里踩过的一堆坑,以及"e2e profile1"这种场景配置到底怎么用才算合理。不管你是刚准备给项目引入 e2e 的新手,还是已经跑了一段时间但总觉得用例"又慢又脆"的同行,这篇文章都能给你一些可以直接用的东西。

先说清楚一个边界问题:e2e 不是什么都能接手。它解决的是"整体流程是否能走通"的问题,而不是"具体某个算法是否正确"的问题。一个页面上的按钮点击之后没有反应,这可能是前端事件绑定问题,也可能是后端接口 500,还可能是网络层发出去的请求丢了包。单测和接口测试能在各自层级帮你定位问题到底出在哪一层,而 e2e 的价值在于快速告诉你"有一个地方坏了",至于坏在哪,需要靠日志、靠链路追踪、靠分层测试去缩小范围。所以一套健康的测试体系必然是金字塔结构:大量的单测做底座,适量的接口测试撑中层,少量但关键的 e2e 用例守顶层。层与层之间各司其职,谁也不要想着替谁干活。

1.1 为什么单元测试覆盖满了,线上还是出问题

我接手过一个电商项目,后端用 Spring Boot,前端用 Vue,单测覆盖率报表非常好看,行覆盖率和分支覆盖率都过了 75%。但每次发版进灰度,总有那么几个用户能复现出让人哭笑不得的流程 bug:比如用户把商品加进购物车之后,直接刷新页面,购物车徽标数变成 0 但购物车详情页里依然有商品;比如用户在下单页切换收货地址之后,运费没有跟着重新计算;再比如用户在微信内置浏览器里支付成功后,页面一直停在"支付中"的状态,因为支付回调里依赖的某个参数在微信 WebView 环境下被吞掉了。

这些问题有一个共同特征:它们都能在浏览器里直接复现,而且路径清晰、逻辑明确,可单测就是测不出来。为什么?因为单测测的是"函数输入输出",而真实用户面对的是一个有状态、有时序、有外部依赖的完整系统。上述三个 bug 分别涉及 localStorage 数据同步、组件状态跨页面传递、第三方浏览器环境的兼容处理,这些交互层面的逻辑单测不是不能写,而是写起来成本极高、收益极低,开发同学光是要模拟一个"从列表页到详情页再到购物车再回来"的完整浏览器状态机,就足以让人崩溃。e2e 在这个场景下反而是最经济的方案:它不止测某个函数,而是测整条链路在真实浏览器里的真实表现。

线上频繁出问题的第二个原因,是数据流的断裂。后端接口单测往往会做一堆 mock,把数据库、缓存、第三方服务全部替掉,保证测试隔离性,这本身没错。但 mock 得越多,离真实环境越远,那些只有在真实环境里才会暴露的问题,比如某个字段为 null 导致前端渲染异常、数据库某个字段长度不够导致插入失败、Redis 里的缓存过期策略和代码假设不一致等等,单测永远模拟不出来。e2e 天然贴近真实环境,它是当前所有自动化测试层级里最接近"用户手测"的方式,也因此最有能力兜住这类问题。

1.2 e2e 测试的定位与边界

给项目引入了 e2e 之后,还得想清楚一个特别重要的问题:哪些场景值得用 e2e 来覆盖,哪些场景不应该用 e2e 硬上。这个问题想不清楚,测试套件很容易变成又慢又脆的累赘。

我自己的原则是三个:高频、关键、跨端。高频是指用户每天都会走的核心路径,比如登录、搜索、浏览、加购、下单,这类路径一旦挂掉,影响面是全局性的,值得用 e2e 天天守夜;关键是指业务上不可妥协的高价值流程,比如支付回调、政企客户的 OA 审批流、报表系统的导出下载,这些流程容错率极低;跨端则是指涉及多种技术栈协作或者多端联动的场景,比如前端调 API、又依赖 WebSocket 推送的状态同步,这种链路中间某一环的接口测试做得再好,也无法验证端到端的表现,只能靠 e2e 补位。

反过来,一些纯粹文案渲染、样式错位、轻量交互的问题,用组件级快照测试或者人工走查可能更快更精准,强行写成 e2e 反而会放大执行不稳定性的影响。另外,性能测试该用专业压测工具去做,安全测试该用扫描和人工渗透去做,这些都有各自的专业工具链,不要把 e2e 工具硬套上去。测试的终极目的不是证明自动化有多强大,而是在合理成本内把风险控制住,这个逻辑要时刻记住。

2. 工具选型与方案设计——从零开始搭建 e2e 测试框架

讲完了"为什么做",接下来就是"怎么选"和"怎么搭"。工具选型这件事,我在不同项目里反复经历过好多次,每一次都觉得"这次选对了",但实际操作一段时间之后,总会发现一些文档里看不到的问题。这里把最主流的几个方案放在一起,对比着说,也把我最终的选择和理由交代清楚。

2.1 主流 e2e 工具的真实对比

先说一个基本情况:目前业界主流的 e2e 工具无外乎 Selenium、Cypress、Playwright、Puppeteer 和 TestCafe 这几家。Selenium 是爷爷辈,生态庞大,支持的语言多,浏览器兼容性强,但它有几个让人头疼的老毛病:一是依赖 WebDriver 协议,启动浏览器的时候要单独起一个 driver 进程,环境配置复杂度高;二是内置等待机制偏弱,测试里动不动就要手工 sleep,导致执行既慢又不稳定;三是脚本录制的体验虽然好,但录完的脚本几乎都需要大量手工调试,维护成本非常高。现在很多团队在用 Selenium,但基本都是历史包袱,新团队很少再选它做主力了。

Puppeteer 是 Chrome 团队推出的,用 DevTools Protocol 直接控制浏览器,性能和调试体验都不错,但它的定位更偏向浏览器自动化脚本,而不是一个完整的测试框架。你用它做爬虫、做截图、做 PDF 生成,非常顺手,但要做断言、做测试报告、做失败重试,它本身不带这些能力,需要自己集成。TestCafe 不需要浏览器驱动,内置了断言和等待机制,用起来也算友好,但它的社区生态明显没有前几家活跃,遇到问题能搜到的解决方案相对少。

真正在这几年拉开车距的是 Cypress 和 Playwright。Cypress 的定位是"前端开发者的 e2e 测试工具",它把开发体验做得极其流畅,树形结构展示用例、自动等待机制、时间旅行回放,这些特性让它在一众工具中非常有辨识度。但 Cypress 有几个先天限制我比较介意:它不支持多标签页真实操作,不支持同时控制多个浏览器进行跨端验证,网络层控制也只能有部分手段。如果你的项目只是一个纯前端单页应用,Cypress 会特别舒服;但一旦涉及多 Browser Context、多用户身份切换、复杂的移动端浏览器兼容测试,Cypress 就会让你在很多边缘场景里到处找 workaround。

Playwright 最大的优势是它从一开始就站在"完整自动化解决方案"的角度来设计架构。底层协议同样是 DevTools Protocol,但它把 Selenium、Puppeteer、Cypress 这些工具的常见痛点都在框架层做了处理:自动等待、网络拦截、多页面多上下文管理、移动端设备模拟、文件下载、麦克风权限弹窗处理、Trace 视频回放,这些能力都是开箱即用的。而且 Playwright 在浏览器支持上覆盖 Chromium、Firefox、WebKit,这一点对兼容性测试尤其重要。下面这个表格是我基于实际体验整理的对比:

维度SeleniumCypressPlaywrightPuppeteerTestCafe
安装复杂度高,需单独管理 driver低,npm 安装即可低,会自动下载浏览器低,但浏览器支持单一低,无需 webdriver
浏览器支持Chrome/Firefox/Safari/Edge 等仅 Chromium 族为主Chromium/Firefox/WebKit 全覆盖仅 ChromiumChromium/WebKit 等
自动等待弱,需大量手工同步内置,体验极佳内置,可自动等待元素、请求、页面加载需自己写轮询内置
多标签页/多用户支持不支持原生支持多 Browser Context支持但需要手写支持有限
网络控制/拦截弱部分支持原生支持,可拦截请求、模拟断网支持支持有限
失败诊断Screenshot内置时间旅行 DebuggerTrace Viewer + 视频回放 + 截图需自行集成截图
社区生态最庞大活跃快速增长活跃较小

这些差异不是"哪个更好"的静态对比,而是要回到你的实际项目结构里去判断。我强烈建议在做选型之前,先花一个下午把自家最核心的主流程用两到三个候选工具各写一遍,真实跑一轮,不要只看文档里的宣传话术。

2.2 我最终选择的技术栈与理由

去年我负责一个中大型 B2B 交易平台的 e2e 测试体系搭建,前端是 React + TypeScript,后端是微服务架构,一共拆分了几十个服务,不少业务场景需要登录不同的租户账号、切换不同的后台角色,而且平台对外还要兼容主流浏览器的最新两个版本。在那一轮候选工具实测里,我最终选了 Playwright 作为主力框架。

选它的理由归纳下来有三条。第一是多浏览器支持和多上下文管理能力强悍。B2B 平台最先要考虑的就是"不同角色打开同一页面看到的数据和菜单不一样",而 Playwright 的 Browser Context 机制可以非常干净地模拟"多个独立会话同时并存"这个场景,用两个 Context 分别模拟普通租户和管理员,不需要反复登录退出,效率提升特别明显。第二是它的等待机制足够聪明。它内置的 Auto-wait 机制会在执行点击、输入、查看元素等操作前自动等待元素达到可操作状态,比如可见、稳定、可点、可编辑,这样写出来的脚本天然稳定很多,不用像老一套工具那样到处塞 sleep 或者轮询方法。第三是它的失败诊断体验太好用了。跑挂了可以直接拿到 trace 文件,里面记录的是整个执行过程的网络请求、DOM 快照、控制台日志和鼠标轨迹,还原现场就像看一段录播,排查问题效率极高。

当然,我也不是没评估过 Cypress。坦白说,如果你的应用是纯前端重度交互型产品,用户路径高度集中在 Web 端,不怎么涉及多端协同和真实性极强的浏览器兼容,Cypress 的开发体验和能力其实是够用的。我在另一个内部运营系统项目里就用了 Cypress,因为它够简单、够快,团队里几乎所有前端同学都能快速上手,这是它最大的优势。但在本文所述的这个 B2B 平台的场景里,Playwright 更像那个"没有明显短板的六边形战士"。

2.3 测试环境与数据隔离方案

工具选完了,紧接着要解决的就是测试运行环境和数据问题。这两件事如果不提前设计好,后面不管用例写得再好,都会被环境不稳定和数据污染拖垮。

先说环境。e2e 测试的最佳运行环境是什么?很多人第一反应是"直接用线上环境测,测出来就是真实结果"。这个想法大错特错。线上环境的数据是不可控的,你不知道什么时候会有运营人员改了一条配置、用户下了一笔大额订单,更不能接受测试数据污染生产数据,而且线上环境不具备随时部署最新代码的条件。我的建议是搭建一套专门的 e2e 测试环境,它不要求和生产环境同等规格,但它的分支来源、服务部署、第三方依赖都必须是可控的。最简单的做法就是让 CI 流水线在每次合并到主干后自动构建一套环境,跑完 e2e 再销毁,这样环境是干净的、代码是新鲜的,可复现性也最强。不过这种方式成本高,如果团队资源紧张,也可以退一步用固定环境,但一定要配合好数据清理策略。

数据隔离是另一个大坑。我遇到过太多次这样的情况:e2e 用例执行到中途,页面崩溃了,查了半天发现是上一个用例留下的脏数据把一个唯一键给占住了。为了避免这种互相干扰,我一般会做两层处理:一是每条用例执行前通过 API 快速准备自己的专属数据,避免不同用例共享同一份数据;二是尽可能让测试数据带上用例特征标识,比如生成一个带时间戳的账号名,这样即使哪条用例清理失败,也不会立刻影响其他用例。这里有个额外的技巧是:能用 API 预置数据的场景,就绝不要通过 UI 去创建数据。比如测试下单流程,你可以先用 API 把该准备的商品库存、优惠券、用户余额都DP配置好,然后在 UI 层只管走下单路径,这样既节省执行时间,又能大幅降低因前置步骤失败导致整条用例挂掉的概率。

3. 实操过程全记录——写出一套能稳定跑完的 e2e 用例

到了真正动手写用例的阶段,就不能只聊理念了。这一节我把一个真实项目的 e2e 落地过程完整拆开,包括怎么定义场景配置、怎么写核心用例、怎么接入 CI 和产出报告,每个环节都附上关键代码和参数选择逻辑。我会用一个电商风格的交易平台作为贯穿案例,这样讲解起来更有实感。

3.1 从 profile1 场景出发设计用例框架

"e2e profile1"这个词不是随手搜出来的,它代表的是一个实际存在的概念:测试场景配置画像。在 e2e 测试里,profile(画像)本质上是一组预先定义好的场景参数集合,它告诉测试框架"在什么环境、用什么身份、走哪条路径、侧重验证什么"。设计 profile 的价值在于:当你的业务足够复杂,e2e 用例数量多到一定程度时,你不可能每次都跑全部用例,必须根据版本变更的影响面,选择性地执行某些 profile。

我在 B2B 平台项目里设计了三类 profile:

  • profile1:核心交易链路。模拟一个标准租户账号,从登录开始,依次完成搜索商品、发起询价、加入采购单、提交订单、线上支付的完整流程,属于日常每次发版都要全量回归的"守门员"用例集合。
  • profile2:后台管理链路。模拟不同角色的后台操作系统,包括管理员审批订单、财务导出对账单、运营配置商品上下架。这类 profile 测试的是权限控制和流程审批的完整性。
  • profile3:稳定性与异常链路。覆盖网络抖动、接口超时、服务降级等异常情况下的用户提示和降级表现,比如断网后重试、服务 503 时的友好报错等。

这里重点说说 profile1,因为它是整个体系里最核心也最容易被写垮的部分。 profile1 的落脚点不应该是一堆零散的 UI 操作步骤,而是一张清晰的主流程状态图:用户每一步操作对应的页面状态是什么、前后状态之间的数据依赖是什么、预期结果在哪里出现。我先用一段伪代码把 profile1 的场景骨架列出来,再讲具体的实现细节:

profile: "profile1" flow: - step: 打开登录页 - step: 输入租户账号密码 -> 点击登录 - assertion: 登录成功,跳转至工作台首页,顶部显示租户名称 - step: 在搜索框输入商品关键词 -> 回车 - assertion: 商品列表页展示搜索结果,且结果条数大于 0 - step: 点击第一个商品 -> 进入详情页 - step: 点击"加入采购单" -> 弹窗确认数量 -> 确认 - assertion: 购物车/下单页出现该商品,数量正确 - step: 点击"立即下单" -> 选择收货地址 -> 提交订单 - assertion: 订单提交成功,页面展示订单号 - step: 点击"去支付" -> 模拟收银台拉起 -> 支付成功 - assertion: 返回订单详情页,状态显示"已支付"

这个骨架确定了之后,每个 step 对应的测试代码就只需要做两件事:操作和断言。你要始终记得,e2e 用例的价值主体是断言,操作只是手段。如果一条用例断言太少,它就只能证明"流程没崩",不能证明"流程做对了"。所以 profile1 里每一段操作后面,我都会至少跟一个明确的断言,而且尽量断言业务状态,不只是断言元素存在。比如"断言登录成功"要用"顶部显示了租户的名称"而不是"登录按钮消失了",这两个的验证力度是完全不同的。

3.2 核心用例代码详解与关键配置

骨架定好了,下面就是真实的代码写法和配置说明。我用 Playwright 的 JavaScript/TypeScript 版本做示例,因为这是目前我要重点推荐的技术栈。第一段先看一个最简单的登录场景,重点在于解释 Playwright 自动等待的能力和推荐写法:

import { test, expect } from '@playwright/test'; test('profile1 - 租户登录并进入工作台', async ({ page }) => { // 打开登录页 await page.goto('https://e2e.xxx.com/login'); // 输入账号密码 await page.getByPlaceholder('请输入租户账号').fill('tenant01'); await page.getByPlaceholder('请输入密码').fill('e2e_tenant_2024'); // 点击登录按钮 await page.getByRole('button', { name: '登 录' }).click(); // 断言:跳转到工作台,且顶部显示租户名称 await expect(page).toHaveURL(/\/home/); await expect(page.getByTestId('tenant-name')).toHaveText('测试租户一'); });

这段代码看起来很简单,但里面蕴含了大量 Playwright 为你兜底的细节。比如getByRole和getByPlaceholder这类基于可访问性语义的定位器,是推荐优先使用的,因为它不依赖 CSS class 或者 DOM 结构,只要页面上的可访问性标记不变,即使前端重构了样式,用例也不会轻易挂掉。再比如expect(...).toHaveURL()这种断言,Playwright 会自动轮询等待直到 URL 匹配期望值或者超时,而不需要手动写循环等待逻辑。

接下来看一个稍微复杂的场景:跨页面状态的验证。在 profile1 里,用户把商品加入采购单之后,采购单的角标数量应该联动变化,而且刷新页面之后角标和详情页的数据应该保持一致。这个场景就特别适合用 Playwright 的多页面和网络拦截能力来实现:

import { test, expect } from '@playwright/test'; test('profile1 - 商品加入采购单后状态持久化', async ({ page }) => { // 先拦截加入采购单的接口,确保请求发出并成功响应 const responsePromise = page.waitForResponse( (res) => res.url().includes('/api/shopping-cart/items') && res.request().method() === 'POST' ); await page.goto('https://e2e.xxx.com/products/10001'); await page.getByRole('button', { name: '加入采购单' }).click(); await page.getByRole('button', { name: '确认' }).click(); // 等待接口成功,然后断言接口返回的数据 const response = await responsePromise; const data = await response.json(); expect(data.success).toBe(true); // 断言页面上的角标数量变成了 1 await expect(page.getByTestId('cart-badge')).toHaveText('1'); // 刷新页面,断言角标和数据仍然保持一致 await page.reload(); await expect(page.getByTestId('cart-badge')).toHaveText('1'); // 进入采购单详情页验证商品是否真实存在 await page.getByTestId('cart-badge').click(); await expect(page.getByText('自动化测试商品A')).toBeVisible(); });

这里有两个值得注意的设计点。第一,我在点击"加入采购单"之前提前声明了一个responsePromise,这样能保证在事件发生的第一时间捕捉到接口调用,而不是等点击完成之后再去找响应对象,这就避免了竞态条件带来的 flaky。第二,刷新页面之后的断言非常关键,它验证的不只是当前页面的交互状态,还包括 localStorage、服务端存储、初始渲染这几层之间的数据一致性。以前我在不写 e2e 的时候,这类 bug 经常只在真实用户刷新时才会暴露,现在用一条用例就能守住。

说到"profile1"这个配置,还有一个经常被忽略的细节:执行超时时间和重试策略。e2e 用例跑在真实浏览器环境里,脚本执行速度受网络影响很大,所以我给 profile1 场景设计了一组专门的配置:

import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './e2e', timeout: 60_000, // 单条用例执行超时 60 秒 expect: { timeout: 15_000, // 单个断言等待超时 15 秒 }, retries: 2, // 重试 2 次,应对偶发性环境抖动 workers: 4, // 4 个 worker 并行执行不同用例 reporter: [ ['list'], ['html', { open: 'never' }], ], use: { baseURL: process.env.E2E_BASE_URL || 'https://e2e.xxx.com', trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', }, });

用生活化一点的方式来理解这些参数:retries: 2就像是给用例穿了件救生衣,第一次没跑过,系统会再给两次机会,只有连续三次都挂了才判定为失败。trace: 'retain-on-failure'则像是飞机上的黑匣子,用例失败时会把整个执行过程完整录制下来,排查问题时可以直接在 Trace Viewer 里看到当时的页面快照、网络请求和 console 报错。workers: 4意味着测试框架会同时开 4 个浏览器去并行跑不同的用例,这里一定要考虑被测环境的承载能力,不要盲目调大。

3.3 CI 集成与报告输出

用例在本地能跑通,只是第一步。真正让 e2e 产生长期价值的是把它接入 CI 流水线,让它成为每次发版前置检查的一部分。我的接入策略很有讲究:不是所有合并请求都跑全套 profile1,因为全量 e2e 再快也需要几分钟到几十分钟,如果每个 PR 都全量跑,开发同学的等待成本会成倍增加,反而会让大家开始排斥这个测试体系。

我采用的方案是分级触发。平时每个 PR 只触发冒烟级 e2e,即 profile1 里最核心的 10 条左右用例,覆盖登录、首页加载、单商品下单、撤回订单这些最高频路径,这套用例要求 5 分钟内跑完;只有往主干分支合并、准备打版本标签、或者发版后冒烟巡检的时候,才触发完整 profile1 和 profile2,保证重大变更前的深度回归。这样做既守住了质量门槛,又没把反馈周期拖得太久。

CI 里运行 e2e 的流水线,我一直坚持用下面的步骤顺序:

  1. 拉取测试代码和被测应用最新构建产物;
  2. 部署或者复用当前环境的测试版本;
  3. 运行npx playwright install确保浏览器环境就绪;
  4. 执行npx playwright test --grep @profile1;
  5. 上传 trace、视频、截图以及 HTML 报告作为流水线产物;
  6. 设置阈值:成功率低于 95% 则流水线失败,通知相关团队。

一个实操细节:e2e 环境一定要在用例开始前做好健康检查。我见过太多次因为被测服务还没启动完,e2e 用例在第一步页面加载时就全部超时,白白浪费了半小时。所以我的流水线里会有一步e2e-healthcheck,直接用 curl 去请求被测环境的/healthz和首页路径,确认 HTTP 200 之后再开始正式测试。

报告输出方面,Playwright 自带的 HTML Reporter 已经很好用,但我还会额外把测试摘要推到团队的即时通讯群里,方便第一时间感知结果。摘要里包含三块信息:总用例数、通过率、挂掉的用例名和失败原因摘要,支线再附上报告链接。这样连对自动化测试细节不太熟悉的同事,也能通过一条消息快速判断这次发版是否安全。

4. 常见问题与排查技巧——线上回归跑挂的十几个真实案例

这一节我认真梳理了 e2e 测试从落地到稳定运行过程中,我遇到过的最典型的问题。每一个问题背后都对应一个真实的踩坑故事,我尽量把排查思路也讲清楚,让大家以后遇到相似情况时有迹可循。这里先说一句最核心的经验:e2e 测试跑挂,80% 的情况不是被测系统的功能有问题,而是测试脚本自身写得不够稳。

4.1 等待策略的坑:为什么我的用例总是偶发失败

刚上手 e2e 的人最容易踩的坑就是等不到位。页面元素已经渲染了一半,测试脚本就急着去点,结果点了个寂寞。老一辈做法是到处加sleep(3000),治标不治本,而且还会让整个测试套件越跑越慢。现代 e2e 工具已经内置了自动等待机制,但自动等待也不是万能的,它只能感知到 DOM 状态的变化,感知不到业务状态的真实就绪。

举一个我实际遇到的例子:下单页面有个"提交订单"按钮,点击之后页面会先发起一个异步请求,然后弹出一个支付二维码。我第一次写断言时直接判断二维码是否可见,结果经常失败。后来发现,二维码组件在请求发出后就会被插入 DOM,但二维码的图片内容要等接口返回并渲染完成后才会真实出现。自动等待只帮我等到"元素出现",但我要断言的是"支付状态已经生效"。这个问题的解法是用业务状态断言替代 UI 状态断言,也就是等待特定接口的响应成功,或者等待订单状态文本从"待支付"变为"已支付",而不是只盯着一个元素在不在 DOM 里。

第二个常见的等待问题是"页面加载完了吗"。很多人习惯用await page.goto(url)之后就认为页面已经加载完成,实际上goto返回的时候是页面 load 事件触发的时间点,而前端框架大量使用异步数据渲染,load 事件触发时页面可能还只有骨架屏。我的习惯是:如果一个页面有关键数据需要渲染后再做断言,就先用一个expect去等待核心文本或者核心元素可见,这相当于在代码层面显式告诉测试框架"这里必须先等到业务数据就绪才能继续往下走"。

4.2 测试数据污染:同一条用例为什么换个时间跑就挂了

数据污染是 e2e 稳定性最大杀手,没有之一。我之前维护过一套订单查询的 e2e 用例,用例逻辑很简单:创建一个订单,然后在列表页搜索这个订单的订单号,断言它出现在结果第一行。这用例本地跑十次能过十次,但是放到 CI 上隔几天跑一次,就开始时不时失败了。

排查了很久才发现问题出在数据隔离上。那条用例为了省事,直接复用了别的用例创建的订单数据,而另一条用例会在特定条件下把订单状态改成"已取消",一旦两个用例的执行顺序发生改变,查询用例的断言就挂在"订单状态不对"上。这个问题的根治方案,就是我前面提到的:每条 e2e 用例都应该准备自己独立的数据。我后来把用例改成了通过 API 在 beforeEach 阶段创建一条带时间戳的订单,测试结束再统一清理,从那以后再也没遇到过这个场景的数据污染问题。

数据清理这件事,执行起来要格外细心。不要清理得太快,也不要凭空清理。我的常见做法是在测试完成后调用删除接口清理,但前提是删除接口本身不是被测试的对象,否则就要换一种方案,比如直接把测试数据写入一个单独的逻辑库,定期统一回收,这种模式在 B2B 平台里尤其适用,因为它有明确的租户维度,清理成本极低。另外请注意:如果测试脚本依赖执行顺序,或者说某条用例运行结束后的环境状态会影响下一条用例,那这一定是一个设计错误,用例之间必须做到完全独立。为保证这一点,我会在每一条用例的断言里额外验证"前置状态符合预期",比如下单用例会先断言登录状态、库存数量、地址列表都符合预期,再开始主流程。

4.3 并行执行与资源隔离:worker 开多了为什么反而更慢

引入 Playwright 之后,我一开始非常贪心地开了 8 个 worker 并行跑全量用例,想着能最大化利用机器资源。结果非常反直觉——总执行时间并没有显著下降,反而局部出现了更多 timeout 和断言失败。排查下来发现是测试目标应用扛不住了:8 个浏览器同时发起大量请求,把测环境的接口服务直接打到了高负载状态,部分请求响应时间超过了断言等待上限,用例就挂了。

这个问题教会我一件事:并行度不是越高越好,它应该根据被测系统的承载能力来设定。我的调整办法是先观察单个 worker 跑全量用例时的基础耗时,然后逐步增加 worker 数,找到吞吐量开始下降的那个临界点,再回退一档作为默认配置。目前那个 B2B 平台的线上配置就是 4 个 worker,实测是最优平衡点。另外还有一个不被很多人注意的细节:并行跑的时候,不同 worker 的浏览器进程之间其实是互相隔离的,但如果你的测试代码里出现了共享的全局变量、磁盘临时文件、或者同一个数据库里的固定名称纪录,污染风险会成倍增加。所以并行方案落地前,一定要先做一次"串行全量通过、并行重复跑三遍"的稳定性验证。

还有一个经常被问到的点:验证码、短信验证码这类需要人工介入的环节怎么处理。我的方案很直接:测试环境下拉一个验证码开关,所有 e2e 环境统一走万能验证码000000,这个开关只在测试环境生效,并且通过 mock 短信服务实现,绝不触碰真实短信网关。如果你的项目没法做这种开关,也可以走"预留测试手机号 + 邮箱解析验证码"的方案,但那种方式维护成本更高,不推荐首选。总之,e2e 测试理论上是自动化闭环,凡是无法自动化的外部依赖,都应该在设计阶段就把它隔离掉或者替换掉。

4.4 用例维护:测试套件越来越大,如何保持健康

最后聊一个所有 e2e 体系都会面临的长期问题:用例数量上去了,维护成本怎么控制住。我见过不少团队,一开始热情高涨,写了三四百条 e2e 用例,但不到半年就变成了"趟雷区",动不动全挂,维护者每天都在改 selector、加等待、调整断言,累死累活,最后还是被领导点名"自动化测试形同虚设"。这个问题不是工具不行,而是用例设计和治理从一开始就没做好。

我的经验有三条。第一,坚持分层设计,不要把所有验证都堆在 e2e 层。e2e 的主角是"跨层链路验证",业务逻辑的细节尽量下移到接口测试或者组件测试去覆盖。比如一个按钮的文案、一个表单的校验规则、一个金额计算的边界值,这些在接口层和单测层就能覆盖得很好,没必要在浏览器里反复测。保持 e2e 用例的小而精,执行时间才能闭环在可控范围。第二,建立用例的 owner 制度。每条用例都挂到具体业务模块的负责人名下,哪个模块的用例挂了,对应负责人要去修,而不是指望一个专职测试把所有模块的业务逻辑都吃透。我所在团队的实践是每个业务小组可以指定一名 e2e 维护接口人,每周轮换,这样责任感是分散的,不会变成某个人的独角戏。第三,定期做用例清理。每隔一个迭代,我会把所有用例的执行历史拉出来,按照"最近 30 天运行次数、失败率、最后修改时间"三个维度排序,把确认没有业务价值的用例直接删除或者降级为手动场景。这个习惯虽然有点反直觉,因为大家总觉得"用例越多越踏实",但实际情况是,一条验证价值很低、执行频率却很高的用例,只会不断消耗大家的信任和耐心,不如把它拿掉。

说到清理,我想起一个具体情况:平台改过一次商品详情页的信息架构,把"加入采购单"按钮从页面右上角移到了商品信息区的左下角,同时还改了按钮的文案,加了图标。当时我们团队正好有十几条用例都是通过page.click('text=加入采购单')这种方式定位的,结果一夜之间全部挂了。这一轮维护虽然糟心,但让我养成了一个好习惯:所有关键定位优先使用>