☰
端到端测试(e2e)实战:Playwright框架、profile配置与CI回归
2026/10/26 15:48:06 网站建设 项目流程

1. 从"e2e"这个词说起:到底在测什么

做软件这行,几乎每天都离不开"e2e"三个字母。别小看这个词,它背后代表的是整套测试体系里最贴近用户真实体验的那一层——端到端测试(End-to-End Testing)。我经常跟团队里新来的同学说:单元测试是检查螺丝钉拧没拧紧,集成测试是检查齿轮咬合得对不对,而 e2e 测试就是整车通电之后,从点火、挂挡、踩油门到车轮真正转起来的全过程。

e2e 测试解决的核心问题只有一个:用户在真实场景里能不能顺利完成操作。它不管内部哪个函数调用有问题,也不管某个模块的接口返回是否标准,它只关心一件事——当用户点击"登录"按钮,填上账号密码,按下回车,系统是不是真的把他带进了首页,而不是卡在加载动画里出不来。

这个测试层级最大的价值在于兜底。我在实际项目里见过太多这样的案例:单元测试全绿、接口测试全过,结果一上线用户就反馈"下单根本提交不了"。原因往往是前端某个字段的传参名跟后端约定的不一致,或者是路由守卫把正常跳转给拦了。这些问题,只有 e2e 测试能抓住。

如果你是刚接触测试领域的新手,或者你的团队一直靠"手工点点点"来验证功能,这篇文章就是为你准备的。我会从 e2e 的核心设计思路讲起,再到工具选型、配置写法、常见坑位排查,全程用实际项目里的经验说话,不讲空理论。文章里我会反复提到一个词:e2e profile。你可以把它理解成一套"测试场景的预设档案",后面我会专门用一个章节讲清楚它到底是什么、怎么用、为什么好用。

2. e2e 测试的整体设计:别一上来就写脚本

2.1 先搞明白 e2e 测试和"自动化脚本"不是一回事

很多团队对 e2e 有个误解:以为写了几个自动化脚本、定时跑一跑,就算做了端到端测试。其实差得远了。e2e 测试的核心是一套完整的测试策略,而不只是脚本本身。

我分享一个真实经历。之前有个项目,前端用 Vue,后端是微服务架构,登录状态靠 JWT 维护,支付流程要调第三方接口。团队一开始只写了三个用例:登录、注册、个人资料修改。跑了两个月,发现线上事故依然不断——因为真正容易出问题的是组合流程,比如"用户注册完 → 绑定手机号 → 领取新人券 → 用券下单 → 支付成功 → 查看订单状态",这一条链路涉及 6 个页面、4 个微服务、2 个第三方 API。任何一个环节出错,用户感知到的就是"下单失败"。

所以设计 e2e 测试,第一个原则是跟着核心业务流走,而不是跟着页面走。你要把用户最高频、价值最高的几条路径列出来。比如电商项目就是:搜索商品 → 加入购物车 → 结算 → 支付 → 查看订单。内容社区就是:注册 → 发布内容 → 互动评论 → 消息通知。这些路径有一个共同特点:跨模块、跨团队、最容易出集成问题。

第二个原则是控制粒度。e2e 测试不是把所有功能点都自动化,那样维护成本会爆炸。它只覆盖关键业务流和冒烟测试路径。那些相对独立的功能,交给单元测试和组件测试就够了。我见过有人把"修改昵称时的字符长度校验提示"也写成 e2e 用例,结果每次样式微调都要跑一遍全量测试,耗时半小时,反馈周期长到令人绝望。

2.2 e2e profile:让一套代码管理多套环境

这里就引出了e2e profile这个概念。简单说,profile 就是一组预定义的配置集合,它把"测试环境地址、登录账号、超时时间、浏览器参数、启用的功能开关"等变量打包在一起,形成一套可切换的测试档案。

为什么要这样做?因为在真实项目里,你至少有三个环境要跑 e2e:本地开发环境、测试联调环境、预发布环境。这三个环境的域名不同、数据不同、甚至功能开关也不同(比如预发布环境不启用真实支付)。如果每次切换环境都要手动改脚本里的 URL 和账号,那简直是一场灾难。

我的做法是创建一个e2e.config.ts文件,里面用 profile 对象维护多套环境配置:

// e2e.config.ts export const profiles = { dev: { baseURL: 'http://localhost:5173', users: { normal: { username: 'dev_user', password: '123456' }, admin: { username: 'dev_admin', password: '123456' } }, timeouts: { navigation: 10000, action: 5000 }, featureFlags: { useMockPay: true } }, staging: { baseURL: 'https://staging.example.com', users: { normal: { username: 'staging_user', password: '******' }, admin: { username: 'staging_admin', password: '******' } }, timeouts: { navigation: 15000, action: 8000 }, featureFlags: { useMockPay: true } }, production: { baseURL: 'https://www.example.com', users: null, // 生产环境不跑真实账号 timeouts: { navigation: 20000, action: 10000 }, featureFlags: { useMockPay: false } } };

然后通过命令行参数指定要跑哪个 profile:

npx playwright test --project=e2e --profile=staging

这里的思路其实跟后端开发的"多环境配置"一脉相承。好处有三个:切换环境零成本、账号信息集中管理、不同环境的特殊逻辑(比如支付 mock)可以按配置自动启用/禁用。我在多个项目里都用了这套方案,稳定性和维护效率都明显提升。

3. 工具选型:Playwright、Cypress 和 Selenium 怎么选

3.1 三款主流框架的优缺点对比

市面上主流的 e2e 测试框架就那几个:Selenium、Cypress、Playwright。我三个都用过,说下最直观的感受。

Selenium是老牌选手,优点是对浏览器种类的覆盖最全,WebDriver 协议支持 Chrome、Firefox、Safari、Edge 甚至旧版 IE。但它的缺点也明显——因为要通过 WebDriver 跟浏览器通信,执行速度慢,而且写出来的代码相对冗长。最让我头疼的是它处理异步加载元素时很啰嗦,经常要自己写显式等待。如果你维护的是一个需要兼容超老浏览器的企业系统,Selenium 可能是唯一选择,但如果你在做现代 Web 应用,我一般不推荐它作为 e2e 首选。

Cypress的优势在于开发体验好,它自己封装了完整的断言库,测试用例写起来接近自然语言,而且有一个非常好用的浏览器调试面板,出问题可以直接看到每一步操作的截图和 DOM 快照。它的限制在于:只支持自己的浏览器运行模式,跨浏览器测试不如 Playwright 顺手;另外它默认不直接支持多标签页操作,有些场景要做额外适配。

Playwright是我现在的主力工具,由微软维护。它最打动我的几点是:支持 Chromium、Firefox、WebKit 三套浏览器引擎;自带自动等待机制,不用天天写sleep(1000)这种垃圾代码;网络拦截非常强大,可以轻松 mock 接口返回;还有 trace viewer,测试失败后能看到完整的现场回放。它的语法也很简洁,配合 TypeScript 用起来非常舒服。

我给团队的选型建议是这样的:新项目直接用 Playwright,老项目想要快速上手、团队 Ruby 背景比较重才考虑 Cypress,只有被强制要求兼容 IE 老古董才选 Selenium。

3.2 安装与初始化:三步走,别踩版本坑

Playwright 的安装其实很简单,但有几个细节要注意。我第一次装的时候就被"缺系统依赖"折磨了半天。

第一步,在你已有 Node.js 项目里安装测试库和 browsers:

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

这里有个坑:npx playwright install默认只装当前系统的浏览器。比如你在 macOS 上开发,CI 跑在 Ubuntu 上,就还需要执行:

npx playwright install --with-deps chromium

--with-deps参数会自动安装浏览器运行所需的系统库,这一步在 Linux 环境尤其重要,否则启动浏览器时经常报各种 .so 文件缺失。

第二步,初始化测试目录。我一般习惯建e2e/目录,把测试文件和配置文件分开:

mkdir -p e2e/tests e2e/fixtures touch playwright.config.ts

第三步,在playwright.config.ts里配置基础参数。这里我建议直接开启 trace 和截图保存功能,方便排查问题:

import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './e2e/tests', timeout: 60000, expect: { timeout: 10000 }, fullyParallel: false, // e2e 建议串行,避免共享状态冲突 retries: 1, reporter: [['list'], ['html', { open: 'never' }]], use: { baseURL: 'http://localhost:5173', viewport: { width: 1280, height: 720 }, screenshot: 'only-on-failure', video: 'retain-on-failure', trace: 'retain-on-failure' }, projects: [ { name: 'chromium', use: { browserName: 'chromium' } } ] });

这里的retries: 1值得单独说一下。e2e 测试因为涉及真实浏览器和网络,偶尔会有偶发性失败(flaky),所以重试一次是合理的。但重试次数不能太多,否则失败原因会被淹没——我在实践中发现,超过 2 次重试反而会让你忽视真正的问题。

4. 实操环节:从第一个用例到业务流覆盖

4.1 写第一个登录用例,注意这3个细节

我习惯用登录场景作为第一个 e2e 用例,因为它最简单,但又包含了所有核心要素:路由跳转、表单填写、按钮点击、等待响应、断言页面状态。下面这个例子我基于常见实践写一个可直接跑的版本:

import { test, expect } from '@playwright/test'; test('用户可以使用正确账号密码登录', async ({ page }) => { // 步骤1:访问登录页 await page.goto('/login'); // 步骤2:填写表单 await page.getByLabel('用户名').fill('test_user'); await page.getByLabel('密码').fill('test_pass'); // 步骤3:勾选"记住我"(顺便测一下 checkbox 交互) await page.getByRole('checkbox', { name: '记住我' }).check(); // 步骤4:点击登录按钮 await page.getByRole('button', { name: '登 录' }).click(); // 步骤5:等待跳转并断言 URL await expect(page).toHaveURL(/\/dashboard/); // 步骤6:断言页面关键元素出现 await expect(page.getByText('欢迎回来')).toBeVisible(); });

这段代码看起来简单,但里面有三个我在实际中踩过坑的细节:

第一,优先用getByRole和getByLabel而不是 CSS 选择器。getByRole('button', { name: '登 录' })匹配的是可访问性树里的按钮角色,即使按钮文本里有空格或图标,只要 accessible name 对得上就能找到。CSS 选择器对类名改动极其敏感,前端重构一下测试就挂了。

第二,点击登录后不要立刻断言收据,用await expect(page).toHaveURL(/\/dashboard/)这个自动等待的断言。Playwright 的 expect 会等待一段时间(默认 10 秒),直到条件满足或超时。你不需要自己写page.waitForTimeout(),手动等待只会拖慢执行速度。

第三,登录用例别复用 token,就老老实实走一遍表单。很多团队为了提速,第一个登录用例跑完后把 cookie 缓存起来,后面的用例直接注入登录态。这样确实快,但也把登录页面的回归覆盖给丢了。我的折中方案是:单独维护一条"登录态前置"的 fixture,在关键业务流用例里复用,但冒烟用例里必须保留一个完整的登录用例。

4.2 构建一条完整的电商下单业务流

登录用例只是开胃菜,e2e 测试的价值在于跑通完整业务流。下面我以一个典型电商场景为例,写一个完整的"搜索→加购→结算→支付→订单确认"用例,并解释每一步的思考方式。

test('用户能完成商品下单全流程', async ({ page }) => { // 前置:登录(通过 API 注入登录态,节省时间) await page.request.post('https://staging.example.com/api/test-login', { data: { userId: 'test_user' } }); await page.goto('/'); // 1. 搜索商品 await page.getByPlaceholder('搜索商品').fill('无线耳机'); await page.getByPlaceholder('搜索商品').press('Enter'); await expect(page).toHaveURL(/\/search/); await expect(page.getByText('无线耳机').first()).toBeVisible(); // 2. 进入商品详情并加入购物车 await page.getByText('无线耳机').first().click(); await page.getByRole('button', { name: '加入购物车' }).click(); await expect(page.getByText('已加入购物车')).toBeVisible(); // 3. 进入购物车,点击结算 await page.getByRole('button', { name: '去购物车' }).click(); await page.getByRole('button', { name: '结算' }).click(); await expect(page).toHaveURL(/\/checkout/); // 4. 填写收货地址 await page.getByLabel('收货人').fill('张三'); await page.getByLabel('手机号').fill('13800138000'); await page.getByLabel('详细地址').fill('某某省某某市某某路1号'); await page.getByRole('button', { name: '提交订单' }).click(); // 5. 支付(mock 第三方支付,走测试模式) await expect(page).toHaveURL(/\/pay/); await page.getByRole('button', { name: '确认支付' }).click(); // 6. 断言订单成功 await expect(page).toHaveURL(/\/order\/success/); await expect(page.getByText('订单支付成功')).toBeVisible(); });

这段流程看起来顺理成章,但真正跑起来你会遇到各种幺蛾子。举几个典型:

搜索结果是异步加载的,直接断言"可见"可能会偶发失败。我就遇到过搜索结果里同时出现了多个"无线耳机"文案,导致.first()选错元素。后来我改成先断言搜索结果的容器加载完毕,再点进去。另外,搜索结果里有外链广告位时,getByText('无线耳机')可能匹配到广告位里的商品,然后点击跳去了第三方网站。解决办法是缩小选择范围,比如在商品列表容器内查找:

const productList = page.locator('.product-list'); await productList.getByText('无线耳机').first().click();

支付环节是最容易 flaky 的地方。真实的第三方支付页面无法在测试环境稳定重现,所以必须用网络 mock 把支付页面的接口拦截掉。Playwright 里我一般这样做:

await page.route('**/api/pay', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, orderId: 'TEST_ORDER_001' }) }));

然后把前端代码里的"支付成功"跳转逻辑跑通。这里关键的思路是:e2e 测试要验证的是应用自己的流程逻辑,第三方依赖必须可控。支付、短信验证码、短信通知这类外部依赖,能 mock 就 mock,不能 mock 就引入一个测试专用后门,否则你的测试结果会被外部系统的不确定性污染。

4.3 共享登录态:fixture 的正确打开方式

一个项目跑着三五十个 e2e 用例,如果每个用例都从登录页开始走一遍,每一轮全量回归要多花十几分钟。所以我几乎每个项目都会做一个"已登录用户"的 fixture,基础思路是:通过 API 直接创建会话,并把登录态保存到浏览器上下文里。

在 Playwright 里,fixture 的正确打开方式是这样的:

// e2e/fixtures/authenticated.ts import { test as base, expect } from '@playwright/test'; export const test = base.extend({ authenticatedPage: async ({ browser }, use) => { const context = await browser.newContext(); const page = await context.newPage(); // 通过接口登录,拿到 session token const loginRes = await page.request.post('/api/login', { data: { username: 'test_user', password: '123456' } }); const { token } = await loginRes.json(); // 注入 localStorage 或 cookie await context.addCookies([{ name: 'session_token', value: token, domain: 'localhost', path: '/' }]); await use(page); await context.close(); } });

然后每个业务流用例就可以直接从某个业务页面开始跑,不再重复走登录流程。这里有个经验:fixture 的设置和清理逻辑要成对出现,我在早期项目里不重视清理,导致用例之间互相污染,最典型的是购物车里的数据串场——你在 A 用例加的购物车商品,跑到 B 用例时还在。后来统一在test.beforeEach里清空购物车,才彻底解决。

4.4 跑通之后:如何接入 CI 做自动回归

e2e 测试的价值全靠持续运行来体现。写完所有用例还只是开始,真正的目标是让它在每次代码合并前、每次发布前自动跑一遍。

我的做法是配合 GitHub Actions 或者 GitLab CI,在流水线里加一个 e2e 任务。关键点是要先把测试环境服务启动起来,然后等待服务就绪再执行 Playwright。

# 示例:GitLab CI 片段 e2e-test: stage: test image: mcr.microsoft.com/playwright:v1.40.0-focal script: - npm ci - npm run build - npx playwright install --with-deps chromium - npm run dev -- --host 0.0.0.0 & - npx wait-on http://localhost:5173 - npx playwright test --project=e2e artifacts: when: always paths: - playwright-report/ - test-results/

这里使用官方 Playwright Docker 镜像能省很多事,系统依赖都提前装好了。wait-on的作用是轮询等待开发服务器就绪,避免 e2e 测试启动时首页还没有响应。

如果测试规模大了之后跑得慢,我一般分两步优化:第一步,把用例按业务模块切片,用--grep参数分别跑,并行执行;第二步,引入 sharding 机制,把测试平均分到多个 worker 上跑。Playwright 自带--shard参数,CI 里可以这样串起来:

- npx playwright test --shard=1/4 - npx playwright test --shard=2/4 - npx playwright test --shard=3/4 - npx playwright test --shard=4/4

这样四台机器同时跑,全量 80 个用例的时间从 25 分钟降到 7 分钟左右,反馈速度提升非常明显。

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

5.1 元素定位失败:优先调整选择策略,不要硬等

e2e 测试最容易崩的就是元素定位。我统计过项目里的 flaky 用例,80% 以上都是 locator 的问题。典型报错长这样:

TimeoutError: locator.click: Timeout 10000ms exceeded Call log: - waiting for locator('button:has-text("提交")')

遇到这种问题,我的排查顺序是这样的。

第一步,先确认元素是否真的存在,打开 trace viewer 或者重跑一次带录制视频的用例,看看页面实际长什么样。很多时候问题出在元素被遮挡——比如弹窗盖住了按钮,或者按钮还在 loading 状态但文案还没变。

第二步,检查 locator 是否过于宽泛。button:has-text("提交")这种写法,如果页面上有"提交订单"、"提交评论"、"提交退款申请"三个按钮,它就会匹配多个然后报 strict mode violation。解决办法是增加限定范围,比如加上父级容器:

await page.locator('.checkout-footer').getByRole('button', { name: '提交订单' }).click();

第三步,如果确定是页面加载慢导致的,不要急着加waitForTimeout,应该用page.waitForSelector或expect(locator).toBeVisible()的自动等待。手动等待数字拍脑袋写,环境一慢就挂,环境一快又浪费大量等待时间。

5.2 偶发失败(flaky):拿到 trace,先归类再修复

每一个做 e2e 的人都会跟 flaky test 打交道,这东西才是真正的拦路虎。我处理 flaky 的思路是拿到失败现场后按下面几个方向归类:

网络时序问题:接口返回慢导致某个元素延迟出现。处理方式是把关键接口断言一下,比如点击按钮后先等待某个 API 响应:

const resPromise = page.waitForResponse('**/api/order'); await page.getByRole('button', { name: '提交订单' }).click(); const res = await resPromise; expect(res.ok()).toBeTruthy();

共享状态污染:上一个用例留下的数据影响下一个用例。这个最典型的就是"没有保证测试数据隔离"。我在团队里立了条规矩:测试用例要么用随机数据,要么在用例结束时主动清理数据,绝不让用例依赖上一个用例留下的状态。

CSS 动画和过渡导致的短暂不可用:点击时按钮还在位移,或者下拉框还在收起过程中。处理方式是触发点击前先等待元素稳定:

await expect(page.getByRole('button', { name: '提交订单' })).toBeEnabled();

这个toBeEnabled()比等动画结束更可靠,它实际上会轮询元素的 disabled 状态,直到变为可点击。

5.3 环境问题:浏览器启动失败和端口冲突

新入坑的人还容易遇到两类环境类问题。一类是 Linux CI 上浏览器启动失败,报缺少依赖库。解决办法我已经在前面提过,就是npx playwright install --with-deps,不推荐手动去一个个装系统包,太容易漏了。

另一类是本地调试时Error: port 9324 is already in use。这是 Playwright 的调试服务端口被占了。一般lsof -i :9324找到进程杀掉就行,但更优雅的解决办法是在配置里指定一个随机端口:

use: { port: 0, // 让系统自动分配 }

5.4 排查工具三板斧:trace、录屏、截图

最后分享我排查现场问题的三板斧。Playwright 跑完用例之后,如果开启了 trace 和 video,你会在test-results目录下看到每一次失败的完整录屏和 trace 文件。

trace viewer 是我调试的第一选择,它不仅能看页面截图,还能看到每一步操作发生了什么、Console 里有没有报错、网络请求的响应体是什么。很多时候一个问题看不清楚,是因为你只看测试代码的日志,而 trace 能给你完整的浏览器内部视角,这个信息量是任何日志都比不了的。

如果是纯 UI 布局问题,看录屏比看截图更直观,因为你能看到交互过程里发生了什么。我遇到过好几次"明明在测试里点了按钮,视频里却没看到点击动作"的诡异事件,最后都是靠录屏发现是测试代码里的坐标定位到了页面的某个透明遮罩层。

6. 我的几点亲身体会

做了这么多年 e2e,有个感受特别深:e2e 测试写起来不是难事,难的是设计好该测什么、不该测什么,以及把它稳定地跑起来并坚持跑下去。不少团队写了一堆用例,维护成本高到崩溃,最后的结局是删掉跑不完的测试,回到人工点点点的老路。我的建议是宁可慢一点,也要先想清楚第一条核心业务流是什么,把这条链路跑得铁稳,再逐步扩展。

另外一个小技巧很值得分享:把 e2e 测试跑出来的失败当"第一优先级"来处理。在团队里我定了条规矩——任何人收到 e2e 失败的告警,要么在 30 分钟内修复,要么就直接禁用这个用例并提 issue,绝不允许留着半死不活的测试躺在 CI 里天天报错。因为只要测试开始经常失败,大家就会习惯性忽视它,这套体系就算废了。保持测试的"绿"是长期收益最高的一件事。

最后说一句关于 e2e profile 的扩展思路:现在我的项目里不只是按环境分 profile,还会按测试目的分,比如"冒烟测试 profile""全量回归 profile""功能专项 profile"。不同 profile 对应不同的用例集合和运行参数,跑一轮核心冒烟只需要 3 分钟,而全量回归需要 20 分钟,用的时候各取所需。这套用的时间越长,越能体会到"不是把所有测试跑一遍才叫测试,而是让合适的测试在合适的时机跑"——这才是 e2e 自动化真正该有的样子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询