
上个月我们上线了一个看似不起眼的订单详情页改版单元测试覆盖率 80% 以上接口联调也全部通过结果第二天就有用户反馈部分账号在弱网环境下点进订单详情页页面一直转圈。排查到最后问题出在一个被前端当成必返字段的新接口参数上——而后端在特定用户配置下根本不会返回它。单测测不到这种跨模块联动接口测试也测不到真实浏览器环境下的渲染行为。这就是端到端测试e2e要解决的场景站在真实用户的角度把前端、后端、数据库、第三方服务整条链路串起来验证。这篇文章我想聊聊 e2e 这件事。不是给你堆概念而是把我在多个项目里从 Selenium 时代一路摸到 Playwright 的真实经验摊开来讲e2e 到底测什么、工具怎么选、体系怎么搭、稳定性怎么救、CI 里怎么跑。适合正在做 Web 项目质量建设、准备引入端到端测试或者已经被一堆随机失败的用例折磨到想删库的团队参考。1. 端到端测试到底在测什么一次全绿上线翻车事故的复盘1.1 事故复盘功能都验证过了为什么还是翻了车那次订单详情页改版表面上看是页面展示字段实际上牵扯到前端组件、后端接口、用户配置体系、缓存层四个模块。前端组件自身的单元测试是好的——因为 mock 数据里永远包含那个新字段后端接口测试也是好的——因为测试账号恰好命中会返回该字段的用户配置前后端联调也通过——因为联调环境用的是同一个特殊账号。但真实用户不是那个特殊账号。当后端在某个用户画像下不返回该字段时前端代码完全没有兜底逻辑一直处于 loading 状态。这种问题有一个共同特征任何单一模块的测试都发现不了只有在真实浏览器 真实后端 特定数据状态的组合下才会暴露。这就是端到端测试的价值所在它不关心某个函数怎么实现也不只关心接口返回什么字段它关心的是一个真实用户能否在接近生产的环境里完成他想做的事。1.2 单测、接口测试、e2e 各司其职一张表说清楚很多团队对测试层级的认知是模糊的经常出现单测写了但没用e2e 想写又不敢写的状态。我习惯用这样一张表来对齐认知测试层级关注点速度合理数量能发现的问题单元测试函数、组件、类的局部逻辑毫秒级最多逻辑错误、边界条件、分支覆盖接口/集成测试模块之间的协议与契约秒级中等字段缺失、状态码错误、时序问题端到端测试完整用户任务链路分钟级最少跨模块联动、真实渲染、权限链路、环境配置经典的测试金字塔是底层单测最多顶部 e2e 最少。但我在实际项目里看到的情况经常是倒过来的——团队迷信 e2e 能一把梭写了上千条端到端用例结果跑一次要一个多小时三分之一的用例随机失败最后大家看到红点都麻木了这套测试就废了。所以 e2e 从来不是越多越好而是要精准。1.3 e2e 守的是钱和口碑不是每个按钮端到端测试的成本是三种测试里最高的跑得慢、环境依赖重、偶发失败难排查。所以它应该全部花在刀刃上。以电商系统为例真正值得上 e2e 的链路无非是登录注册、搜索商品、加购下单、支付回跳、退款售后、后台发货。这些链路直接关系到收入和用户信任必须守死。我常跟团队打一个比方e2e 不是给每个房间装灭火喷淋而是走廊里的烟雾报警器。你不会在每个抽屉里都装一个报警器但主通道上必须有而且必须灵敏可靠。如果一个页面只是展示类的信息页改动频繁、价值有限交给单测和人工回归就够了硬塞进 e2e 反而拖垮整套测试的稳定性。2. 选型不是搜排行榜Playwright、Cypress 与 Selenium 的真实差距2.1 我经历过的 Selenium 时代能用但很脆我在早期项目里用过很长一段时间 Selenium WebDriver Python。当时最大的痛点是等待。页面元素没渲染出来就好不客气地time.sleep(3)接口还没返回又WebDriverWait配合各种 expected condition 写一堆模板代码。好不容易把用例写绿了换台机器、换个浏览器版本又红了。调试也痛苦失败之后只有一张截图想搞清楚是哪个元素挡了点击、哪次请求超时基本靠猜。并行执行更是重灾区Selenium Grid 配置复杂节点环境稍微不一致就出妖蛾子。不是说 Selenium 不行它生态成熟、语言支持广但在今天这个追求稳定和开发效率的语境下它已经不是我写新项目 e2e 的首选了。2.2 三款主流框架的横向对比我评估 e2e 框架时重点看五个维度自动等待机制、调试能力、多标签/多用户支持、并行能力、语言生态。对比下来大概是这样的维度PlaywrightCypressSelenium自动等待内置基于可操作性检查内置但部分场景依赖手动基本靠手写等待调试能力Trace Viewer 可回放每一步操作和网络请求时间旅行式调试开发者体验好截图 日志信息有限多标签/多用户原生支持浏览器上下文隔离较弱多 Tab 场景受限支持但使用成本高并行能力内置 workers可配置分片并行依赖付费 Dashboard依赖 Grid运维成本高支持语言JS/TS/Python/Java/C#仅 JS/TS多语言上手成本中等概念清晰低文档友好较低但写稳很难2.3 我的决策依据为什么把主力切到 Playwright我们最终把主力框架定在 Playwright核心原因有三点。第一是自动等待机制Playwright 的点击、填表、选择这类操作都会先检查元素是否附加到页面、是否可见、是否稳定、是否被遮挡、是否可交互全部满足才执行操作。这一套可操作性检查消灭了我过去用 Selenium 时最常见的竞态问题。第二是浏览器上下文隔离每条用例默认跑在独立的浏览器上下文里相当于每次都是全新的浏览器会话互不污染这套机制也让我可以很方便地模拟不同登录态、不同用户配置也就是所谓的 e2e profile。第三是 Trace Viewer用例失败后可以回放每一个操作、每一条网络请求、每一次控制台报错定位随机失败基本不用再靠猜。当然Cypress 在纯前端团队里依然是好选择尤其是它的时间旅行调试体验确实爽。但对我们这种经常要处理后台管理员 前台用户双角色交互、第三方支付回调、多标签页跳转的复杂业务场景来说Playwright 的多上下文、多页面能力更贴合需求。2.4 存量项目的过渡策略不要推倒重来如果你们项目已经有一套跑着的 Selenium 或 Cypress 用例我强烈不建议搞推倒重写这种运动。我们在一个项目里试过200 条 Selenium 用例预计重写周期三个月期间业务还在迭代新老用例并行维护的成本高到离谱。后来换了个思路老用例继续留在 pipeline 里跑只保证不崩新功能、核心链路的用例一律用 Playwright 写每次碰到的老用例涉及的模块顺手迁移一条。三个月后老套件自然缩减了三分之一新套件撑起了核心链路整个过程没有一次停摆式重构。3. 从零搭一套可维护的 e2e 体系目录、Page Object 与第一个用例3.1 目录结构按业务域组织而不是按测试类型组织很多人搭 e2e 项目时习惯按登录测试下单测试支付测试来建目录一开始看着清晰用例多了以后就乱了——同一个下单流程可能跟登录、地址、优惠券都相关放哪个目录都能说得通。我更推荐按业务域组织把页面操作封装抽出来共用。e2e/ ├── config/ # 环境配置按 profile 拆 ├── fixtures/ # 测试数据、mock 数据 ├── pages/ # Page Object按页面封装 │ ├── login.page.ts │ └── order.page.ts ├── specs/ # 用例按业务域组织 │ ├── auth/ │ │ ├── login.spec.ts │ │ └── register.spec.ts │ └── order/ │ ├── create-order.spec.ts │ └── cancel-order.spec.ts └── utils/ # 通用工具账号生成、数据库清理等这个结构的核心思想是pages 管怎么操作specs 管验证什么fixtures 管数据从哪来。三个层次各管各的改动时的互相影响最小。3.2 Page Object 模式老套路依旧最稳虽然 Playwright 的定位器 API 已经很强但 Page Object 模式并不过时。它的价值在于把选择器和重复操作集中管理。比如登录页如果 UI 改了输入框的 label你只需要改一个地方export class LoginPage { readonly usernameInput this.page.getByLabel(用户名); readonly passwordInput this.page.getByLabel(密码); readonly submitButton this.page.getByRole(button, { name: 登录 }); constructor(private readonly page: Page) {} async login(username: string, password: string) { await this.usernameInput.fill(username); await this.passwordInput.fill(password); await this.submitButton.click(); await this.page.waitForURL(/account); } }实际项目中我会要求所有用例只通过 Page Object 暴露的方法操作页面不允许直接写page.locator。这样一是用例代码可读性高二是团队成员写用例时不用重复造轮子三是选择器改动的影响面被限制在一个文件里。3.3 核心配置超时、视图区、重试与并行配置文件是 e2e 体系里最容易被忽略但又最关键的部分。我一般会把配置拆成几套 profile本地开发、CI、staging每套 profile 控制 baseURL、账号池、是否 mock 第三方支付等参数。一套典型的 Playwright 配置长这样import { defineConfig } from playwright/test; export default defineConfig({ testDir: ./specs, timeout: 60_000, // 单条用例超时 expect: { timeout: 10_000 }, // 单个断言超时 fullyParallel: true, workers: process.env.CI ? 4 : 1, retries: process.env.CI ? 2 : 0, reporter: [[html, { outputFolder: playwright-report }]], use: { baseURL: process.env.E2E_BASE_URL ?? https://staging.example.com, viewport: { width: 1440, height: 900 }, actionTimeout: 15_000, trace: retain-on-failure, video: retain-on-failure, }, projects: [ { name: chromium, use: { browserName: chromium } }, { name: mobile-chromium, use: { browserName: chromium, viewport: { width: 390, height: 844 } } }, ], });几个关键的配置项解释一下。expect.timeout单独设是因为断言等待和操作等待的语义不同断言等待的是某个状态出现通常给得更宽松。retain-on-failure的意思是只有失败的用例才保留 trace 和录屏避免测试产物占满磁盘。fullyParallel配合workers控制单机并行度CI 里可以再通过分片把负载分摊到多台机器上。3.4 第一个真正有价值的用例登录 下单从头到尾拆解我拿一个最典型的电商下单流程 demo 一下。这套用例的价值在于覆盖了登录态、商品搜索、购物车、结算、订单生成这几个跨模块环节test(用户能够从登录到成功下单, async ({ page }) { const loginPage new LoginPage(page); await loginPage.goto(); await loginPage.login(e2e_user_001, process.env.TEST_PASSWORD!); await expect(page.getByRole(heading, { name: /我的账户/ })).toBeVisible(); const product 经典款双肩包; await page.getByPlaceholder(搜索商品).fill(product); await page.getByRole(button, { name: 搜索 }).click(); await page.getByText(product).first().click(); await page.getByRole(button, { name: 加入购物车 }).click(); await page.getByRole(button, { name: 去结算 }).click(); await page.getByRole(radio, { name: 在线支付 }).check(); await page.getByRole(button, { name: 提交订单 }).click(); await expect(page.getByText(订单提交成功)).toBeVisible(); const orderNo await page.locator(.order-number).innerText(); expect(orderNo).toMatch(/^\d{12,}$/); });写用例时有一条铁律断言不能只验证页面没报错要验证业务上有意义的信号。比如这里我断言了登录后能看到我的账户标题、提交后能看到订单提交成功、订单号符合 12 位以上纯数字的规则。如果哪天订单号规则改了这个用例会因为业务规则变更而失败这恰恰是 e2e 应该做的事——提醒你业务契约变了。4. 稳定性歼灭战异步等待、接口 mock 与随机失败定位4.1 别再写 sleep 了自动等待与可操作性检查我接手过的项目里十有八九的 e2e 代码里躺着setTimeout或者waitForTimeout(3000)。这种固定等待是随机失败的第一大来源。网络快的时候 3 秒纯浪费网络慢的时候 3 秒根本不够。Playwright 真正改变体验的是自动等待机制点击一个按钮之前框架会自动轮询检查这个元素是否可见、是否稳定、是否可被点击等条件满足才动作等不到就按你的超时配置报错并附上实际状态快照。对比一下两种写法// 坏味道固定等待制造随机失败 await page.waitForTimeout(3000); await page.click(#submit); // 正确交给框架的可操作性检查 await page.getByRole(button, { name: 提交 }).click();习惯自动等待之后你会发现大量原来需要手工处理的竞态都消失了。唯一的代价是你要理解自动等待不等于所有等待比如某个接口返回之后的页面更新自动等待未必感知得到这时候需要配合断言轮询或者waitForResponse。4.2 接口 mock 的边界全 mock 会失真全真实会变脆e2e 测试里最纠结的问题之一就是接口到底要不要 mock。我的原则很简单内部服务尽量走真实环境第三方依赖必须 mock。支付回调、短信验证码、图形验证码这类强外部依赖不 mock 的话测试根本没法稳定跑。mock 的时候要注意mock 的粒度要尽量小只 mock 掉真正不可控的部分而不是把整个业务流程都 mock 成假数据。比如我只拦截第三方支付通知接口返回一个带固定 tradeNo 的成功响应其他业务接口全部走真实后端await page.route(**/api/payment/notify, async route { await route.fulfill({ status: 200, contentType: application/json, body: JSON.stringify({ code: 0, tradeNo: MOCK_TRADE_20250101 }), }); });如果所有内部接口都 mock你的 e2e 就从测系统变成了测自己的 mock 数据。我见过一个项目把用户列表、商品列表全部 mock 掉结果后端改了字段名e2e 全绿前端页面全白。这种假绿比没有测试更可怕因为它给了团队虚假的安全感。4.3 动画、懒加载、虚拟列表这些场景怎么等人真实页面里最讨厌的就是动画和懒加载。弹窗渐入要几百毫秒滚动加载要等接口返回虚拟列表还会让非可视区域元素直接不存在。这些场景处理方式要分开说。CSS 过渡动画优先用断言轮询比如断言提交按钮最终可见框架会自动等到动画结束await expect(page.getByText(操作成功)).toBeVisible();懒加载图片可以配合waitForResponse等真实的图片请求完成虚拟列表不要试图操作不可见的元素必须先scrollIntoViewIfNeeded()再操作。这里要特别提醒不要用networkidle这种等待方式SPA 应用里它经常等不到因为页面会持续有网络请求等它闲置基本等于等一个遥遥无期的状态。4.4 偶发失败三件套trace、录屏与日志关联随机失败是所有 e2e 维护者的噩梦。用例本地跑全绿上了 CI 就红重试一次又绿了。这种问题最有效的定位方式就是保留失败现场。Playwright 的 trace 会把用例的每一步操作、每个操作前后的 DOM 快照、每条网络请求、每个 console 报错全部记录在一个文件里配合录屏可以还原出完整的失败过程。配置上记得打开 trace 和 video 的失败保留模式use: { trace: { mode: retain-on-failure, sources: true, snapshots: true, screenshots: true, }, video: retain-on-failure, }我印象很深的一次排查一条用例在本地一直通过CI 里隔三差五就挂。用 trace 回放才发现staging 环境针对某些地区的 IP 会弹一个合规弹窗那个弹窗恰好盖住了提交按钮自动等待等不到按钮可点击就超时了。这类只在特定环境、特定网络条件下才出现的元素不开 trace 根本猜不到。5. 让 e2e 在 CI 中跑出价值环境、并行分片与失败反馈5.1 跑在哪独立测试环境比复用联调环境省心e2e 对环境的要求很高。直接复用团队联调环境是最省事但也最坑的方案别的同事改个 feature flag 你的用例挂了别人往数据库里灌了条脏数据你的用例也挂了并发跑的时候账号互相挤下线更是家常便饭。有条件的话一定要准备独立的 e2e 环境。不需要整套生产规模的基础设施至少保证前端服务、后端服务、数据库是相对独立的哪怕用 docker-compose 把核心链路需要的服务拉起来也够了。如果实在只能复用联调环境那就必须做到数据隔离用例里永远用独立的测试账号测试账号名带上时间戳或随机后缀避免并发冲突。测试数据用完及时清理不清理的话环境会越来越脏脏到一定程度所有用例都开始随机失败到那时候再清理成本就很高了。5.2 并行与分片把 20 分钟压到 4 分钟e2e 用例跑得慢是常态但通过并行和分片可以大幅缩短时间。并行是单机多 worker分片是把用例拆到多台机器上跑两者可以叠加。在 GitHub Actions 里用矩阵做分片非常方便strategy: matrix: shardIndex: [1, 2, 3, 4] shardTotal: 4 steps: - run: npx playwright test --shard${{ matrix.shardIndex }}/${{ matrix.shardTotal }}但并行有一个前置条件测试之间不能互相污染数据。所以我在 5.1 里说数据隔离是基础账号池里每台执行机拿到的账号各不相同数据库和缓存也必须在写入层面做好隔离设计。一套原本 20 分钟的用例分成 4 片并行之后压到 5 分钟以内这在 CI 里的体验差异是非常明显的——快反馈才能让团队愿意频繁跑 e2e 而不是把它从发布流程里踢出去。5.3 失败之后的反馈闭环跑完不算完失败后的反馈链路决定这套测试有没有人用。我的做法是三个环节报告可访问、失败通知到即时通讯群、失败信息带 trace 链接。HTML 报告上传为 CI artifact通知群里贴上失败用例名、失败截图、trace 查看链接相关人员点开就能看回放不用自己去 CI 日志里翻。还要定一个量化指标flake 率也就是失败次数除以总执行次数。如果一套用例的 flake 率高于 5%团队会逐渐默认红的就是环境问题然后开始忽略失败。我见过最离谱的项目CI 里 e2e 常年红着大家只管自己改动相关的那些用例其他失败一律不管。这已经是测试名存实亡的状态了。遇到 flake 率高的用例要立刻修修不好就先标记 skip也不要让它继续污染整体信号。6. 关于 e2e 的误区和踩坑记录数据、选择器与测试金字塔6.1 误区一e2e 越多越好这是最常见的坑。e2e 用例的维护成本是动态增长的用例越多互相之间的环境影响越大环境维护成本越高排查随机失败的时间成本也越高。我现在的衡量标准是核心业务链路必须有覆盖非核心页面宁可不写。20 条稳定得不行、每条都精心维护的用例价值远大于 500 条三天两头随机失败、没人敢看的用例。6.2 误区二用 e2e 代替单元测试测试金字塔不是摆设。如果把全部质量保障压在 e2e 上你会得到一个糟糕的调试体验e2e 只会告诉你有问题不会告诉你是前端组件逻辑错了、后端接口返回错了、还是数据库状态不对。而单测定位问题精确到函数和分支。正确的关系是单测和接口测试负责快速定位e2e 负责最终把关两者是互补不是替代。6.3 踩坑案例一个最稳定的选择器是怎么把整个套件搞崩的分享一个真实事故。我们之前有一套用例用了很长的 CSS 路径选择器里面的层级里带了nth-child定位。运行了几个月一直很稳定结果某次产品在页面顶部插入了一个新的运营 banner一夜之间三十多条用例全部挂掉。原因是 banner 插进来之后整个页面的子元素顺序全部后移nth-child全部指向了错误元素。那次之后我要求所有新写的用例一律使用语义化定位优先getByRole、getByLabel、getByTestId而不是 CSS 层级和索引。前端组件里统一约定加>button>await page.getByTestId(checkout-submit).click();>test.beforeAll(async ({ request }) { const resp await request.post(/api/orders, { data: { productId: P-1001, quantity: 1, paymentMethod: online }, headers: { Authorization: Bearer ${process.env.E2E_TOKEN} }, }); expect(resp.ok()).toBeTruthy(); });这样既保住了对关键链路的覆盖又控制住了整体时长和稳定性。项目的测试数据准备逻辑建议统一封装成数据工厂fixtures每个场景用一套 profile 定义好账号、商品、初始化状态用例们各取所需而不要在每条用例里硬编码一堆 magic number。踩过这些坑之后再回头看e2e 本质上不是一个技术问题而是一个工程治理问题。框架选得再好配置写得再全如果团队不认可核心链路必须守住这个目标用例很快会腐化成一堆没人愿意碰的负担。我个人最深的体会是从第一天就把稳定性和可维护性当第一优先级数据隔离、语义化定位、失败现场保留这三件事宁可先做扎实再扩用例。先把最核心的几条链路维护得像铜墙铁壁再慢慢往外延伸。