1. 从“e2e”说起:端到端测试到底在测什么
第一次看到“e2e”这个词,很多人会愣一下——它其实是 end-to-end 的缩写,中文叫“端到端”。在软件研发领域,e2e 测试指的是从用户视角出发,把整个系统当成一个黑盒,模拟真实用户操作路径,验证从入口到出口的完整链路是否走得通。它不关心某个函数写得对不对,也不关心某个接口返回的字段是否规范,它只关心一件事:用户能不能顺利完成他想做的事。
我做了十多年一线研发和测试,见过太多团队在单元测试覆盖率 90% 的情况下依然频繁出线上事故。原因很简单——单元测试测的是“零件”,而用户用的是“整车”。e2e 就是那个把整车开上路、踩油门、打方向盘、踩刹车的验证过程。它解决的核心问题是:系统集成后的真实行为是否符合业务预期。适合谁来参考?前端、后端、测试、DevOps、技术负责人,只要你的项目有多个模块协作、有 UI 或 API 链路、有发布上线的需求,e2e 就绕不开。
而最近热词里出现的“e2e profile1”,我理解它大概率指向某种 e2e 测试的配置档案或环境预设——比如在持续集成流水线里,针对不同阶段(冒烟、回归、全量)定义不同的 e2e 执行策略。这个后面会展开讲。先把 e2e 的底层逻辑拆清楚,后面所有实操才有根。
2. e2e 测试的整体设计与方案选型
2.1 为什么 e2e 不能“随便写写”
很多团队第一次做 e2e 时,习惯性地把它当成“高级一点的集成测试”,结果写出来的用例又长又脆,改一个按钮文案就挂一片。我踩过最大的坑就是:早期用 Selenium 写了几百条 e2e,每次发版前跑一遍要两个小时,失败率 30%,最后没人看结果,整个套件形同虚设。
e2e 的设计核心不是“覆盖多少功能”,而是“覆盖多少用户旅程”。一个用户旅程(user journey)是一串有业务意义的操作序列,比如“登录 → 搜索商品 → 加入购物车 → 下单 → 支付 → 查看订单”。这条链路里任何一环断了,用户就流失了。所以 e2e 用例的粒度应该以“旅程”为单位,而不是以“页面”或“按钮”为单位。
方案选型上,我一般按三个维度来决策:
| 维度 | 选项 A | 选项 B | 选型建议 |
|---|---|---|---|
| 测试对象 | UI 层 | API 层 | 核心链路 UI + API 双覆盖,非核心只做 API |
| 执行时机 | 本地手动 | CI 自动 | 必须进 CI,否则一定荒废 |
| 环境策略 | 共享环境 | 独立环境 | 独立环境优先,共享环境需做数据隔离 |
为什么这么选?UI 层 e2e 最接近用户真实体验,但执行慢、维护成本高;API 层 e2e 快、稳,但覆盖不了前端渲染和交互问题。两者结合,核心旅程用 UI 跑,辅助验证用 API 跑,才能在成本和收益之间找到平衡。至于环境,共享环境最大的问题是数据污染——A 团队的测试数据被 B 团队的用例删了,排查起来能让人崩溃。独立环境虽然资源成本高,但省下的排查时间远超那点服务器钱。
2.2 e2e profile1 的合理拆解
热词里的“e2e profile1”,我倾向于把它理解为一套 e2e 执行的配置档案。在实际项目里,我通常会把 e2e 分成几个 profile:
- profile1(冒烟级):只跑最核心的 5~10 条旅程,比如登录、下单、支付。每次代码合并到主干时触发,要求 5 分钟内出结果。
- profile2(回归级):覆盖全部核心旅程和主要边界场景,每晚定时跑一次,要求 30 分钟内完成。
- profile3(全量级):包含所有 e2e 用例,发版前手动触发,允许跑 1~2 小时。
这样分层的理由是:不同阶段对反馈速度的要求不同。合并代码时你只想知道“有没有把主流程搞挂”,不需要等全量跑完。发版前才需要全面验证。如果所有场景都塞进一个 profile,要么慢到没人等,要么快得漏掉关键问题。
注意:profile 的划分不是一成不变的。项目初期可能只有 profile1,随着业务复杂度上升再逐步拆分。强行一开始就搞三套,维护成本会压垮团队。
3. 核心细节解析与实操要点
3.1 用户旅程的识别与优先级排序
写 e2e 的第一步不是打开代码编辑器,而是拿一张纸,把产品的核心用户旅程列出来。怎么列?我常用的方法是“三问法”:
- 用户来这个产品最想完成的三件事是什么?
- 这三件事分别涉及哪些页面和接口?
- 如果这件事做不了,用户会不会直接离开?
以电商为例,答案通常是:买东西、查订单、退换货。其中“买东西”是绝对核心,必须 UI 层全覆盖;“查订单”次之,可以 API 层覆盖;“退换货”频率低但影响大,至少保证主流程通。
排序之后,给每条旅程打两个分:业务重要性(1~5 分)和技术复杂度(1~5 分)。优先做“重要性高、复杂度低”的,快速拿到收益;再啃“重要性高、复杂度高”的,这部分往往需要拆解成多个子旅程。重要性低的,如果复杂度也低就顺手做,复杂度高就暂时放弃——e2e 的资源永远有限,必须花在刀刃上。
3.2 选择器的稳定性设计
e2e 用例脆弱的头号原因就是选择器不稳定。今天用 CSS class 定位,明天前端重构改了类名,用例全挂。我试过几种方案,最后稳定下来的做法是:
- 优先使用
>mkdir e2e-project && cd e2e-project npm init -y npm install -D @playwright/test npx playwright install然后在
playwright.config.ts里配置基础信息:import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 30000, retries: 1, use: { baseURL: 'http://localhost:3000', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, ], });retries: 1的意思是失败后重试一次。为什么设 1 而不是 0?因为 e2e 受环境影响大,偶尔的网络抖动或渲染延迟会导致假失败。重试一次能过滤掉大部分噪音。但注意,如果重试后还是失败,那就是真问题,必须查。4.2 编写第一条核心旅程用例
以“登录 → 搜索 → 加购 → 下单”为例,用例大概长这样:
import { test, expect } from '@playwright/test'; test('用户完成下单主流程', async ({ page }) => { // 登录 await page.goto('/login'); await page.getByTestId('username-input').fill('testuser'); await page.getByTestId('password-input').fill('testpass'); await page.getByTestId('login-btn').click(); await expect(page.getByTestId('user-avatar')).toBeVisible(); // 搜索商品 await page.getByTestId('search-input').fill('无线耳机'); await page.getByTestId('search-btn').click(); await expect(page.getByTestId('product-list')).toBeVisible(); // 加入购物车 await page.getByTestId('product-item').first().click(); await page.getByTestId('add-to-cart-btn').click(); await expect(page.getByTestId('cart-count')).toHaveText('1'); // 下单 await page.getByTestId('cart-icon').click(); await page.getByTestId('checkout-btn').click(); await page.getByTestId('confirm-order-btn').click(); await expect(page.getByTestId('order-success-msg')).toBeVisible(); });这条用例覆盖了四个关键页面和五个关键操作。每一步都有断言,确保操作真的生效了,而不是“点了一下就往下走”。很多新手写 e2e 只点不验,结果页面报错了用例还是绿的,这种 e2e 毫无意义。
4.3 参数化与数据驱动
如果每条用例都硬编码
testuser和testpass,跑第二次就会因为用户已存在而失败。解决办法是参数化:const users = [ { username: `user_${Date.now()}_1`, password: 'pass123' }, { username: `user_${Date.now()}_2`, password: 'pass456' }, ]; for (const user of users) { test(`用户 ${user.username} 完成下单`, async ({ page }) => { // 先用 API 创建用户 // 再用该用户执行 UI 旅程 }); }这样每次跑都是新用户,不会冲突。创建用户可以通过调用后端注册接口完成,比走 UI 注册快得多,也更稳定。
4.4 接入 CI 流水线
e2e 不进 CI 等于没做。以 GitHub Actions 为例:
name: e2e on: pull_request: branches: [main] schedule: - cron: '0 2 * * *' jobs: e2e-profile1: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install --with-deps - run: npx playwright test --grep @smoke - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/这里
--grep @smoke就是跑 profile1 的冒烟用例。我在用例标题里加@smoke标签,比如test('@smoke 用户完成下单主流程', ...)。这样一套代码可以按标签跑不同 profile,不需要维护多份配置。注意:CI 里的 e2e 一定要上传失败时的截图和视频。没有这些,排查失败就像盲人摸象。Playwright 的
screenshot: 'only-on-failure'和video: 'retain-on-failure'就是干这个的。5. 常见问题与排查技巧实录
5.1 典型问题速查表