1. 当"单元测试全绿、系统照样崩"时,你在缺什么
先聊个我踩过的老坑:某次上线前,后端单测覆盖率83%,前端组件测试也过了大半,结果生产环境一跑,用户从登录到下单走完一个流程,直接卡死在支付回调。后来定位了很久,发现是一个第三方登录回调把用户态搞丢了,单测里压根没人模拟过这条完整链路。
这就是端到端测试(E2E,即 End-to-End)存在的意义。它不关心某个函数返回值对不对,也不关心某个组件渲染了没有,它只关心一件事:用户沿着真实操作路径走一圈,系统最终给不给得出正确结果。从点击按钮、输入账号、等待接口返回、跳转页面、到最终看到订单号,这一整条完整的业务链路,才是 E2E 验证的对象。
而"e2e profile1"在我理解里,是一个很典型的工程化表达——它代表了你为了把端到端测试真正落地,建立的第一个可复用的运行配置(Profile)。很多人卡在"我知道要写 E2E,但不知道从哪开始",或者"写了一堆用例,但一跑就飘,根本不敢放进 CI"。这篇文章我会以一个真实的电商主流程为例,把我从零搭起一套端到端测试、并稳定运行在 CI 里的全过程拆开讲,从为什么做、怎么做,到踩过的那些坑,一次说清楚。不管你是在自己维护一个小项目,还是在大团队里推质量基建,都希望能给你省点弯路。
2. 动手之前,先把"E2E 要覆盖什么"这件事想明白
坦白说,E2E 最大的敌人不是写不出用例,而是写了不该写的用例。一上来就想着全站自动化、每个页面都点一遍,结果就是维护成本爆炸,一个月后没人敢碰这套测试。所以动手前,花半天时间做三件事,比撸代码有用得多。
2.1 圈定核心用户路径:不是所有按钮都值得 E2E
我的习惯是画一张"用户价值地图",只把用户能感知、且出了问题会造成直接损失的操作路径纳入 E2E 范围。以电商为例,核心路径无外乎这几条:
- 未登录用户从搜索商品 → 商品详情 → 加入购物车 → 结算 → 登录 → 下单成功;
- 已登录用户直接购物车 → 结算 → 支付 → 查看订单状态;
- 用户中心 → 修改收货地址 → 下单时选择新地址 → 确认订单配送信息正确。
flowchart LR A[用户进入首页] --> B[搜索商品] B --> C[打开商品详情] C --> D[加入购物车] D --> E[进入结算页] E --> F{是否登录} F -->|是| G[提交订单] F -->|否| H[跳转登录] H --> G G --> I[支付成功页]上面这种图,我不会真的画给团队看,我通常会拉一个表格,把每条路径标记为 P0(核心、失败影响直接)、P1(重要、失败影响较大)、P2(一般、可视情况覆盖)。E2E 第一轮只做 P0,跑稳了再往 P1 扩。
| 路径编号 | 用户旅程 | 优先级 | 关键断言点 |
|---|---|---|---|
| P0-01 | 搜索→详情→购物车→登录→下单 | 核心 | 订单生成成功,出现订单号 |
| P0-02 | 购物车→结算→选择地址→支付 | 核心 | 支付成功状态流转 |
| P1-01 | 搜索关键词无结果时展示推荐商品 | 重要 | 空态页面正常展示 |
| P2-01 | 历史订单页点击"再次购买" | 一般 | 购物车商品正确回填 |
只有这种"先用业务视角切蛋糕、再谈技术实现"的方式,才能保证你的 E2E 是在验证业务,而不是在验证代码。
2.2 单测、接口测试、E2E 各自的边界在哪
很多团队把这三层测试混为一谈,导致测了半天都测不到点子上。我的理解是:
- 单元测试:验证"单块积木本身是好的",比如购物车累加逻辑、优惠券折扣计算;
- 接口测试:验证"积木之间的接口是对的",比如下单接口收到参数后返回的 JSON 是否正确,无需关心前端怎么展示;
- E2E 测试:验证"积木拼成房子后,人住进去舒不舒服",必须通过真实浏览器操作,覆盖前端交互、接口调用、路由跳转、状态持久化等完整环节。
所以每写一个 E2E 用例前,我都会问自己一个问题:这个场景如果只测接口,能不能发现相同的问题?如果能,那就别写 E2E,写接口用例便宜得多、稳定得多。E2E 的独特价值恰恰在于跨端集成问题——比如前端某个按钮的 loading 状态导致连续点击多次提交、路由守卫把已登录用户弹回登录页、第三方脚本篡改了页面 DOM 导致按钮不可点。这些问题,单测和接口测试都发现不了,只有 E2E 能。
2.3 技术选型:Playwright、Cypress,还是 Selenium?
这里我不展开做超长对比,只说结论。如果你是从零开始、想尽快见到效果,我建议Playwright,原因有三个:
- 安装简单,一条命令就能拉起来,自带浏览器下载管理,不用像 Selenium 那样手动折腾驱动版本;
- 等待机制足够聪明,
page.click()会自动等待元素可操作,大大减少"找不到元素"这种最烦人的报错; - 生态里内置了 trace 录制、视频回放、断言重试,调试体验接近"开着 X 光看浏览器里发生了什么"。
如果你所在团队已经重度使用 Cypress,那继续用它也完全没问题。我不建议同时引入两套端到端方案,那是给自己找罪受。
选型敲定后,我一般会再确认一下运行环境。我的基准配置是这样的,你可以直接抄:
# 以 Playwright 为例的推荐运行环境 Node.js 版本:>= 20 浏览器:Chromium 为主(跑核心路径),Safari/WebKit 覆盖 20% 关键场景 运行模式:本地 debug 用 headed(有头模式),CI 用 headless(无头模式) 报告:HTML Report + 失败自动截图 + trace 录制3. 把你的第一个 E2E 跑通:以登录→下单全流程为例
这一章是全文的重头戏。我会完整地把"profile1"这个第一个端到端测试配置搭建出来,并且用一个真实的电商核心路径来演示代码怎么写。这里的思路不仅适用于电商,换成后台管理系统、SaaS 产品、内容社区,套路完全一致。
3.1 初始化工程:从一个空目录开始
我习惯把 E2E 测试独立成一个小工程,而不是塞在业务项目里。好处是测试代码不会污染业务依赖,也不会被业务项目的构建逻辑影响。初始化过程比想象中简单:
# 1. 创建工程目录 mkdir e2e-practice && cd e2e-practice # 2. 初始化 package.json npm init -y # 3. 安装 Playwright npm install @playwright/test # 4. 安装对应浏览器(这一步会下载 Chromium 等) npx playwright install chromium装完之后,跑一下npx playwright test,如果环境正常,你会看到一条"No tests found"的提示,这就说明框架已经就绪。
接下来是我比较坚持的一步:新建playwright.config.ts配置文件。这个文件定义了你的"Profile 1"——即所有 E2E 用例共享的基础运行参数。我贴一份可用的最小配置:
import { defineConfig } from '@playwright/test'; export default defineConfig({ // 并发线程数:本地调 4,CI 建议 8~10 workers: process.env.CI ? 8 : 4, // 失败重试:CI 上跑 2 次重试,本地 0 次 retries: process.env.CI ? 2 : 0, // 每个用例超时时间:5 分钟 timeout: 300000, use: { // 基础 URL,所有测试里的相对路径都会拼到它上面 baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', // 始终记录视频,失败时保存 trace video: 'retain-on-failure', trace: 'retain-on-failure', // 视口尺寸统一,避免因为设备差异导致布局断言不稳定 viewport: { width: 1440, height: 900 }, // 模拟一个稳定的地理位置(部分业务可能依赖) geolocation: { longitude: 116.4, latitude: 39.9 }, }, // 只跑我们定义的 E2E 目录 testDir: './tests/e2e', });这里面的E2E_BASE_URL环境变量很重要。本地调试时可以指向 dev server,CI 里可以指向临时部署的预览环境,完全不用改代码。
3.2 第一个用例:登录→选品→加购→下单
先别急着写页面对象模型(POM),我自己刚上手时最大的教训就是过早抽象。第一版用例就是平铺直叙的"故事线",看得懂、跑得通,再做抽象。下面这份代码,我把它命名为purchase-flow.spec.ts,模拟的是一个新用户完成首单的整个流程:
import { test, expect } from '@playwright/test'; test.describe('核心购物流程', () => { test('新用户从搜索到下单的完整链路', async ({ page }) => { // 1. 打开首页,确认加载出搜索框 await page.goto('/'); const searchInput = page.getByPlaceholder('搜索商品'); await expect(searchInput).toBeVisible(); await expect(searchInput).toBeEnabled(); // 2. 搜索一个商品关键词,等待结果列表出现 await searchInput.fill('无线机械键盘'); await searchInput.press('Enter'); const firstProduct = page.locator('.product-card').first(); await expect(firstProduct).toBeVisible({ timeout: 10000 }); // 3. 点击进入商品详情页,查看商品标题和价格 await firstProduct.click(); await page.waitForURL('**/product/**'); const productTitle = await page.locator('.product-title').innerText(); expect(productTitle.length).toBeGreaterThan(0); const priceText = await page.locator('.product-price').innerText(); expect(priceText).toContain('¥'); // 4. 点击"加入购物车",等购物车角标变为 1 await page.getByRole('button', { name: '加入购物车' }).click(); const cartBadge = page.locator('.cart-badge'); await expect(cartBadge).toHaveText('1'); // 5. 跳转购物车,点击去结算 await page.locator('.cart-icon').click(); await page.waitForURL('**/cart'); await page.getByRole('button', { name: '去结算' }).click(); // 6. 未登录状态下,先走登录流程 await page.waitForURL('**/login'); await page.getByPlaceholder('邮箱').fill('e2e-user@example.com'); await page.getByPlaceholder('密码').fill('test-pass-123'); await page.getByRole('button', { name: '登录' }).click(); await page.waitForURL('**/checkout'); // 7. 确认订单,这里我给了一个临时虚拟地址 await page.getByPlaceholder('收货人姓名').fill('张三'); await page.getByPlaceholder('手机号').fill('13800138000'); await page.getByPlaceholder('详细地址').fill('XX市XX区XX路100号'); await page.getByRole('button', { name: '提交订单' }).click(); // 8. 断言订单成功页出现订单号 await page.waitForURL('**/order/success**'); const orderNumber = page.locator('.order-number'); await expect(orderNumber).toBeVisible(); const numText = await orderNumber.innerText(); expect(numText).toMatch(/\d{10,}/); }); });看到第 6 步那个"未登录状态下先走登录流程"了吗?这就是端到端测试最有价值的部分——它真实复现了一个新用户的操作路径:他不可能先帮你登录好再来买东西,一定是下了单才发现要登录。这种"跨登录状态"的验证,接口测试根本做不了。
3.3 为什么说等待机制是 E2E 的"命门"
上面代码里,我反复用了await expect(...).toBeVisible()和await page.waitForURL(...)。这是 Playwright 的自动等待机制,它会轮询元素状态,直到超时或条件满足。很多新手会写成page.waitForTimeout(3000),也就是"死等 3 秒"。这种东西我强烈不建议用,原因很直白:
- 固定等待时间长了,整个用例变慢,跑一次全回归可能要多花十几分钟;
- 固定等待时间短了,弱网环境下元素还没渲染出来,用例就会偶发失败,而失败一次排查起来非常痛苦;
- 它会掩盖真实的时序问题。比如你说等 3 秒,但可能 0.5 秒时页面已经能操作了,剩下 2.5 秒都在浪费;反过来说,某次后端响应慢了 4 秒,等待 3 秒的用例照样挂。
正确的做法是用断言驱动等待。await expect(element).toBeVisible()底层会自动重试,最长等到超时时间才放弃。这既保证了稳定性,也保证了速度——条件一旦满足,测试立刻继续往下走,不会空等。把"等待"这层逻辑交给框架,是你从菜鸟走向老手的第一步。
3.4 Profile1 的进阶:把测试数据隔离出来
第一条用例跑通之后,你很快会面临一个新问题:每次跑测试,都会真实创建一个订单、一个用户,数据越积越多。而且如果测试 A 改了用户密码、测试 B 假设密码还是旧的,相互之间就会打架。
我的解决方案是定义一个独立的测试 Profile——也就是让 E2E 永远运行在一套隔离的账号和数据域下。做法在电商项目里通常是:
- 初始化测试数据专用账号:
e2e-user@example.com,密码固定; - 单独配置一套测试商品的 SKU,价格、库存都写死;
- 下单接口对测试订单走单独的队列,不影响线上统计报表;
- 每次跑完用例,调用一次清理脚本,把测试订单标记归档。
这套隔离机制落在配置里,就是这个样子的:
// tests/e2e/fixtures.ts import { test as base } from '@playwright/test'; export const test = base.extend({ // 每个用例开始前,确保登录态可用 loggedInPage: async ({ page }, use) => { await page.goto('/login'); await page.getByPlaceholder('邮箱').fill('e2e-user@example.com'); await page.getByPlaceholder('密码').fill('test-pass-123'); await page.getByRole('button', { name: '登录' }).click(); await page.waitForURL('**/account'); await use(page); }, });然后你在测试里可以这样用:
import { test } from '../fixtures'; test('已登录用户修改默认地址', async ({ loggedInPage }) => { // 进到用户中心,改地址 await loggedInPage.locator('.address-edit').click(); // ... 后续操作 });这种做法叫fixture 注入,类似单元测试里的beforeEach,但更干净。它会保证每个用例拥有独立上下文,互不干扰。这也是我理解的"e2e profile1"里profile二字的另一个含义——稳定的、可复用的运行环境配置。
4. E2E 跑起来之后,你必然后悔没早知道的稳定性问题
很多团队的 E2E 死在"时好时坏"上。昨天全绿,今天早上一看挂了三四个,点进去一看,元素定位器用的text=登录,但页面上出现了两个"登录"(一个是顶部导航,一个是弹窗左下方的推广位),于是测试选错元素,失败。这种问题,清洗选择器就能解决一半。
4.1 选择器越长越脆弱:把定位器写成"人能一眼看懂的话"
先说我的血泪教训:不要重度依赖 CSS 路径选择器。我见过最夸张的写法是:
body > div#root > div > div.main > div.sidebar > div:nth-child(2) > div > button这种选择器,前端稍微搞一次 DOM 重构,全挂。而且挂在哪个元素你都不知道,排查成本极高。
推荐的做法是按运行优先级,依次选择:
- 语义化角色:
getByRole('button', { name: '提交订单' })——不挑 DOM 层级,只关心"这是页面上的哪个按钮"; - 可访问标签:
getByLabel('邮箱')——针对表单控件; - 占位符或文本:
getByPlaceholder('搜索商品')、getByText('订单已提交'); - TestID 兜底:如果业务方对可访问性没有要求,可以请前端在关键元素上打
>async function closeModals(page: Page) { const closeButtons = page.locator('.modal-close, .cookie-close'); const count = await closeButtons.count(); for (let i = 0; i < count; i++) { await closeButtons.nth(i).click().catch(() => {}); } }这层处理放在
beforeEach里,每个用例跑之前强制清理一次。不好看,但管用。4.3 网络请求不是瞬时的:把"等待接口返回"写进用例
E2E 里最容易产生的偶发失败,就是"页面已经跳转,但接口数据还没回来"。比如你提交了表单,页面上先出现了"订单已提交"的字样,但订单号区域还在 loading。很多断言此时就会拿空值去比较,逻辑上没错,但结果就错了。
正确的姿势是用 Playwright 的
page.waitForResponse:// 点击提交后,等待下单接口返回 const [response] = await Promise.all([ page.waitForResponse(resp => resp.url().includes('/api/orders') && resp.request().method() === 'POST' ), page.getByRole('button', { name: '提交订单' }).click(), ]); // 断言接口返回成功 expect(response.ok()).toBeTruthy(); const body = await response.json(); expect(body.orderId).toBeDefined();这里用
Promise.all同时触发点击和等待接口,不然你会陷入竞态:点击事件发生得比waitForResponse注册还早,结果一直等不到响应。这种写法我几乎用在了所有涉及提交数据的操作上,稳定性和排查速度都提升了非常多。4.4 失败排查:先看 trace,再截图,最后看日志
Playwright 最让我喜欢的一点,是失败时会自动生成 trace 文件。打开 trace 查看器,你能看到:
- 每一步操作前页面的真实截图;
- 每一步前后的 DOM 快照;
- 所有网络请求的耗时与响应状态;
- 控制台报错信息。
这个排查路径比直接看日志高效十倍。我处理 CI 上挂掉的用例时,惯例是:
- 打开 HTML Report,找到失败用例;
- 下载 trace 文件,拉到本地用
npx playwright show-trace path/to/trace.zip查看; - 定位到失败的最后一步,看是"元素找不到"还是"断言值对不上";
- 如果是断言值异常,再看这段时间前后台网络请求返回了什么,往往立刻就能定位到后端 bug。
5. 把 E2E 嵌进 CI:跑出稳定性的最后一张拼图
本地跑通只是个开始。E2E 如果只在你电脑上全绿,价值打折一半。它必须要能放进 CI,每次合并代码前自动跑一遍,才能真正成为"质量防线"。
5.1 一张最小可用的 GitHub Actions 配置
我用的是 GitHub Actions 加 Playwright 官方的 GitHub Action,配置量非常小:
name: e2e on: push: branches: [main] pull_request: jobs: e2e: runs-on: ubuntu-latest services: mysql: image: mysql:8.0 env: MYSQL_ROOT_PASSWORD: test ports: - 3306:3306 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci # 启动前端和依赖的后端服务 - run: npm run build && npm run start:preview # 启动 E2E 测试 - uses: playwright-community/playwright-action@v2 with: command: npx playwright test这里的思路是:CI 里先拉代码、装依赖、启动被测试服务的临时实例,然后再跑 Playwright。注意数据库我是用一个 MySQL 服务容器模拟的,目的是让测试写操作落在隔离库,不影响本地数据。
5.2 把 E2E 放进"合入门禁"前,先想清楚策略
我见过两种极端团队:
- 一种是把 E2E 挂在主干上,只在代码合并后跑。缺点是出问题了,代码已经进主干,修复成本变大;
- 另一种是把 E2E 挂在每个 PR 上,但用例一多,跑一次要半小时,开发者怨声载道。
我现在的推荐策略是分级跑:
场景 用例范围 说明 PR 阶段 仅 P0 核心路径(8~15 个用例) 5 分钟内跑完,快速反馈 合入主干后 P0 + P1(50~80 个用例) 20 分钟内跑完,作为质量门禁 发布前夜 全量回归(100+ 用例) 覆盖所有核心和重要路径 这种分级策略兼顾了反馈速度和覆盖深度。不要试图让每个 PR 都跑全量回归,那只会让你在等待中浪费生命,最终反而选择不跑。
5.3 定时触发 + 失败通知:比你想的多一层保障
除了代码变更时触发,我还会配一条"每天凌晨 2 点跑全量"的定时任务。原因很现实:很多线上偶发问题,比如某个第三方接口第二天凌晨挂了、某个测试账号密码过期了,不是代码变更引起的,但会暴露在环境层面。定时跑一次,能在白天上班前发现问题,而不是等用户来报。
失败通知我用的是 Slack Webhook,不过你可以按团队习惯换成飞书、企业微信或者邮件。重要的是不要只发"E2E 失败"几个字,而是直接附上报告地址和失败用例列表,方便接手的人直奔主题。
6. 从"能跑"到"敢用":我踩过的三个隐蔽的坑
这套体系跑稳定之后,我以为自己已经"毕业"了,结果还是陆续踩了几个更隐蔽的坑,这里一次性拿出来讲,避免各位重复踩。
6.1 测试数据污染:比你想的更严重
最早我只隔离了账号,但没隔离商品库存。结果某段时间 CI 老挂,定位半天发现是两个并行测试进程同时下单抢同一件库存只剩 1 件的商品,一个成功一个失败。从那之后我学乖了:
- 所有测试数据必须做到"用例级隔离":每个用例创建自己专属的测试商品,或者给不同用例分配不同的固定 SKU;
- 能 mock 的外部接口就 mock 掉,尤其是支付网关,绝对不碰真实的沙箱环境,否则测试速度会拖垮、还会产生大量脏数据;
- 跑完用例后,记得调用清理钩子(
test.afterEach),把创建的订单、优惠券、地址等清理掉。
6.2 过度录制:录制脚本很爽,维护很痛
Playwright 有
npx playwright codegen的录制功能,能根据你的手动操作自动生成脚本。新手会觉得这太香了,点几下就有一条用例。但我必须泼一盆冷水:录制出来的脚本,往往是"最脆的选择器 + 最长的时间线"。它为了兼容你的每次停顿,会生成一堆不需要的等待和毫无语义的 CSS 路径。这些脚本跑两次全绿,两周后开始红,根本维护不下去。所以我把 codegen 的定位限定为"探索工具,而非生产力工具"。它最大的价值是帮你快速确认"某个元素到底有没有稳定选择器",以及"页面跳转的 URL 规律是什么"。真正落地的用例,我还是会手写。
6.3 不区分断言失败还是环境失败
E2E 红,不等于产品有 bug。还可能是测试环境挂了、测试数据丢了、第三方接口 mock 掉了、甚至 CI 机器太慢导致超时。所以我会在报告里尽量打上"失败类型"标签,Playwright 的
testInfo可以帮我们做这件事:test('订单流程', async ({ page }, testInfo) => { // 如果页面停留在登录页,标记为"状态违规" if (page.url().includes('/login')) { testInfo.annotations.push({ type: 'failureType', description: 'AUTH_LOGOUT' }); } });把失败分类以后,每次 CI 挂掉,就不再是"诶 E2E 挂了"这种语焉不详的状态,而是能直接说"E2E 失败,分类是支付网关超时,跟咱们本次改动无关"。这在团队协作里,减少的沟通成本远超你的想象。
7. 你的第一个 E2E 用例,今晚就可以开始写
这篇文章写到这里,核心的骨架已经全出来了。我还想最后强调一个观念上的转变:E2E 测试不是测试部门的专属工作,而是整个研发团队对业务质量达成共识的载体。它逼你把"用户到底怎么用这个系统"这个终极问题用代码写出来;它逼你和前端、后端、产品一起对齐每次交互的预期结果;它也逼你在每次发布前认真想一遍:这个系统如果出了大问题,会在哪一环最先暴露。
如果你今晚就想动手,我的建议是:
- 别贪多,先挑一条你自己天天在走的核心路径,把它写成一条用例;
- 配好
playwright.config.ts,让它在本地能稳定跑通; - 截图和 trace 打开,挂一次就被动调试一次,你会快速积累起"哪些元素不能这么选"的直觉;
- 等你手里有了 10 条左右、连续一周全绿的用例,再考虑接 CI。
跑 E2E 的日子里,我最深的体会就是:你永远不可能用单测消除所有线上问题,但你一定可以用 E2E 把"用户最核心的那条路"堵得死死的。剩下的旁支细节,交给接口测试和单元测试去兜底。这种分层测试体系,才是一个成熟工程真正该有的样子。