1. 为什么 E2E 和 profile1 总是一起出现
说实话,我第一次在项目里看到“e2e”这个目录名的时候,心里是有点懵的。那时候我刚从纯后端转到全链路开发,打开仓库一看,tests/e2e/下面躺着一堆profile1.spec.ts、profile2.spec.ts,第一反应是:这哥们儿是不是给每个用户都建了一套测试?
后来问了一下带我的老同事,他一句话点醒了我:“e2e 就是端到端,profile1 就是我们设计的第一个真实用户场景。”从那以后我才慢慢理解,所谓 e2e 测试,不是简单地把接口测一遍,而是站在用户的角度,把整个业务链路完整地走一遍——从打开页面、输入数据、点击按钮,到后端处理、返回结果、前端渲染,再到最终用户在页面上看到什么,全部串起来验证。
那 profile1 是什么?你可以把它理解成“第一个端到端测试的配置档案”。在实际项目里,我们不会只测一条链路,一般会按照不同的业务角色、不同的用户类型、不同的权限范围,拆出多个 profile。profile1 通常就是最基础、最核心的那条路径:一个普通用户,从一个入口进入,完成一个完整的业务动作,然后确认结果正常。
我见过不少团队一上来就疯狂写 E2E,恨不得把每个页面每个按钮都覆盖一遍,结果维护成本高到崩溃,跑一次全量要两个小时,天天修脚本比写业务代码还痛苦。真正靠谱的做法,是先想清楚 profile1 是什么,把最重要的一条链路跑通、跑稳,再考虑扩展。
这篇文章我就拿一个实际项目做例子,讲清楚 E2E 的完整思路:从场景设计、环境搭建,到脚本编写、问题排查,再到如何把 E2E 真正纳入日常开发流程。全程没有废话,都是我自己踩过的坑和验证过的方案。
2. 动手写 E2E 之前,先把这三件事想明白
很多人学 E2E 的时候,第一步就去装框架、写代码,这是最大的误区。E2E 测试和单元测试不一样,它牵涉的环境因素非常多,数据、网络、第三方服务、权限体系都可能影响结果。稍有不慎,测试结果就不是“代码对不对”,而是“环境配没配好”。
2.1 工具选型:不要盲目追新,要看你团队的现状
我见过太多团队在选 E2E 框架的时候犹豫不决。Cypress 火的时候全上 Cypress,Playwright 出来又全换 Playwright,Selenium 明明还在线上跑着却没人愿意维护。
我个人的经验是:如果团队从零开始,优先选 Playwright。原因很简单:自带等待机制,不用手动 sleep;支持多标签页、多浏览器;还能直接拦截网络请求做 mock。这些能力在写真实业务场景的时候会节省大量时间。
Cypress 的优势是上手快、调试体验好,但它在多标签页和跨域场景下有先天限制。Selenium 虽然老,但生态最成熟,适合有历史包袱的老项目。工具本身没有绝对的好坏,关键是匹配你的团队能力和业务场景。
| 框架 | 上手难度 | 多标签支持 | 网络拦截 | 建议场景 |
|---|---|---|---|---|
| Playwright | 中等 | 好 | 好 | 新项目首选 |
| Cypress | 低 | 有限 | 好 | 纯前端单页应用 |
| Selenium | 较高 | 一般 | 需要额外方案 | 老项目、多语言绑定 |
2.2 测试环境:E2E 最怕的不是代码 bug,而是环境漂移
这是我要反复强调的一点:E2E 测试必须在一个稳定、独立、可恢复的环境里跑。我见过最典型的翻车现场是这样的:开发同学在自己本地跑 E2E 全绿,一上 CI 就挂,查了半天发现是测试环境的数据库被某个手工操作改了数据,导致跑出来的结果和预期不一致。后来我们规定:E2E 环境必须自动化构建,数据必须通过脚本重置,禁止任何人手工连接测试环境数据库改数据。
具体来说,环境层面要解决三个问题:
- 数据隔离:每个测试跑之前,把数据库恢复到初始状态,或者用事务回滚的方式保证相互不影响。
- 服务依赖:被测系统依赖的 Redis、消息队列、第三方接口,要么在测试环境起真实的替代服务,要么通过 mock 固定返回。
- 浏览器和系统版本:最好用 Docker 把运行环境固定下来,避免本地的 Chrome 版本和 CI 不一致导致莫名其妙的问题。
2.3 数据准备:profile1 场景里到底用哪套数据
刚才说的 profile1,关键就是“用哪套数据、走哪条链路”。我们项目的 profile1 是这样设计的:一个普通注册用户,从登录页输入账号密码,进入工作台,点击“创建订单”,填写订单信息并提交,跳转到支付页面,支付成功后看到订单状态变为“已完成”。
这套场景对应的数据准备就非常明确:需要一个预先创建的测试账号、预先配置的商品信息、初始为空白的订单列表。而这些数据不能靠手工在界面上点出来,要在跑测试前通过接口或数据库脚本准备好。
3. profile1 场景完整实操:从配置到跑通
现在进入正题,我拿一个具体的项目来讲。这个项目是一个简化版的订单管理系统,前端是 Vue3,后端是 Node.js,E2E 框架用 Playwright。
3.1 项目结构和配置文件
先看目录结构。我习惯把所有 E2E 相关的东西放在一个独立的目录下,不和单元测试混在一起:
tests/ └── e2e/ ├── profiles/ │ ├── profile1.spec.ts │ └── helpers/ │ ├── setup.ts │ └── teardown.ts ├── fixtures/ │ └── users.json └── playwright.config.tsplaywright.config.ts是核心配置文件,我会把 baseURL、测试环境变量、浏览器设置都放在这里。给一个参考版本:
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './profiles', timeout: 30000, fullyParallel: true, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, use: { baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], });这里有两个细节值得解释。第一,retries在 CI 环境设成 2,是因为 E2E 测试天然带有一定不稳定性(网络抖动、动画延迟等),给一次重试机会可以避免偶发失败阻塞发布。第二,trace: 'retain-on-failure'是调试利器,测试失败时 Playwright 会保留完整的追踪信息,包括网络请求、控制台日志、页面快照,事后排查不需要让开发同学重复复现。
3.2 数据准备脚本:让每次测试都在相同的起点
数据准备是整个 profile1 里最容易被忽视但又最关键的环节。我写了一个setup.ts,在测试执行前统一完成数据初始化:
import { request } from '@playwright/test'; const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000'; export async function setupTestData() { // 通过 API 创建测试用户,避免走界面注册流程(慢且不稳定) const apiContext = await request.newContext(); const res = await apiContext.post(`${BASE_URL}/api/test/seed`, { data: { user: { name: 'profile1_user', email: 'profile1@test.local', password: 'Test@1234', }, products: [ { sku: 'SKU-001', name: '测试商品A', price: 199 }, { sku: 'SKU-002', name: '测试商品B', price: 299 }, ], }, }); if (!res.ok()) { throw new Error(`准备测试数据失败: ${res.status()}`); } await apiContext.dispose(); }这里我特别强调一下:不要用界面操作去准备数据。如果你让测试脚本先去注册页面填表单、再点确认、再等邮件验证,这条链路本身就充满了不稳定因素,而且每跑一次测试都要多花几十秒。通过 API 直接写入种子数据,速度快、可控性强,还能把测试的焦点保持在真正要验证的业务链路上。
3.3 编写 profile1 测试用例
接下来就是重头戏,profile1 的测试用例本身。我先说明一下这个场景包含的步骤:
- 访问登录页面
- 输入账号密码并登录
- 跳转到工作台
- 点击“创建订单”按钮
- 填写订单信息(商品、数量、收货地址)
- 提交订单
- 跳转到支付页面完成支付
- 返回订单列表,确认订单状态为“已完成”
完整代码如下:
import { test, expect } from '@playwright/test'; import { setupTestData } from './helpers/setup'; test.describe('profile1: 用户完成一笔订单支付', () => { test.beforeAll(async () => { await setupTestData(); }); test('创建订单并完成支付', async ({ page }) => { // 1. 登录 await page.goto('/login'); await page.getByLabel('邮箱').fill('profile1@test.local'); await page.getByLabel('密码').fill('Test@1234'); await page.getByRole('button', { name: '登录' }).click(); // 2. 验证登录成功,跳转到了工作台 await expect(page).toHaveURL('/dashboard'); await expect(page.getByText('欢迎回来,profile1_user')).toBeVisible(); // 3. 创建订单 await page.getByRole('button', { name: '创建订单' }).click(); await page.getByLabel('商品SKU').fill('SKU-001'); await page.getByLabel('数量').fill('2'); await page.getByLabel('收货地址').fill('测试街道188号'); await page.getByRole('button', { name: '提交订单' }).click(); // 4. 进入支付页 await expect(page).toHaveURL(/.*\/payment/); await expect(page.getByText('待支付金额:398 元')).toBeVisible(); // 5. 模拟支付成功 await page.getByRole('button', { name: '确认支付' }).click(); // 6. 回到订单列表,验证状态 await expect(page).toHaveURL(/.*\/orders/); await expect(page.getByText('测试商品A')).toBeVisible(); await expect(page.getByText('已完成')).toBeVisible(); }); });这段代码看起来很简单,但它背后有几个容易踩坑的地方:
第一步,登录之后的 URL 判断。toHaveURL('/dashboard')我怎么确认它是相对路径还是绝对路径?在 Playwright 里,如果传的参数是字符串且以/开头,会基于baseURL拼接。这个特性很好用,但我见过不少人在这里踩坑,把 baseURL 忘了配,结果断言永远不通过。
第二步,元素定位策略。我用的是getByLabel、getByRole,而不是#login-btn这种 CSS 选择器。原因很简单:从用户视角定位元素,更接近真实用户的操作方式,而且在页面改版(class 名变了、DOM 结构微调)的时候,这些测试依然能稳定运行。如果你接手的项目里全是div#root > div > form > input这种选择器,强烈建议逐步替换掉。
第三步,慢操作与重试策略。我在支付完成后,没有立即断言订单状态。因为支付回调可能是异步的,订单状态需要几毫秒甚至更久才能更新。Playwright 的expect自带重试机制,会自动等待元素出现,所以不用手动 sleep。但如果你用的是老式写法await page.waitForTimeout(3000),这就是很多人 E2E 跑不稳的原因——固定等待要么太短(不稳定)、要么太长(拖慢整个测试)。
3.4 参数选择的逻辑:为什么我的等待是“自动的”而不是“固定的”
上面提到 Playwright 的自动等待,很多人不理解它和其他框架的区别。我打个比方:你去餐厅吃饭,你坐在座位上,服务员会等你举手示意才过来下单,这叫“事件驱动”;而waitForTimeout(5000)就像你设置了一个 5 秒的闹钟,不管你有没有准备好,闹钟一响就呼叫服务员。显然,前者更合理,更不容易出错。
所以在写 E2E 的时候,我给自己定了一条规则:能用自动等待的地方,绝不用固定 sleep。所有toBeVisible、toHaveURL、toBeEnabled这些断言,都会不断轮询检查条件是否满足,直到超时(默认 5 秒)。如果某些操作比较慢,可以单独调大该断言的超时时间:
await expect(page.getByText('已完成')).toBeVisible({ timeout: 15000 });但注意,这不是让你到处把超时调大,而是在确实存在不可控耗时(比如支付渠道回调)的场景下,给足余量。
4. E2E 测试跑不稳定?这是最全的排查手册
E2E 测试最大的敌人不是业务逻辑复杂,而是“不稳定”。同一份代码,上一次跑全绿,下一次就挂了,而且报错信息还特别难懂。我在项目里反复遇到过这些问题,整理成了一份排查速查表。
4.1 常见失败原因和解决方案
| 失败现象 | 根本原因 | 解决思路 |
|---|---|---|
| 登录后页面一直停留在登录页 | 国际化文案不一致,登录按钮的 name 在环境间有差异 | 统一使用><button>await page.getByTestId('login-submit-btn').click(); 这样不论文案怎么变、语言怎么切,只要 testid 不变,测试就稳定。 4.2 排查步骤:从拿到失败信息到定位根因如果你拿到一个失败的 E2E 报告,我建议按这个顺序排查,效率最高:
4.3 提高稳定性的三个土办法除了上面这些标准的排查手段,我还有一些“土办法”,实测下来很稳: 第一个,测试号专用且独立。不要拿线上真实用户账号来跑 E2E,也不要在多个环境共用同一个测试账号。给每个环境单独建测试账号,密码写在 CI 的密钥仓库里。 第二个,跑完测试主动做清理。我们会在 第三个,标记已知的“不稳定用例”。如果某个用例因为这个那个原因短时间内无法彻底解决,我建议用 5. 从“能跑”到“靠谱”,E2E 还需要补上这三板斧跑通 profile1 只是第一步,要真正把 E2E 变成团队的质量防线,还需要做三件事:接入 CI、控制执行时间、建立维护规范。 5.1 接入 CI:让每一次合并都自动跑 E2E我们的做法是,E2E 不跑在每一次 commit 上,那样太慢了。我们配置在 Pull Request 阶段,当代码变更涉及前端页面或关键接口时,自动触发 E2E。具体实现是在 CI 流水线里加一个步骤: 构建前先启动被测服务。有人可能问,为什么不在测试脚本里启动服务?因为被测服务的启动方式依赖于具体项目(Docker Compose、npm run dev、k8s),不应该和测试代码强耦合。CI 负责把服务跑起来,E2E 只负责访问它。 5.2 控制执行时间:8 分钟是心理底线如果一次 E2E 全量跑下来超过 10 分钟,开发同学的耐心会被耗尽,他们会开始跳过、忽略失败,这条防线就形同虚设了。要想控制时间,可以从这里入手:
5.3 建立维护规范:让新人也能贡献 E2E最后讲讲团队协作层面的经验。E2E 测试不是写完就完事的,它需要持续维护。我给自己和团队定了三条规则:
|