ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从零搭建E2E测试工程:Playwright选型、落地与踩坑复盘

从零搭建E2E测试工程:Playwright选型、落地与踩坑复盘

“e2e”这三个字母,任何后端、前端、测试工程师看到都不会陌生,它是 end-to-end 的缩写,中文叫端到端。我做后端出身,早年对端到端测试的态度一直很别扭:业务代码写完,单元测试也补了,接口联调没问题,为什么还要花大把时间维护一套跑得又慢又脆的 e2e 用例?直到有一次线上事故彻底改变了我的看法——某个订单状态流转的边界条件,单元测试全部通过,结果因为两个服务之间一个字段名的历史差异,用户在下单流程里把订单重复提交了,数据直接脏了。那晚修完数据,我第二天上班第一件事就是给项目引入端到端测试。这篇内容不聊理论,就聊聊我后面这一年把 e2e 从零搭起来、踩坑、返工、最终稳定运行在 CI 流水线里的全部过程。如果你正打算上马 e2e,或者已经被用例的脆弱性折磨到头秃,这篇文章应该对你有用。

1. 先搞懂E2E到底在解决什么问题

1.1 E2E在软件工程中的准确位置

E2E 是 end-to-end 的简称,指从真实用户视角出发,完整走完一条业务链路,验证系统在最接近生产的环境下是否正常工作。拿下单举例:用户打开页面、登录、搜索商品、点击加购、进入结算页、填收货地址、支付回调、订单入库、用户端看到订单状态变化——这一整套动作,在真实浏览器里从头跑到尾,才叫一条合格的端到端用例。

与单元测试、集成测试的区别,我用汽车来打比方:单元测试是检查每个发动机零件是否合格,集成测试是把发动机、变速箱装在一起测试是否匹配,而 e2e 测试是整车路试,从点火到上高速,模拟真实驾驶场景。零件都合格不代表车就一定好开,模块能匹配不代表用户就一定能顺利下单。历史上很多严重线上故障,根因恰恰出在“每个模块都正确,但组合起来不对”的接缝处。

从测试金字塔来看,E2E 处在最顶端,数量最少,但最贴近用户真实体验。合理的比例是大量单元测试、中等数量的集成测试、少量关键路径的端到端测试。很多人把这个金字塔反过来用,把所有业务场景全写成 e2e,结果维护成本爆炸,这恰恰是走向另一个极端。我见过一个团队,测试代码仓库比业务代码仓库还大,跑一次全量要两小时,最后谁都不敢动这个测试套件,相当于给自己建了一座玻璃牢房。E2E 的定位应该是“守护核心”,而不是“覆盖一切”。

1.2 为什么很多团队跳过E2E后追悔莫及

为什么单靠单元测试和接口测试还不够?最典型的翻车场景是微服务架构:A 服务和 B 服务各自都对,但 B 服务期望的字段名是 createTime,A 服务返回的是 createdAt,这样的问题在接口测试里往往发现不了,因为接口测试通常是契约驱动,两边各自 mock 后又各自通过。等到真实浏览器里用户操作,前端把参数拼出来打到网关,网关再路由到服务,任何一环的字段错位都会导致“看起来全绿,上线就崩”。

还有一类场景:权限控制、前端路由守卫、第三方支付回调、限流降级策略,这些跨端跨服务的行为,只有在真实浏览器里把完整路径走一遍才能发现。我没见过哪家团队能完全靠手工测试把这类问题防住,尤其当系统越来越复杂、回归频率越来越高时,手工回归根本顾不上。早年间我们每两周发一次版,全靠测试妹子手工回归几百条用例,后来改成每天发布,手工回归直接变成摆设,线上问题频发,这才逼着我们把 e2e 提上日程。

所以 e2e 测试不是锦上添花,它是在“系统复杂到人工回归已经不可依赖”这条分水岭上的必需品。但它也绝不是越全越好。你不需要覆盖一百条用例,你需要覆盖的是那些一旦坏了就会让公司赔钱、让用户骂街的核心路径。这个观念的转变,是我在项目里反复和产品、前端、测试同事磨了大半年才统一下来的。毕竟每写一条用例,后面就是长期的维护成本,动笔之前想清楚值不值得,比什么都重要。

2. 工具选型:Playwright、Cypress还是Selenium?

2.1 三种主流工具的核心差异

先直接说结论:如果你的项目是从零开始,优先考虑 Playwright 或 Cypress;如果只是维护老项目,Selenium 依然有存在的理由。但我的建议是,新项目直接选 Playwright。

用表格看三者差异:

维度PlaywrightCypressSelenium
底层架构浏览器自带调试协议(CDP)注入浏览器内部运行WebDriver协议(JSON Wire)
自动等待内置等待内置等待基本无,需手动显式等待
多标签页/多上下文原生支持较弱,需要hack支持但配置繁琐
移动端模拟原生支持(设备描述符)部分支持依赖Appium扩展
网络模拟/拦截内置 route 拦截内置 intercept依赖代理工具
调试体验Trace Viewer + Inspector自带时间旅行调试基本靠日志和截图
并行能力多worker原生支持可用但受限于架构需自己搭建网格

Selenium 的老员工地位不用多说,WebDriver 协议让几乎所有编程语言都能用。但它的最大问题是慢:每次操作都要经由 WebDriver 服务器、走 HTTP 协议,并且它没有内置的自动等待机制,你得多写大量 waitUntil 代码,代码复杂度直线上升。我记得用 Selenium 写过一个简单搜索用例,光是等元素可用的代码就写了十几行,每次跑还动不动因为网络抖动闪红,那会儿查问题的成本高到让我一度怀疑自动化测试到底有没有价值。

Cypress 的优势是开发者体验极好,时间旅行调试是杀手锏,出错时你可以看到每步操作之前的状态。但它有几个硬伤:不支持多标签页操作,尤其遇到“点击支付跳到新窗口再跳回来”这类场景会很痛苦;而且它只能在它支持的浏览器范围内跑,早期对 Firefox 和 WebKit 的支持都不理想。如果你是纯前端团队,项目不涉及复杂跨页场景,Cypress 至今仍然是个好选择,只是它的边界你得清楚。

Playwright 是微软开源的,底层直接通过 CDP(Chrome DevTools Protocol)和浏览器原生调试协议通信,从设计层面就把“快”和“稳”当作目标。它最打动我的一点是自动等待——你只需要描述“我要点击什么”,它自动帮你等元素可操作,不需要手写 sleep 循环。这个特性看起来不起眼,实际写用例时体验天差地别:我的测试代码行数大概减少了一半,且不会再因为“等不到元素”这种弱智问题反复修用例。

2.2 我为什么最终选择Playwright作为主力

我遇到过一个让我决定换掉 Cypress 的真实场景:被测系统里有个支付流程,用户点击“去支付”后会打开一个银行收银台新标签页,输入验证码后再跳回商户系统。Cypress 对这种跨标签页场景支持很差,我不得不用 cy.window().then 把 window.open 改写掉,强行塞到同一个页面里,虽然测试能跑通,但已经完全不是真实用户路径了。换到 Playwright 后,直接用 context.waitForEvent('page') 拿到新页面,操作完关闭,逻辑清清楚楚,也终于回归到了真实用户的行为路径。

另外 Playwright 的多浏览器支持是真正的原生支持:Chromium、Firefox、WebKit 三套引擎都能跑,WebKit 覆盖的是 Safari 的真实内核,这在兼容性场景非常有用。再加上它可以模拟移动端设备(比如 iPhone 14 的视口和 UA),一套用例能做响应式页面和移动端 H5 的基础验证。我们这个项目既有 PC 管理后台又有移动端 H5,一套工具两边都能管,省下的搭建成本不是个小数目。

调试体验上,Playwright 有两个工具我非常依赖:一个是 trace viewer,跑完用例后自动生成一个完整的时间线回放,每一步的 DOM 快照、网络请求、控制台日志、截图一应俱全,排查 CI 里的偶发失败非常有用;另一个是 codegen,直接在浏览器里点击操作就自动生成测试代码,虽然生成的代码通常需要手动清理,但用来快速了解某个复杂控件的选择器结构效率极高。我经常跟团队说,光这两个工具,就足够让 e2e 从“写完就扔”变成“敢写敢改敢维护”。

最后是社区趋势。大量新项目开始默认内置 Playwright 支持,比如 Vue、Angular、Svelte 的官方测试文档里都出现了 Playwright 的身影;GitHub 上它的 star 增长也是同类工具里最快的。押注一个还在快速迭代且有大厂背书的工具,长期维护风险低很多。我唯一提醒的是:Playwright 还在快速迭代中,API 偶尔会有 breaking change,建议锁定大版本并定期跟随更新。我们项目里专门有个依赖更新 PR,每周升一次小版本,保证大版本有变更时能平滑处理。

3. 从零搭建一套可落地的E2E工程

3.1 项目初始化与目录结构设计

假设你已经有一个前端项目,我要演示的是一个独立于被测应用之外的 e2e 测试工程。为什么要独立?因为 e2e 测试需要跑在干净的环境里,如果和业务项目混在一起,依赖冲突、构建配置互相干扰都是常态,后面 CI 也难隔离。我自己吃过这个亏,最初把测试写在业务仓库里,升级了某个 UI 库之后测试框架跟着坏掉,排查问题浪费了一下午,后来还是老老实实拆成了独立仓库。

新建目录动作很简单,以 Playwright 官方的 TypeScript 模板为例:

npm init playwright@latest

跑完后会有一些默认文件,但我建议立刻重构目录结构。我目前比较满意的一套结构长这样:

e2e/ ├── config/ │ ├── base.config.ts │ ├── profile1.ts │ └── profile2.ts ├── pages/ │ ├── login.page.ts │ ├── order.page.ts │ └── checkout.page.ts ├── tests/ │ ├── smoke/ │ │ └── login.spec.ts │ └── regression/ │ └── order-flow.spec.ts ├── data/ │ └── users.json ├── utils/ │ ├── api-helper.ts │ └── expect-helper.ts ├── global-setup.ts └── playwright.config.ts

说明每个目录的用途:config 目录放不同环境、不同 profile 的配置;pages 目录做页面对象模型(Page Object Model,POM),把某个页面的操作封装成方法,比如 loginPage.login('admin', 'pass'),测试用例里只关心业务动作,不关心按钮选择器长什么样。这是 e2e 工程里最核心的一个抽象层,它的好处是当页面结构变化时,只需要改这一个文件,所有用例不用跟着动。我曾经用非 POM 方式写过两百行用例,结果一个按钮 class 被前端改了,牵连了十几个用例,一口气改了一晚上,打那以后 POM 成了我的铁律。

utils 目录放与业务无关的工具,比如构造测试数据的 api-helper、常用的断言方法;data 目录放测试数据,注意只是静态数据,真正会变的测试数据推荐由 API 动态创建,后面测试数据那段我会单独讲。global-setup.ts 负责所有用例执行前的初始化工作,比如准备登录态、创建测试账号。别小看这个初始化步骤,把耗时的公共操作放到这里,比每一条用例自己重复登录要高效太多。

3.2 核心配置:多环境Profile方案(profile1的诞生)

热词里提到的“profile1”,其实就是这套多环境配置中的第一套 profile。我在实际项目中会给每个环境准备一份独立配置,默认区分三个环境:

  • profile1:测试环境(test env),稳定版本,供日常开发自测和 nightly 回归
  • profile2:预发布环境(staging env),上线前验证
  • profile3:本地环境(local env),开发者本机快速调试

这样区分的好处是:用例代码完全不用改,只需要通过环境变量或命令行参数切换 profile,就能在不同环境上执行同一套代码。切换命令像这样:

npx playwright test --config=config/profile1.ts

我的一句经验是:不要在用例代码里写死任何环境地址,环境地址必须全部来自配置文件。否则某天你把测试环境域名换了,就要全局搜索替换,极容易漏掉一两处,然后跑半天才发现连的是旧地址。这种低级问题真的很消耗人的耐心。

一个简化版的 profile1 配置长这样(TypeScript):

import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ // 测试入口目录 testDir: '../tests/', timeout: 60_000, expect: { timeout: 10_000 }, fullyParallel: true, retries: process.env.CI ? 2 : 0, // 执行用例前的全局初始化 globalSetup: '../global-setup.ts', use: { baseURL: process.env.E2E_BASE_URL || 'https://test.example.com', headless: true, viewport: { width: 1280, height: 720 }, screenshot: 'only-on-failure', video: 'retain-on-failure', trace: 'retain-on-failure', // 关键:复用登录态,避免每条用例都走一次登录流程 storageState: 'data/storage-state.json', launchOptions: { args: ['--disable-dev-shm-usage'], }, }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, ], });

你可能注意到 baseURL 是从环境变量里读的,当没有环境变量时回落到测试环境地址。这么做是为了本地开发时可以快速覆盖目标环境,比如我想调试生产环境的页面,一条命令就能跑起来:

E2E_BASE_URL=https://www.example.com npx playwright test --config=config/profile1.ts tests/smoke/login.spec.ts

这套 profile 方案真正的威力在于:当你需要切换环境时,完全不需要碰用例代码,只需要换一个配置文件,或者覆盖一个环境变量。我在项目里还加了一个约定:profile1 跑 nightly 回归,profile2 在发布前由流水线强制执行,一旦红了就阻断发布。这样 e2e 就从“人工想起来才跑”变成了“没有跑过就发布不了”,这才是它真正的价值所在。

3.3 第一个端到端用例的完整编写过程

直接拿“登录后下单”这条最经典的路径来说,这是绝大多数业务系统的命脉。先看用例文件长什么样:

import { test, expect } from '@playwright/test'; import { LoginPage } from '../pages/login.page'; import { CheckoutPage } from '../pages/checkout.page'; test('完整购买流程:登录-搜索-加购-结算', async ({ page }) => { // 1. 打开首页并登录 const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('tester01', 'password-hash'); // 2. 搜索商品 await page.getByPlaceholder('请输入商品名称').fill('无线耳机'); await page.getByRole('button', { name: '搜索' }).click(); await expect(page.getByText('搜索结果')).toBeVisible(); // 3. 加入购物车 await page.locator('[data-testid="product-card"]').first().click(); await page.getByRole('button', { name: '加入购物车' }).click(); // 4. 前往结算 await page.getByRole('button', { name: '去结算' }).click(); await expect(page).toHaveURL(/\checkout/); // 5. 确认订单 const checkoutPage = new CheckoutPage(page); await checkoutPage.submitOrder('张三', '13800000000', '北京市朝阳区xxx'); await expect(page.getByText('订单提交成功')).toBeVisible(); });

有几个点新手容易踩,我逐个说。

第一,不要用 page.waitForTimeout(3000) 这种固定等待。Playwright 有自动等待机制,你只需要在断言前写 expect(...).toBeVisible(),它会轮询直到元素出现或超时。滥用固定等待会让测试又慢又脆。有些同事习惯从 Selenium 时代带来这种写法,我每次 code review 看到 waitForTimeout 都会让它删掉换成显式断言。

第二,选择器优先用 role 和 text 语义,其次是稳定的>await page.route(/\/(gtm|analytics|fonts)\./, route => route.abort());

第三步,如果确认是被测应用自己的慢接口拖累,给该用例标记为慢用例单独串行执行,用更长的 timeout,或者给接口层添加“可接受延迟”的断言。不要一刀切把所有用例的 timeout 都调大,那样失败反馈会很慢,反而更糟。

另外一个常见误区是把 headless 模式下的截图当证据,结果页面其实渲染得很慢,截图时还是空白。记得截图前先等待关键渲染元素可见,再截图。我见过同事提交 bug 的时候附带一张全白截图,后面定位了半天才发现是截图时机太早。这种问题不算难,但很耽误事。

5.2 元素定位脆弱:从根源上减少维护成本

元素定位是 e2e 维护成本的大头。前端一天不改,测试就红,是很多人放弃 e2e 的心理阴影来源。实际上大部分定位脆弱是可以从设计上避免的。

先列出三种容易踩坑的选择器写法:

  • 绝对路径:html > body > div#root > div > div:nth-child(3),最脆弱,页面加一个节点直接炸。
  • 依赖动态 class:btn btn-primary-hashed-abc123,每次前端改样式名就炸。
  • 依赖深层文本且不限定范围:getByText('提交'),页面上有多个“提交”按钮时会报严格模式错误。

推荐的选择器顺序是:role 语义 -> 稳定 text ->>

RELATED READING

延伸阅读

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