端到端测试实战:从场景设计到Playwright CI流水线搭建

发布时间:2026/10/26 15:22:30
端到端测试实战:从场景设计到Playwright CI流水线搭建

开篇先说句实话:很多项目在“测试”上花的精力不少,但线上还是出问题,而且往往出在用户真正走到的那些流程上。问题出在哪?八成是测了零件,没测整机。单元测试把每个函数都罩住了,接口测试把后端接口都点了,可用户不是函数调用器,也不是接口机器人,用户是从首页点进去,一步步把表单填完、把订单提交掉、把支付回调解掉的那拨人。端到端测试(e2e)要补的,正是这段从“开发觉得没问题”到“业务真正跑得通”的巨大鸿沟。

e2e 的完整含义是 end-to-end,按字面理解就是从头到尾。放到软件测试里,它意味着用真实或虚拟的用户操作,驱动整个系统走完一条完整业务链路,中间经历的页面渲染、前端状态、网络请求、后端验证、数据库读写、第三方服务回调,全都串起来验证一遍。这篇文章不是概念科普,我想结合一套我近期在搭的 e2e 回归流水线,把场景设计、测试数据准备、工具链选择、用例编写、可靠性踩坑这些环节挨个拆开讲清楚,你可以直接拿过去套用,尤其适合正在搭自动化测试平台、准备把 e2e 引入 CI 的团队。

1. e2e 到底在测什么:一句话说透端到端测试

1.1 你以为的“流程能跑通”和真正的端到端

“流程能跑通”这句话,开发说过,产品说过,测试也说过,但大家说的时候,心里想的往往不是一回事。开发说的跑通,是几个单元测试绿灯,接口用 Postman 调了一遍没报 500;产品说的跑通,是页面能打开、按钮有响应;测试说的跑通,是核心 Case 在测试环境里找到过一次成功路径。

e2e 不一样。它是把用户请到一个相对完整的环境里,让它从头到尾完成一件完整的事。举个例子:一个电商项目,“用户登录后下单”这条链路,如果拆分离散验证,设计出来的可能是登录接口 200、商品详情接口 200、创建订单接口 200,三个接口全绿,你就敢说没问题吗?不敢。因为你不知道登录态在跳转时有没有丢,购物车数量有没有被重新拉取覆盖,订单提交后跳转到收银台时,前端有没有把订单号正确带上,支付回调回来之后,页面展示的状态到底对不对。这些都不是接口测试能回答的,因为它们的问题往往出现在两个模块交界的地方,就像流水线上下工位的对接缝,单测只看工位本身,而 e2e 看的是整条传送带。

所以 e2e 的核心价值不是“多跑几个用例”,而是验证一个完整用户在真实交互路径上的体验对不对。它解决的是“集成正确性”问题,关注的是链路、状态流转和数据一致性。我搭建这套 e2e 的出发点就在这里:把核心业务场景像拼图一样完整拼出来,然后在每次发版前,问系统一句——你还能不能把一个新用户变成真实订单。

1.2 e2e 和单元测试、集成测试的定位差异

很多人对测试金字塔有误解,觉得 e2e 是金字塔尖,是最高级的东西。换个角度想可能更准确:e2e 更接近金字塔尖那块压舱石,它的数量不需要多,但每一条都必须是真实业务场景,错过一条就可能导致线上事故。

  • 单元测试:验证“一个函数/模块在给定输入下,输出是否符合预期”,跑得快,定位准,直接告诉你哪个函数出了问题,但它不关心模块之间怎么配合。
  • 集成测试:验证“两个或多个模块之间的交互是否正确”,解决的是接口契约问题,比如订单服务调用支付服务时,参数格式对不对,返回结构是否匹配。
  • e2e 测试:验证“从用户操作入口开始的完整端到端流程”,它不在乎是哪个函数写错了,它只在乎业务能不能闭环。

我见过不少团队跳过单测、直接梭哈 e2e,结果就是用例数量膨胀到上千条,跑一次全量要两三个小时,不稳定的时候满屏红,定位问题时牵扯页面脚本、接口报错、数据脏乱,根本没法快速收敛。这是最典型的 e2e 误用。真正的做法是:低频、核心、跨系统验证的场景放进 e2e,高频、快速、细粒度验证交给单测和接口测试,三层各有分工,才能让整套测试体系跑得动、跑得有意义。

我自己搭这套东西时给自己定的一个标准是——e2e 用例数永远控制在几十条量级,每一条代表一个完整的用户价值路径:新用户注册并完成首单、老用户复购、优惠券抵扣、退款售后退款、地址管理、多人协作项目创建等。每条链路背后的页面跳转和交互步骤可能很多,但是场景维度必须收敛,否则维护成本会反过来吞噬效率。

2. 拆解一个 e2e 项目的设计思路

2.1 先画场景地图,再写用例

我见过最劝退的 e2e 写法,就是把接口用例翻译成页面操作:页面上有个按钮就点一下,有个输入框就填一个值,最后跑了半天,也不知道在验证什么业务价值。这种用例维护起来既痛苦又没意义。正确做法是先画业务场景地图,再从地图里生成用例。

场景地图怎么画?拿我们项目举例,一个中后台管理系统,核心业务大概是这四个域:账号域、订单域、支付域、售后域。域之间是有依赖的:账号登录了才能下单,下单了才能支付,支付了才能售后。所以场景设计第一步是梳理跨域主链路。

我习惯的主链路通常是这样的:用户注册→创建企业→邀请成员→登录→创建订单→提交审核→支付→发货→确认收货→发起售后。这条链路走通一遍,差不多把所有核心域全部串起来了,这就是我优先级最高的一号 e2e 场景。

然后围绕主链路长出来的分支场景再单独成用例:注册时验证码错误重试、订单提交时库存不足拦截、支付时余额不足回退、售后时金额计算校验。分支场景不追求完整闭环,它只需要验证一个分支判断被正确触发且回到主链路的能力。

画完之后你会发现,很多测试点根本不需要 e2e 来保障,比如“验证码错误提示语”这种页面校验,组件测试就能覆盖,塞进 e2e 只会拖慢执行。所以场景地图还有一个作用——帮团队把 e2e 的范围切到最薄,只留真正的业务闭环和跨系统集成测试点。

2.2 测试数据准备与清理:e2e 里最容易翻车的环节

说 e2e 难,一半难在数据。我最早写 e2e 的时候,用例直接连测试环境的真实库,注册用的手机号写死在代码里,跑完一次下次再跑就提示“号已被注册”。后来改成每次生成随机号,又遇到跑到一半页面报“该企业名称已存在”。归根结底一句话:e2e 用例不是幂等的,跑第二次和第一次的结果可能完全不同,你必须在数据环节就控制好这一点。

我的做法现在比较固定,分三层:

第一层是用例独立数据。每个用例创建的数据都带唯一标识,比如e2e_user_${Date.now()},利用时间戳或随机数生成独立的账号、企业名、订单号,保证用例之间不撞数据。这一步能在源头上避免数据相互污染。

第二层是有 API 就优先走 API 前置初始化。比如注册和创建企业这种耗时步骤,没必要每次都从浏览器页面一点点填表跑一遍,直接用接口请求造好数据,让用例聚焦在核心运营操作上。这样既能节省时间,也能减少页面上不稳定因素的干扰。

第三层是测试后清理。如果你在用一套共享测试环境,跑完的脏数据不清理,早晚会污染别人的用例或手工验证。清理不一定要物理删除,可以把数据打上e2e标签,然后定期用脚本统一清理,也可以让用例走一遍“注销账号”或“删除项目”的业务逻辑,顺便把这个操作也给覆盖了。

顺便说一个很多人忽视的细节:e2e 用例里尽量避免用当天日期、当前周几这种动态量作为断言依据。你跑全量回归的时间点不可控,如果断言写死在“今天创建的订单应当显示今天日期”,一旦执行跑到跨夜时间点,前面创建数据的时间和后面断言的时间就可能不一致,造成莫名失败。

2.3 profile1:配置化让测试在不同环境里横着走

说完用例设计,再聊一个我在实际项目里用得很爽的机制——环境档案(profile)。e2e 测试最烦的一件事就是环境切换:本地联调用 dev 环境,提测用 staging 环境,上线前要预发环境再跑一轮。如果你把环境地址、账号、开关配置全部硬编码在测试代码里,每换一次环境就得改一遍代码,改完还容易带上误操作。

所以我给这套 e2e 框架设计了一个 profile 机制,本质就是一组运行参数的集合。每个 profile 包含环境标识、基础 URL、账号体系、浏览器类型、数据隔离级别、是否开启无头模式等配置。项目里第一个定义的档案就是 profile1,它代表一套最常规的组合:本地开发环境 + 桌面浏览器 + 独立测试账号 + 非无头模式(方便调试)。后续可以继续加 profile2、profile3 对应 staging 环境、CI 回归无头模式等,但 profile1 永远是默认值,也是其他档案的一个基准参照。

profile 文件长这样:

{ "profiles": { "profile1": { "name": "local-dev-desktop", "baseURL": "http://localhost:3000", "env": "dev", "browser": "chromium", "headless": false, "viewport": { "width": 1440, "height": 900 }, "users": { "admin": { "username": "e2e_admin", "password": "123456" } } }, "profile2": { "name": "staging-chrome-ci", "baseURL": "https://staging.example.com", "env": "staging", "browser": "chromium", "headless": true, "viewport": { "width": 1280, "height": 720 }, "users": { "admin": { "username": "e2e_staging_admin", "password": "******" } } } } }

跑测试时指定档案即可,比如命令是npm run test:e2e --profile=profile1,代码里通过配置读取当前运行档案的 baseURL 和账号信息,切换环境只需要换一个参数,不碰代码。

为什么要叫 profile1 而不是直接叫 dev?因为我希望团队成员形成一种认知——profile 不绑定单一环境,也不用记住具体的环境域名,你只需要知道“我想本地调试就跑 profile1,我想模拟 CI 就跑 profile2”。当 e2e 用例数量多起来之后,这种配置化设计能省掉大量心智负担。

3. 实操:从零到一跑通一个 e2e 用例

3.1 选择工具链:Playwright 为什么值得入手

e2e 工具选型我纠结过一段时间。Selenium 生态成熟但本质上是在浏览器外面套了一层自动化协议,处理现代前端的异步渲染比较吃力;Cypress 写起来舒服但多 Tab 和多域名场景支持得绕弯子;最终我选了 Playwright,核心原因是它对“现代 Web 应用异步渲染”这种场景处理得特别顺滑,而且自带自动等待机制,不用在代码里到处塞 timeout。通俗点说,Selenium 让你手工去“催页面”完成加载,Playwright 则自己会耐心地等页面把该渲染的都渲染完再继续操作。

另一个让我下决心的是 Playwright 自带完整的测试运行器,支持并行、重试、逐步骤视频录制、页面 trace 记录。这些不是锦上添花,是调试 e2e 的刚需。e2e 最痛苦的时间往往不是写用例,是写完以后出问题排查——一个用例跑了个寂寞,既不知道是页面报错还是断言失败,也不知道浏览器当时停留在哪个状态。Playwright 的 trace 功能能把每一步操作、网络请求、控制台日志全部录下来,出问题以后像回放录像一样复盘。

3.2 profile1 配置实战

先看实际配置文件怎么和用例代码结合起来。我常用的目录结构大概是这样的:

e2e/ config/ profiles.json tests/ register.spec.js order.spec.js utils/ request.js dataFactory.js fixtures/ payloads/

profile1 对应的配置文件上面的例子就是。然后在测试初始化阶段,把档案解析成一个全局对象,供用例直接调用。用 Playwright 框架的话,我会把全局配置放在playwright.config.js里:

const { defineConfig } = require('@playwright/test'); const fs = require('fs'); const profileName = process.env.E2E_PROFILE || 'profile1'; const profiles = JSON.parse(fs.readFileSync('./config/profiles.json')).profiles; const activeProfile = profiles[profileName]; module.exports = defineConfig({ testDir: './tests', timeout: 30000, fullyParallel: false, retries: 2, use: { baseURL: activeProfile.baseURL, headless: activeProfile.headless, viewport: activeProfile.viewport, trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: `e2e-${activeProfile.name}`, use: { browserName: activeProfile.browser }, }, ], });

这里有个细节值得说:profile 里的账号密码这些敏感信息,别裸写在 profiles.json 里并提交到代码库,我的习惯是配置模板提交仓库,真实值由 CI 变量或本地.env注入,框架内部再在读取时做一次合并替换。

3.3 一步步跑通注册到下单核心链路

下面这段是我从实际项目里简化出来的核心链路测试,场景是“新用户完成首单支付”。直接用 profile1 跑本地环境,用例覆盖了注册、登录、搜索商品、加入购物车、下单支付全流程:

const { test, expect } = require('@playwright/test'); const { generateUser } = require('../utils/dataFactory'); const { registerUser, login } = require('../utils/request'); test('新用户完成首单支付-全链路关键路径', async ({ page }) => { const user = generateUser(); await test.step('注册新用户', async () => { await page.goto('/register'); await page.getByLabel('手机号').fill(user.phone); await page.getByLabel('验证码').fill('123456'); await page.getByRole('button', { name: '注册并登录' }).click(); await expect(page).toHaveURL(/\/welcome/); }); await test.step('进入首页搜索商品', async () => { await page.goto('/home'); await page.getByPlaceholder('搜索商品').fill('无线鼠标'); await page.goto('/search?keyword=%E6%97%A0%E7%BA%BF%E9%BC%A0%E6%A0%87'); await expect(page.getByText('无线鼠标')).toBeVisible(); await page.getByText('加入购物车').first().click(); }); await test.step('购物车提交订单', async () => { await page.getByRole('button', { name: '购物车' }).click(); await expect(page.getByText('无线鼠标')).toBeVisible(); await page.getByRole('button', { name: '去结算' }).click(); await page.getByRole('button', { name: '提交订单' }).click(); }); await test.step('支付并校验订单状态', async () => { await page.getByRole('button', { name: '支付宝支付' }).click(); await expect(page.getByText('支付成功')).toBeVisible(); await expect(page.getByText('待发货')).toBeVisible(); }); });

为什么强调等待异步渲染?注册接口之后通常前端会做静默登录,然后跳转到欢迎页。如果立即断言出现在注册页上的某个文字,很可能会撞上接口还没返回。所以上面我用的是toHaveURL(/\/welcome/),Playwright 会轮询等待 URL 变化到符合正则为止,而不是机械地等待固定秒数。写 e2e 用例第一原则:能用自动等待表达意图,就不要用sleep死等。

3.4 用自动等待处理不确定时序

很多刚上手 e2e 的同学写代码的时候习惯这样写:

await page.click('登录按钮'); await page.waitForTimeout(2000); expect(页面元素).toBeVisible();

上面这种写法我极度不推荐。“等两秒”本质上是在赌:这 2 秒内接口一定返回、前端一定渲染完成吗?本地跑可能稳定,CI 一颤就挂了。正确的姿势是利用 Playwright 的自动等待能力,把“要等到什么结果”告诉工具,而不是“等多久”。

Playwright 内置了 web 优先原则——执行点击、填表这类操作前,会自动等待元素处于可操作状态;执行toBeVisible()这类断言时,会自动重试直到目标出现或超时。这意味着你的代码写的是意图,工具帮你处理底层时序。我还是喜欢用expect.poll处理“无固定时延的数据一致性断言”,比如从购物车结算后,接口返回的金额可能滞后于页面展示,我会这样写:

await expect.poll( () => page.locator('.order-amount').innerText(), { timeout: 5000 } ).toBe('89.00');

把期望从“等待几秒”改成“轮询直到满足条件”,整个 e2e 的可靠性会有肉眼可见的提升。但也要记得,自动等待不是万能的,它处理的是浏览器内部时序,处理不了跨系统异步补偿,比如支付回调是异步的,支付回调可能要几十秒甚至分钟级延迟,这种情况就要配合接口轮询,等到数据库里的状态真正更新后再做断言,而不是傻等页面行为。

4. 可靠性和提速:e2e 最考验耐心的两件事

4.1 定位器怎么写才能少踩雷

写 e2e 的人应该都经历过这样的场景:用例昨天还绿着,今天一跑挂了,原因是界面上文案被人从“确认”改成了“提交”,而定位器写的是page.locator('text=确认')。文本定位器是最脆弱的一类定位方式,一旦 UI 调整,用例就跟着碎一地。

我现在写定位器的优先级是这样的:

  • 优先用户可感知的角色定位:getByRole('button', { name: '提交订单' }),语义清晰,相当于模拟真实用户找到按钮的方式;
  • 其次是表单标签:getByLabel('手机号'),对应输入框前的 label,比 placeholder 更可靠;
  • 再其次才是 test id:>