干这行时间长了你会发现一个现象:很多团队的测试体系里,单测和接口测试做得有模有样,一提到 e2e 就头疼。跑得慢、不稳定、维护成本高,这些标签几乎刻在端到端测试的脑门上。但真等到线上出了事故,复盘的时候才发现,恰恰是那条没人愿意维护的 e2e 用例没能提前拦住问题。这篇文章想聊聊我在 e2e 测试工程化落地过程中的完整思路,从框架选型到环境 profile 的设计,再到具体怎么把用例写得稳、跑得快,最后把一路踩过的坑都抖出来。内容偏实战,适合正在搞 e2e 或者准备搞 e2e 的测试开发、前端工程师,特别是那种被不稳定用例折磨到怀疑人生的团队。
先说清楚一件事:e2e 测试到底在测什么?我见过不少把 e2e 当成"点一遍页面"的团队,用例东写一个西写一个,最后变成 CI 里的摆设。真正有价值的端到端测试,模拟的是真实用户的完整操作路径,走的是从浏览器入口到前端应用、再经过接口层到后端服务的全部链路。它验证的不是某个函数算得对不对,而是整条业务链路能不能在真实环境下走通。单元测试和接口测试解决的是"零件合格"的问题,e2e 解决的是"整机能开机"的问题。这两个问题不一样,需要的工具和思路自然也不一样。
1. e2e 测试的整体设计思路:先想清楚要验证什么
1.1 端到端测试的定位:守住最后一道防线
很多团队把测试金字塔挂在嘴边,但实际落地的时候往往是反过来的——单测覆盖率看着挺高,接口测试也整了一堆,结果核心业务场景一上线就翻车。为什么?因为单测和接口测试都覆盖不到"页面交互"这个层级。用户不会自己去调接口,他们只会点击按钮、填表单、看页面反馈。你接口返回的数据再正确,前端解析渲染出错了,用户看到的就是白屏或者报错。
e2e 测试在这里的位置就是最后一道防线。它站在用户的角度,把一条条完整的业务链路验证一遍。我习惯把 e2e 用例分成两个层次:核心链路和边缘场景。核心链路就是那种一旦挂了团队就得立刻放下手里所有活去修的用例,比如登录注册、主流程下单、支付回调这类。边缘场景是相对低频但依然影响用户体验的路径,比如异常输入、权限校验、空数据展示等等。分层之后,用例的优先级和维护精力就有了明确归属。
端到端测试的代价不低。每一轮跑下来要启动浏览器、加载完整应用、打真实接口,时间成本是单测的几十倍甚至上百倍。所以我的原则很简单:只测关键链路,把 e2e 当成准生产环境的巡检,而不是功能测试的替代品。那种"每个表单都要录一条 e2e"的做法,最后一定会被维护成本拖垮。
1.2 技术选型的核心矛盾:快而稳,还是全而重
聊到 e2e 框架,老生常谈的几个名字绕不开:Selenium、Cypress、Playwright。这个话题被讨论得太多了,但每次做选型还是会发现不少团队踩同样的坑。网上资料满天飞,真到自己评估的时候,往往会被一些表象参数带偏。
我自己的评估逻辑是从三个维度出发:稳定性、调试体验、多浏览器支持。稳定性的意思是,在用例不写错的前提下,环境因素导致跑挂的概率有多低。调试体验说的是用例挂了以后,你能不能快速定位到是哪个环节出了问题。多浏览器支持就不用说了,现在不少 ToB 项目还是绕不开 IE 兼容,虽然这种情况越来越少,但 Chromium 内核一统天下之前,多浏览器测试还是实打实的需求。
基于这三个维度,我现在的推荐是 Playwright 优先,Cypress 作为备选,Selenium 除非是遗留项目否则不建议再新引入。这不是什么小众结论,而是两者在架构设计上的核心差异决定的。Cypress 的架构是插件式运行在浏览器内,这让它在调试体验上非常出色,错误信息直观、交互式回放方便;但代价是它无法真正意义上支持多标签页操作,service worker 和 iframe 场景也会受限。Playwright 走的是单独的 driver 进程加 WebSocket 通信协议,这意味着浏览器行为是真实独立的,多标签、多浏览器、网络拦截都能做,而且它在自动等待和选择器引擎上做了大量优化,常见的不稳定因素被主动规避掉了。
1.3 理解 profile 概念:e2e 测试的环境分身
接着说说最近圈子里聊得比较多的 "e2e profile" 这个说法。第一次听到这个词的朋友可能有点懵,其实它对应的就是端到端测试的环境配置方案。你可以把它理解成测试运行时要套用的"一套完整身份信息"——往哪跑(base URL)、用什么账号、连哪套数据库、开哪些功能开关、走哪套第三方 mock,这些参数打包在一起就叫一个 profile。
我给项目配置 profile 的初衷很简单:同一个测试工程,不能只在一个环境里跑。开发要在本地环境验证、测试要在 staging 环境验收、CI 要在隔离环境跑回归,这些环境的应用版本、数据状态、依赖服务都可能不一样。如果所有环境套同一套配置,要么维护 N 份几乎一样的代码,要么就反复改配置文件手动切换,这两种我都干过,都很痛苦。
所以后来我改成了一件事:把环境差异全部收敛到 profile 里,测试代码里只认抽象的环境变量,不写死任何环境专属的地址或账号。比如我这个项目里的第一个 profile,就默认作为基准环境配置,承载的是 staging 环境的信息。之后加一个 dev profile、一个 prod smoke profile,代码一行都不动,只需要在配置文件里加一组环境变量。这个思路是纯工程层面的实践,和具体框架无关,Cypress、Playwright、Selenium 都能这么搞,只不过 Playwright 的配置文件本身对多项目、多环境支持得更好,用起来更顺手。
2. 核心细节解析:配置文件的梳理与测试用例的写法
2.1 环境 profile 的落盘方式:配置文件优先,写死是大忌
先看配置文件。我见过最原始的方案,就是测试代码里写死一个常量:
const BASE_URL = 'https://staging.example.com';这种事一旦环境一多,立刻会陷入困境。你为了在本地跑通,得手动改代码;改完忘了改回来,代码一提交,CI 砸了。所以我在项目里统一做了一个 profile 层,用环境变量驱动。Playwright 的官方字段是use里的baseURL,但这个 baseURL 本身不能写死。我看过不少项目在playwright.config.ts里直接硬编码,说实话这种写法后期一定会出问题。
推荐的做法是这样的:
// helpers/profile.ts export interface TestProfile { baseURL: string; env: string; account: { username: string; password: string }; featureFlags: string[]; } export function getProfile(): TestProfile { const env = process.env.E2E_PROFILE ?? 'profile1'; const profiles: Record<string, TestProfile> = { profile1: { // 基准 profile:对应 staging 环境 baseURL: 'https://staging.example.com', env: 'staging', account: { username: 'e2e.user@example.com', password: process.env.STAGING_PW || '' }, featureFlags: ['new-checkout'], }, dev: { baseURL: 'http://localhost:5173', env: 'dev', account: { username: 'dev.user@example.com', password: process.env.DEV_PW || '' }, featureFlags: [], }, }; const profile = profiles[env]; if (!profile) throw new Error(`Unknown e2e profile: ${env}`); return profile; }然后在playwright.config.ts里读配置:
import { defineConfig, devices } from '@playwright/test'; import { getProfile } from './helpers/profile'; const profile = getProfile(); export default defineConfig({ testDir: './tests', timeout: 30_000, retries: 2, workers: process.env.CI ? 4 : 1, reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'list', use: { baseURL: profile.baseURL, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, ], });这样 "e2e profile1" 的含义就非常清晰了:它就是一个代号,通过环境变量E2E_PROFILE=profile1指定测试运行时使用的基准环境配置。跑本地开发环境就是E2E_PROFILE=dev,跑 CI 是E2E_PROFILE=profile1,一套代码,多环境适配。
2.2 测试用例的写法:围绕用户行为而不是页面细节
配置文件理清楚之后,回到用例本身。我看过太多用例写出来像操作手册:"点击右上角的登录,输入账号,点击确认,断言出现欢迎语"。这种用例跑通一次之后基本就没人维护了,因为页面样式一改,选择器一换,用例就得跟着改一遍。
我的经验是给用例套上行为层的封装。所谓行为层,就是把你对某个页面的操作和断言封装成业务语义明确的函数,用例本身只描述"用户要做什么",不关心"用哪个按钮哪个输入框去完成这件事"。
import { test, expect, Page } from '@playwright/test'; async function login(page: Page, username: string, password: string) { await page.goto('/login'); await page.getByRole('textbox', { name: '账号' }).fill(username); await page.getByRole('textbox', { name: '密码' }).fill(password); await page.getByRole('button', { name: '登 录' }).click(); // 等待登录态恢复,避免直接跳转后立即断言导致的竞态 await expect(page.getByRole('heading', { name: '工作台' })).toBeVisible({ timeout: 10_000 }); } test('用户可以通过邮箱账号正常登录', async ({ page }) => { const profile = getProfile(); await login(page, profile.account.username, profile.account.password); await expect(page.getByText('欢迎回来')).toBeVisible(); });注意到上面用了getByRole和getByText,而不是传统的#id或.class选择器。这是 Playwright 推荐的可访问性选择器,核心思路是:用户不关心样式,用户关心的是语义。按钮改成别的样式,role 不会变;输入框的文案调整了,role 依然可以匹配到。这比靠 CSS 类名稳得多。当然了,如果组件库渲染出来的结构没有正确的语义属性,那还是要靠>test('核心购买链路:选择商品到支付跳转', async ({ page }) => { await test.step('进入商品列表页', async () => { await page.goto('/products'); await expect(page.getByRole('heading', { name: '商品列表' })).toBeVisible(); }); await test.step('加入购物车', async () => { await page.getByRole('button', { name: '加入购物车' }).first().click(); const badge = page.locator('[data-testid="cart-badge"]'); await expect(badge).toHaveText('1'); }); await test.step('前往结算页面', async () => { await page.getByRole('button', { name: '去结算' }).click(); await expect(page).toHaveURL(/\/checkout/); await expect(page.getByRole('heading', { name: '订单确认' })).toBeVisible(); }); });
用例写到这里已经具备基本的样子了,但真正决定 e2e 能走多远的是稳定性。这一块下面单独展开,因为它值得一整个章节的篇幅。
3. 实操过程与核心环节实现:从零搭一套可跑的 e2e 工程
3.1 初始化工程与安装依赖
我习惯从零开始带一个项目,所以这里把完整的初始化过程过一遍。假设你有一个前端项目,用的是 Vite 或 Next.js,想要补一套 e2e 测试。第一步是在项目根目录初始化 Playwright:
npm init playwright@latest这个命令会引导你创建一个tests目录、生成playwright.config.ts,还会问你要不要安装浏览器内核。注意这一步不要全选跳过,浏览器内核是必须的。如果你希望 CI 环境也用同一套浏览器,可以加装系统依赖:
npx playwright install --with-deps安装完之后,项目根目录会有几个关键文件。我看过一些新手直接开写用例,连配置文件都不加改动的。这里建议还是先把默认配置过一遍,因为脚手架生成的配置往往太保守,比如 timeout 太短、没有重试策略。下面是我常用的基准配置段:
export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 4 : undefined, reporter: [['html', { open: 'never' }], ['list']], use: { baseURL: getProfile().baseURL, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, });这里重点说说retries和trace的组合。retries: 2的意思是失败的用例会重跑两遍,如果第一次是偶发性的网络抖动导致的失败,重跑可能就过了;关键信息在于,第一次失败时 Playwright 会保留 trace,你能看到失败时的完整现场。这在排查"时好时坏"的 flaky 用例时几乎是救命稻草。
3.2 第一个真实用例:登录态与环境隔离
写第一个用例之前,先解决一个核心问题:登录态怎么办。如果你从零开始的一个新项目对登录态没有特殊依赖,那直接走 UI 登录就行。但大多数真实项目是有用户体系的,每次用例都走一遍 UI 登录,时间成本高不说,而且登录流程也可能因为验证码、风控策略导致不稳定。
Playwright 的官方方案是storageState,即把登录之后产生的 localStorage 和 cookie 快照保存下来,后续用例直接加载这份快照,跳过了 UI 登录。这是 e2e 工程里的一个经典实践,我强烈建议早点用上。
实现方式很简单,写一个globalSetup,在跑套件之前统一登录并保存状态:
// global-setup.ts import { expect, request } from '@playwright/test'; import { getProfile } from './helpers/profile'; export default async function globalSetup() { const profile = getProfile(); const ctx = await request.newContext(); const resp = await ctx.post(`${profile.baseURL}/api/login`, { data: { username: profile.account.username, password: profile.account.password, }, }); expect(resp.ok()).toBeTruthy(); await ctx.storageState({ path: 'state.json' }); await ctx.dispose(); }然后在playwright.config.ts里声明:
globalSetup: './global-setup.ts', use: { storageState: 'state.json', },这样绝大多数用例执行时不需要再走一次 UI 登录,直接进入业务操作。注意,如果你要测"退出登录"、"踢人下线"这类涉及登录态的用例,就换个 profile 或者显式清除 storageState,不要在通用用例上叠加这种特殊情况。
3.3 数据规划:e2e 测试最容易忽略的一环
环境配置、登录态都说完了,真正让 e2e 用例稳定跑起来的另一半是测试数据。这是我在带团队时反复强调的一点:登录态搞定了,不等于用例能跑通;如果测试环境的数据是一团乱麻,再稳定的框架也白搭。
我这里的经验是分三档来处理:
- 接口种子数据:通过后端接口在用例开始前创建业务数据。比如下单场景,先调用接口创建一张带指定金额的订单,再在前端走支付流程。这样用例本身只关注 UI 操作,数据准备被收敛到幕后。
- 数据库直连清理:针对那种同一个用例要反复跑的场景,比如"用户已有一笔未支付订单",如果每次运行数据都累积,跑第二次用例可能因为订单数量不对而失败。在测试环境允许的情况下,建议在 setup 阶段直接对相关表做清理。
- 可重复设计的业务规则:这是最简单也最容易被忽视的。设计测试用例时有意识地避免"唯一性断言"。比如断言"购物车数量 +1",而不是断言"购物车数量等于某个固定值",这样即使数据污染一点,也不至于立刻挂掉。
说白了,e2e 用例本质上是一台在真实环境上反复表演的"用户机器人",它要求环境给到的状态是可预期的。数据不可控,预期就无从谈起。很多团队抱怨 e2e 太脆,其实根因就是环境数据本身熵值太高。
3.4 多浏览器与并行执行
配置里如果启用了三个浏览器项目,那用例会在每一个浏览器里各跑一遍。好处显而易见,跨浏览器兼容性问题可以提前暴露;但代价是执行时间翻倍。我的建议是:核心链路全跑三浏览器,边缘场景只跑 Chromium。可以通过 Playwright 的 test 标签机制实现:
test('核心下单链路(全浏览器覆盖)', async ({ page }) => { // 核心链路内容 }); test('边缘场景-空购物车提示(仅 Chromium)', async ({ page }) => { test.skip(process.env.PLAYWRIGHT_BROWSER === 'firefox' || process.env.PLAYWRIGHT_BROWSER === 'webkit'); // 边缘场景内容 });还有一种做法是把浏览器项目拆成不同目录或不同 tag,用 grep 参数来控制。我团队里用得多的是这个组合:CI 里默认跑全部用例,但分两块,一个 job 跑 Chromium,一个 job 跑 firefox + webkit 的 core 子集。配合 Playwright 的 shard 分片能力,整体执行时间能压到可以接受的范围。
如果项目大一点,我还会用 Playwright Report 和 GitHub Actions 或 GitLab CI 的缓存策略,把浏览器内核的下载和依赖的安装时间都缓存下来。这里插一句:CI 的缓存命中对整体效率的提升,往往被很多人低估,哪怕只是缓存掉~/.cache/ms-playwright这个目录,一次流水线也能省下两三分钟。
4. 常见问题与排查技巧实录:跑多了你也会遇到这些坑
4.1 元素定位失败的典型场景和破解方式
第一大类的坑就是元素找不到或定位到多个元素。Playwright 的自动等待机制已经比 Selenium 聪明很多,但只要页面是异步渲染的,依然会遇到"你以为元素出现了,实际上还没渲染完"的尴尬。这个问题的根源往往不是框架不行,而是开发者对选择器的精度不够。
看一个典型的报错:
Error: strict mode violation: locator('button:has-text("提交")') resolved to 2 elements翻译一下:你用文本模糊匹配按钮,结果页面上有两个按钮都含"提交"两个字。这种场景太常见了,一个弹窗里的确认按钮叫"提交",底部的表单按钮也叫"提交"。解决方案很简单:缩小选择器作用域。用page.getByRole('dialog')先把弹窗定位到,再在弹窗里找按钮,或者直接用>const respPromise = page.waitForResponse(resp => resp.url().includes('/api/products') && resp.status() === 200 ); await page.goto('/products'); await respPromise;
等到了接口返回,后续交互基本就稳了。这比waitForTimeout那种固定等待可靠得多,固定等待的问题在于:等短了不生效,等长了浪费时间,而且偶发的慢网络还是会挂。永远不要轻易用await page.waitForTimeout(3000)这种写法,它只是把问题推迟到下一次。
4.2 选择器与用户视角的平衡
现在回到选择器这个问题。我接触过一些测试工程师,习惯用page.locator('div>div:nth-child(3)')这种层级选择器,理由是"页面结构稳定,不会动"。但真实项目里页面结构调整的频率远比你想象的高,随便加一层 wrapper,选择器就失效。时间全花在修选择器上了,自然觉得 e2e 不好用。
我现在的选择器优先级是:可访问性角色 > 文本 >>