ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Playwright E2E测试实战:从profile配置到CI持续集成的稳定之道

Playwright E2E测试实战:从profile配置到CI持续集成的稳定之道

1. 项目概述

1.1 什么是 E2E 测试,它解决了什么问题

E2E,全称 End-to-End,即端到端测试。这几年测试领域的热词一个接一个,但要说真正触及软件质量核心的,E2E 测试绝对算一个。说得直白一点,E2E 测试就是模拟真实用户的操作路径,从打开页面、点击按钮、填写表单,到验证数据是否正确落库、接口是否返回预期结果,完整走通一条业务链路。它和单元测试、接口测试最大的区别就在于"完整"二字,不是测某个函数、某个接口,而是测整条业务流是否真的能跑通。

举个生活化的例子,你去银行办业务,柜员单独检查身份证、单独核对签字、单独验证摄像头,每个环节都正常,但整个流程走下来发现证件信息和系统里的对不上,业务照样办不成。单元测试和接口测试就是分别检查每个环节,而 E2E 测试就是从进银行门到业务办完的全程"演练"。这就是 E2E 测试的核心价值,它不关心某个内部函数是否返回正确,也不关心某个接口的响应码是否为 200,它关心的是用户在那个页面上的每一次点击,究竟能不能得到他想要的结果。

这次项目实践的背景是我们团队接手了一个从零搭建的电商管理系统,后端接口全部通过单元测试,前端的组件测试也覆盖了主干逻辑,但联调上线后频繁出现"接口能通但页面流程断掉"的问题。根源在于前后端各自为政,接口测试和前端测试互相独立,没人站在用户视角把整条链路串起来验证。引入 E2E 测试之后,用自动化脚本代替人工回归,原来需要三个小时的手工冒烟测试压缩到十几分钟,而且每次发版前都能快速发现跨端问题。对于中大型项目、微服务架构、前后端分离架构,E2E 测试几乎是一个绕不开的基础设施。

1.2 这个项目的目标和方案选型

我们的目标很明确,搭一套稳定、可维护、能嵌入 CI 流水线的 E2E 测试体系,覆盖核心业务链路,并且让普通开发人员也能快速编写和运行测试用例,而不是只有测试专家能维护的花架子。

方案选型上我们对比了当前主流的几套 E2E 测试工具,包括 Cypress、Selenium WebDriver、Puppeteer,最终采用了 Playwright + JavaScript(或 TypeScript)的组合,配合多个 profile 配置文件来区分不同环境、不同场景的测试配置。为什么选 Playwright,我在后面"工具选型"章节会详细展开,这里先给个结论:测试稳定性、跨浏览器支持、网络拦截能力、执行速度几个维度上,Playwright 的综合表现最均衡,尤其是它对 E2E 测试中大量异步场景的原生支持,能把很多以前只能靠 sleep 硬等的情况变成自动等待,省掉大量排查 flaky 测试的时间。

profile 这个概念在 E2E 测试里值得单独拎出来讲。我们日常会遇到至少两套环境:本地开发环境(localhost)、测试环境(test server)、预发布环境(staging server),有时候还有生产环境的只读验证。如果每个环境都在代码里硬编码,那维护成本简直灾难。用 profile 配置文件的方式,把每个环境的基础 URL、账号密码(通过环境变量注入)、超时时间、视频录制开关、截图行为、并发数等参数全部抽离,通过命令行参数或环境变量激活对应的 profile,测试代码本身完全不感知环境差异。这就是本项目的核心设计思路之一。

2. 工具选型与配置设计

2.1 为什么选择 Playwright

先说说 Selenium。Selenium WebDriver 是老牌工具,生态成熟、资料多,但它的短板这几年越来越明显:一是驱动管理麻烦,每个浏览器版本要对应下载 driver;二是等待机制不够智能,大量依赖显式等待和 sleep,导致测试脚本动不动就跑挂;三是 API 设计偏底层,写起来啰嗦,维护成本高。

Cypress 是另一大热门,底层架构决定了它只能支持 Chromium 系浏览器(Edge、Chrome、Electron),对 Firefox 和 WebKit 的支持是后来才逐步完善的,且 Cypress 跑在浏览器内,对于多标签页、跨域场景的操作非常别扭。它的网络控制能力也受限,想拦截和修改请求响应没有 Playwright 那么直接。

Playwright 的优势在于微软开发并持续维护,由浏览器开发者直接实现自动化协议,三大浏览器引擎(Chromium、Firefox、WebKit)全面支持。最关键的一点是它的自动等待机制,比如点击一个按钮之前,Playwright 会自动等待元素处于可交互状态,不需要你手动写一大堆"等待元素可见"的代码。这一点直接解决了我前面提到的 flaky test 问题。实测下来,同样规模的测试套件,用 Playwright 跑比之前用 Selenium 跑稳定性提升了一大截。

2.2 profile 配置的心智模型

在 E2E 测试项目里,配置和代码分离是一条基本准则。profile 就是我们定义的一个配置集合,每个 profile 描述了一个特定的测试运行环境。用一个e2e.config.ts(或playwright.config.ts)作为主配置入口,然后用环境变量或者命令行参数激活不同的 profile。

大致的结构是这样的:

// e2e.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ // 主配置,全局默认值 testDir: './tests', timeout: 30000, fullyParallel: true, reporter: [['html', { outputFolder: 'playwright-report' }]], use: { baseURL: process.env['E2E_BASE_URL'] || 'http://localhost:3000', screenshot: 'only-on-failure', video: 'off', trace: 'on-first-retry' }, projects: [ { name: 'profile1-chromium', use: { browserName: 'chromium', viewport: { width: 1280, height: 720 }, baseURL: 'https://test.example.com' } }, { name: 'profile2-firefox', use: { browserName: 'firefox', viewport: { width: 1366, height: 768 }, baseURL: 'https://staging.example.com' } } ] });

profile1 这里我们让它对应 Chromium + 测试环境,profile2 对应 Firefox + 预发布环境,每个 profile 可以设置完全独立的浏览器参数和 baseURL。更重要的是,profile 还能包含业务参数,比如使用不同的测试数据集、启用 or 关闭某些功能开关(feature flag)、模拟不同的用户权限。这些在配置层面解决,比在测试代码里写 if else 干净太多。

2.3 环境变量与账号管理的坑

很多初学者容易把测试账号和密码直接写死在代码里,这是很危险的做法。代码仓库一旦泄露,测试环境账号直接被暴露。我们的做法是使用.env文件管理环境变量,并通过 CI 系统的密钥库注入敏感信息。

# .env.local,本地环境 E2E_BASE_URL=http://localhost:3000 E2E_ADMIN_USER=admin E2E_ADMIN_PASS=local-test-password # .env.test,测试环境 E2E_BASE_URL=https://test.example.com E2E_ADMIN_USER=test-admin E2E_ADMIN_PASS=xxx-xxx-xxx

在测试代码里通过process.env读取这些变量,注意不要将.env.test这样的真实文件提交到 git。CI 上则通过平台的环境变量面板配置同名变量。这里还要注意一点,不同环境下的测试账号权限可能不同,比如测试环境的 admin 可以删除数据,预发布环境可能禁用了删除操作,所以 profile 里的业务用例也要做区分。我的做法是给每个 profile 绑定一个独立的 spec 目录或者添加 tag 标签,跑 CI 时按 tag 过滤。

3. 核心实现与实操过程

3.1 从零搭建一个可运行的 E2E 项目

下面我把整个搭建过程走一遍,你跟着做就能跑起来。这个项目我用的 Node.js 版本是 18+,包管理器用的 pnpm,你也可以用 npm 或 yarn。

第一步,初始化项目并安装 Playwright 测试库:

mkdir e2e-project cd e2e-project npm init -y npm install @playwright/test npx playwright install chromium

这个npx playwright install会下载对应浏览器的二进制文件,Chromium 大概一百多 MB,网速不好时要有耐心。建议装完 chromium 后再装 firefox 和 webkit,本地开发时可以覆盖到三大引擎,CI 里只需要装目标浏览器。

第二步,创建上面说的 Playwright 配置文件,我把刚才的e2e.config.ts保存为playwright.config.ts。注意项目根目录下这个文件名是固定约定,Playwright 会自动加载它。

第三步,创建测试目录和第一个测试文件。我用一个登录加商品搜索的链路来演示核心写法:

// tests/basic-flow.spec.js import { test, expect } from '@playwright/test'; test.describe('核心业务链路', () => { test('用户登录后可搜索商品并加入购物车', async ({ page }) => { // 跳转登录页 await page.goto('/login'); // 填写表单并提交 await page.getByPlaceholder('请输入用户名').fill(process.env.E2E_ADMIN_USER); await page.getByPlaceholder('请输入密码').fill(process.env.E2E_ADMIN_PASS); await page.getByRole('button', { name: '登 录' }).click(); // 断言跳转到首页 await expect(page).toHaveURL(/\/home/); // 搜索商品 await page.getByPlaceholder('搜索商品名称').fill('无线鼠标'); await page.getByRole('button', { name: '搜 索' }).click(); // 验证搜索结果中至少包含一个目标商品 await expect(page.getByText('无线鼠标 Pro 版')).toBeVisible(); // 加入购物车 await page.getByRole('button', { name: '加入购物车' }).first().click(); await expect(page.getByText('已成功加入购物车')).toBeVisible(); }); });

这段代码看起来简单,但里面有不少关键点。比如getByRole、getByPlaceholder这些是 Playwright 推荐用的定位方式,比 CSS selector 更贴近用户视角,也更稳定,因为页面结构调整时角色和语义一般不会轻易变动。注意我没有用page.waitForTimeout()这种硬等待,全部依赖自动等待和断言重试机制。

3.2 profile1 的完整配置与使用场景

刚才只展示了主配置文件的骨架,现在单独看看 profile1 的完整配置应该包含什么。profile1 我们定位为"基于 Chromium 的测试环境冒烟回归",对应的配置项拆解如下:

配置项值说明
browserNamechromium主测浏览器
baseURLhttps://test.example.com指向测试服务器
viewport1280x720标准桌面视口
screenshotonly-on-failure失败时自动截图
videoretain-on-failure失败时保存视频
traceon全程录制 trace,方便排错
timeout30000ms单步操作超时
retries2失败自动重试两次
workers4并发 worker 数
httpCredentials从环境变量读取部分环境需要基础认证

实际配置片段:

{ name: 'profile1-chromium', use: { browserName: 'chromium', baseURL: 'https://test.example.com', viewport: { width: 1280, height: 720 }, screenshot: 'only-on-failure', video: 'retain-on-failure', trace: 'on', httpCredentials: { username: process.env.E2E_AUTH_USER || '', password: process.env.E2E_AUTH_PASS || '' } }, retries: 2, timeout: 30000 }

这里有个细节:trace: 'on'会产生大量文件,会拖慢执行速度。在线下调试时可以开,CI 里我通常改成on-first-retry,只在第一次重试时记录 trace,这样既不影响性能,又能拿到失败现场。视频录制同理,retain-on-failure会比on节省大量磁盘空间。

profile1 主要用于每日冒烟测试和 PR 校验。PR 场景下我们只跑@smoketag 的用例,保证合并代码之前快速反馈核心链路是否被破坏。每日凌晨则跑全量用例,覆盖更深入的业务场景。

3.3 测试用例设计的最佳实践

写 E2E 测试用例最忌讳的就是把每个用例写得又长又密,一个用例里塞了几十个步骤,失败之后根本不知道是哪个环节出了问题。我总结出来的经验是两条线:横向按业务模块拆文件,纵向按用户故事拆用例。

横向拆文件,比如登录认证、商品管理、订单流程、支付流程,各自建单独的 spec 文件,互不干扰,也方便多人同时开发。

纵向拆用例,每个用例尽量保持"一条完整但短小的业务路径"。比如登录这个主题,可以拆成多个用例:正常登录、密码错误提示、用户名不存在提示、登录后跳转、退出登录。每个用例 3 到 6 步操作就好,不要超过 10 步。用例长了,失败定位的成本成倍增加。

值得特别关注的是测试数据的问题。E2E 测试跑起来会真实地操作数据库,产生脏数据是常有的事。我的做法是在测试前置中通过 API 创建测试专用的数据,测试结束通过 API 清理数据,保证用例之间的数据隔离。尽量不依赖界面上手输造数据,那样又慢又容易点错。

test.beforeAll(async ({ request }) => { // 通过 API 创建测试商品 const resp = await request.post('/api/products', { data: { name: '端到端测试专用商品', price: 199 } }); expect(resp.ok()).toBeTruthy(); }); test.afterAll(async ({ request }) => { // 清理测试数据 await request.delete('/api/products/端到端测试专用商品'); });

这种 API 打底结合 UI 操作的方式,在业界也被称为"UI + API 混合测试",它能规避大量由造数过程引入的不稳定因素,而且执行速度更快。毕竟打开一个页面去填写几十个表单字段,远远慢于一次接口 POST。

4. 踩坑记录与排查技巧

4.1 经典问题:元素定位稳定性和 force click 的滥用

前面提到 getByRole 好在哪,这里说说我们踩过的坑。最初团队成员习惯了一堆page.locator('css')从 Selenium 带来的老思路,结果前端同学把 CSS 类名一重构,几十条用例集体变红。后来我去查失败的用例,发现大量定位器写着.item-list > .item:first-child这种从 DOM 结构反推的写法。换成了语义化定位之后,类似的问题大幅减少。

另一个坑是force: true这个选项。Playwright 的 click 默认会校验元素是否可见、是否可点击、是否被遮挡,但有时候你太想快点让脚本跑通,遇到"明明在页面上却点不到"的情况就直接click({ force: true })。这可能让测试检查不到真实的用户交互可行性,生产环境用户根本点不了,而你的测试却"成功"了。我们的经验是:force click 只用于两个场景,一是隐藏 input 元素(比如原生文件上传框),二是确认某个元素在页面上存在,但不关心它的交互性。除此之外,尽量找到元素不可点击的根因并修复产品,而不是绕过它。

4.2 网络请求相关的 flaky 问题

E2E 测试里最头疼的就是网络波动导致的随机失败。我们的接口偶尔在弱网环境下响应慢,Playwright 虽然会自动等待,但如果后端超过 30 秒没返回,超时照样触发。这背后要分清楚是"页面加载慢"还是"接口真的挂了"。

排查真实原因时,trace 文件和 API 日志缺一不可。打开 trace 记录后,你会看到每一步操作对应的网络请求时间线,以及控制台报错。我们遇到过一个经典问题:测试脚本里点击按钮后立即断言一个提示文案出现,但这个提示文案是由后端异步返回后渲染的,偶尔接口在 3 秒后才返回,断言就赶在渲染之前执行,导致测试不稳定。

解决方案有两个:一是把断言改为await expect(locator).toBeVisible()这种自带重试的断言,Playwright 默认在超时时间内反复检查;二是针对特定接口用page.waitForResponse()精确等待某个请求完成。我推荐把这两者结合,核心场景用waitForResponse确保数据返回,之后再用自动等待断言验证 UI 状态。

// 等待搜索接口返回后,再验证页面结果 const respPromise = page.waitForResponse( response => response.url().includes('/api/search') && response.status() === 200 ); await page.getByRole('button', { name: '搜 索' }).click(); await respPromise; await expect(page.getByText('无线鼠标 Pro 版')).toBeVisible();

这一段代码就是很典型的高稳定性写法,保证断言时数据确实已返回,而不是碰运气。

4.3 跨环境跑 profile1 时的常见踩坑汇总

我把这次实践里最容易踩的坑整理成一个速查表,你在自己搭建时参考价值很高:

现象可能原因解决方案
同一个用例本地跑通过,CI 上失败环境变量未注入或 baseURL 错误CI 中显式配置 profile 的环境变量,日志中打印 baseURL 前置校验
登录用例第一次跑挂,重试成功测试账号被别的用例登出每个 worker 分配独立测试账号,或用 API 注册临时账号
截图一片空白断言失败后页面已跳转开启 trace,在 trace 里回顾完整时间线,不要只依赖失败截图
元素定位到了但点击没反应页面有弹层遮挡;CSS 动画未结束用 locator 对应的click前加scrollIntoViewIfNeeded,检查是否有 iframe,考虑动画结束后再交互
登录后 cookie 丢失浏览器上下文隔离导致使用 Playwright 内置的 storageState 持久化登录态,或在一个 context 内连续执行相关用例
浏览器启动慢导致超时并行 worker 数开太多调低 workers,或在 CI 配置中为浏览器启动预留充足超时
页面有中文,getByText 找不到文本节点被拆分使用正则表达式getByText(/无线鼠标/)避免空格和换行干扰
本地数据库数据污染测试数据未清理所有测试数据创建时打 tag 标记,通过全局 teardown 统一清理

这些坑每个单拎出来都能写一篇文章,但关键是你要理解背后的共性:E2E 测试的不稳定性主要来自环境、数据、时序三个维度,解决思路就是让每个维度都可控。环境维度用 profile 隔离,数据维度用 API 清理,时序维度用自动等待替代 sleep。把这三个维度管好了,E2E 测试的稳定性会大幅提升。

4.4 让 E2E 测试嵌入 CI 的最佳姿势

一个不能自动跑的 E2E 测试体系就是个摆设。我们把 E2E 测试接入了 GitLab CI,核心配置是一个 pipeline stage。这里要注意一点,E2E 测试必须在应用部署完成之后执行,否则页面都起不来,全是 404。

# .gitlab-ci.yml 片段 e2e:test: stage: test image: mcr.microsoft.com/playwright:v1.40.0-focal script: - npm ci - npx playwright install --with-deps - npx playwright test --project=profile1-chromium --grep @smoke artifacts: when: always paths: - playwright-report/ - test-results/ expire_in: 7 days

这段配置有几点值得说明。第一,使用了 Playwright 官方 Docker 镜像,它已经预装了浏览器和所有系统依赖,不用再手动安装各种底层库。第二,--grep @smoke实现按 tag 过滤用例。第三,artifacts 配置会保留所有 HTML 报告和测试结果,即使失败也能下载到现场资料。这个 Docker 镜像体积比较大,拖下来大概几百 MB,但胜在一劳永逸,不需要处理一堆 Linux 系统库的兼容问题。

5. 进阶技巧与效率提升

5.1 自动化测试的代码规范与项目结构

当测试用例规模增长到几百条时,没有规范的代码结构就是一场灾难。我们最终沉淀下来的目录结构长这样:

e2e-project/ ├── playwright.config.ts ├── .env.local ├── .env.test ├── fixtures/ │ ├── auth.ts # 登录态 fixture │ └── product.ts # 商品数据 fixture ├── helpers/ │ ├── api-client.ts # API 封装 │ └──>
RELATED READING

延伸阅读

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