项目一发版,最怕的不是功能没做,而是“开发说好了、测试说测过、产品说没问题”,结果用户一打开才发现登录进不去、购物车加不上、支付回调没落地。这类问题,靠单元测试和接口测试很难提前暴露,因为它们拆得太碎,压根模拟不了真实用户的操作流。这时候就需要 E2E——端到端测试。E2E 就是把应用当黑盒,像真实用户那样从入口一路操作到出口,把关键业务链路完整跑起来验证。这篇文章我不讲大道理,主要结合我自己在几个 Web 项目里落地 E2E 的实际经验,聊聊工具选择、配置方案、用例写法,以及怎么摆脱“概率性失败”的折磨。无论你是刚入门自动化的新人,还是被 flaky test 逼疯的老手,这里应该都有你能直接拿去用的东西。
1. E2E 到底在测什么?为什么它值得你花时间
1.1 单元测试、集成测试和 E2E 的边界
测试金字塔这个概念都快被说烂了,但我发现身边真正把边界想清楚的人并不多。单元测试是盯着一个函数或一个组件,给它喂输入、看输出,跑得快、定位细,但它完全不关心页面能不能显示、按钮能不能点。集成测试稍微往外扩一点,把几个模块拼起来,验证接口契约和数据流转,可它依然不关心真实浏览器里渲染出来的东西长什么样。E2E 处在金字塔塔尖再往上那一层,启动完整前端甚至联调后端,用浏览器自动化真实交互,验证“用户目标是否达成”。
我在实际项目里见过一个特别典型的场景:前端在结算页调用后端接口时传了orderId,后端要求的是order_id,单元测试两边都过了——因为前端 mock 的响应早就写死了,后端测试也是自己构造请求,两边各测各的,谁也没发现契约对不上。等联调的时候一测就炸。这类问题本质是“零件各自合格,但组装出来的整机不转”,E2E 就是用来守这个口的。
1.2 E2E 能发现的四类真实问题
接口契约不一致。这种问题出现频率比我预想的高得多。我在一个交易项目里遇到过,后端返回的金额是字符串"12.00",前端却直接做加减法,结果12.00 + 3.50变成了"12.003.50"。单测完全没问题,因为 mock 数据把金额定义成了 number。只有到了 E2E 环境,真实接口返回字符串,页面总金额才算错。E2E 断言页面文本正好把这种差异抓出来。
异步时序问题。SPA 的页面往往是三段式:先出骨架屏,然后发请求,最后数据回来渲染。自动化测试如果不理解这种节奏,很容易在 DOM 刚出现但还没 ready 的时候就点了按钮,然后得到“元素不可点击”或“点击后无响应”。E2E 框架里的自动等待能缓解一部分,但前提是你得知道它在等什么、什么时候需要显式等待。
跨模块回归。登录态这种全局状态,单测根本管不到。E2E 里你从登录页进入,走完一个需要鉴权的流程,如果路由守卫因为 token 过期把用户弹回登录页,用例会立刻失败,这比人工点点点靠谱。还有一个容易漏的场景:A 模块写入 localStorage 的旧版本数据,B 模块读取时没做兼容,同样只有端到端流程能发现。
环境配置差异。CI 上跑的 E2E 往往连 staging 或独立测试环境。如果 CI 环境忘了开 feature flag,或者 CDN 域名指向了旧资源,页面行为就会和生产不一致。E2E 不是只测功能,它也在替你验证“部署后的制品在这个环境里能不能用”。
2. 工具选型:Cypress 还是 Playwright?
2.1 两大主流工具的横向对比
说 E2E 绕不开工具选型,选错工具后期维护成本会翻倍。我最早接触的是 Selenium,WebDriver 协议那一套上手慢,写等待、写重试、处理弹窗都很累,对新项目我已经不推荐了。现在新项目基本就两个选择:Cypress 和 Playwright。
| 维度 | Cypress | Playwright |
|---|---|---|
| 运行架构 | 直接跑在浏览器进程内,通过 API 驱动 | 通过浏览器远程调试协议,跨进程控制 |
| 浏览器支持 | Chrome、Firefox、Edge,Safari 支持较弱 | Chromium、Firefox、WebKit 全覆盖 |
| 自动等待 | 内置 retry-ability,命令会重试直到超时 | 内置 actionability 检查,自动等待元素稳定 |
| 调试方式 | 时间旅行快照、cy.pause() | Trace Viewer、网络时序、DOM 快照 |
| 并行能力 | 官方云服务,或自己拆机器 | 原生 worker 并行与 sharding |
| 配置风格 | cypress.config 里的 e2e 对象 | playwright.config 里的 projects 对象 |
表格不是用来背的,是用来选型的。如果你要覆盖 WebKit 或 Safari 的兼容性,Playwright 是更省事的选择;如果你团队已经习惯 Cypress 的链式语法,或者项目里已有大量 Cypress 用例,继续用 Cypress 也完全合理。工具没有绝对好坏,关键是选完以后把工程结构做好,别换来换去。
2.2 怎么理解 e2e profile 这个配置
最近不少人在教程或仓库里看到e2e profile1这种叫法,初看可能会懵。其实这里的 profile 不是某个框架独有的语法,它指的是“一套独立的运行配置”。你可以把 profile 理解成“针对某一类运行场景打包的完整参数集”:比如测试文件目录、baseURL、浏览器型号、全局环境变量、登录态文件,全都组合在一起,就是一个 profile。
Cypress 里最典型的写法是在cypress.config.js中配置e2e对象,再通过 env 扩展多套 profile。很多团队会同时维护e2e、e2e-profile1这样的命名,用来区分用户端主流程、管理后台流程、不同业务线或不同环境。下面是一个很常见的写法:
// cypress.config.js const { defineConfig } = require('cypress'); module.exports = defineConfig({ e2e: { baseUrl: 'https://staging.example.com', specPattern: 'cypress/e2e/**/*.cy.js', supportFile: 'cypress/support/e2e.js', env: { profile: 'default', profile1: 'user-facing', apiUrl: 'https://api.staging.example.com' }, setupNodeEvents(on, config) { if (config.env.profile1 === 'user-facing') { config.baseUrl = 'https://m.staging.example.com'; } return config; } } });通过环境变量或者命令行参数切换这套 profile,同一份用例就能在桌面端、移动端、不同业务域之间复用。Playwright 里对应的概念是projects,例如:
projects: [ { name: 'e2e', use: { ...devices['Desktop Chrome'] } }, { name: 'e2e-profile1', use: { ...devices['iPhone 14'] } } ]运行npx playwright test --project=e2e-profile1,就只会跑手机端那一整套。这里我特别想强调:profile1这种命名只是团队约定,不是框架强制要求。真正重要的是命名规则要统一,并在 README 里写清楚每个 profile 对应什么环境、什么设备,否则两个月后没人记得profile2到底是干嘛的。
2.3 我的选型建议和理由
我自己在 PC Web 场景下更常用 Playwright,原因很简单:排错快。测试挂了之后打开 trace,能看到步骤时间线、网络请求、控制台报错、DOM 快照,几乎不用再手工复现。个别后台管理项目我继续用 Cypress,因为配置简单,团队接受度高,几十条冒烟用例跑得很稳。
如果你面临同样选择,我建议按这个思路判断:
- 需要强跨浏览器矩阵,尤其 WebKit/Safari:选 Playwright。
- 团队已有大量 Cypress 用例,迁移成本大于收益:继续用 Cypress。
- 以接口为主、附带少量前端冒烟:Cypress 更轻,写起来更快。
- 大型前端应用、需要深入排查页面问题:Playwright 的 trace viewer 会省下海量时间。
3. 从零搭建一套可落地的 E2E 测试工程
3.1 初始化项目与依赖安装
假设你有一个 Node 仓库,想给前端项目补齐 E2E。我建议把 E2E 测试放在独立目录,比如e2e/,别和src/混在一起。这样构建流程、CI 缓存、权限控制都清晰。初始化命令很简单:
mkdir e2e cd e2e npm init -y npm install -D @playwright/test npx playwright installnpx playwright install会自动下载浏览器。如果你在 Linux CI 上跑,记得用npx playwright install --with-deps,它会顺带安装系统依赖库,避免缺 lib 导致浏览器启动失败。公司内网环境可能需要配置镜像源,这属于环境准备,建议提前写进团队文档。
如果你用的是 Cypress,对应命令是npm install -D cypress,首次运行会自动生成配置文件。我的教程以 Playwright 为主,但核心思路两个框架通用。
3.2 配置 e2e profile 与多环境切换
一个可落地的 Playwright 配置大概是这样的:
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', timeout: 30_000, expect: { timeout: 5_000 }, fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [['html', { outputFolder: 'playwright-report' }]], use: { baseURL: process.env.E2E_BASE_URL || 'https://staging.example.com', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'e2e', use: { ...devices['Desktop Chrome'] } }, { name: 'e2e-profile1', use: { ...devices['iPhone 14'] } }, ], });逐个说下关键项为什么这么配:
timeout: 30_000:单个用例超时 30 秒。太短长链路跑不完,太长会把 CI 卡死。expect.timeout: 5_000:断言等待 5 秒。这个时间适合大多数前端异步场景。fullyParallel: true:用例之间如果没有共享状态,可以并行跑,节省时间。retries: process.env.CI ? 2 : 0:CI 环境重试两次,用来抵抗偶发网络波动;本地不重试,逼自己暴露真实问题。trace: 'on-first-retry':只记录第一次失败时的 trace,体积小又够用。projects:这就是我们前面说的多 profile 管理。这里的e2e-profile1是移动端 profile,你可以按业务线继续扩展。
有一点要提醒:workers: process.env.CI ? 1 : undefined是我自己的折中策略。并行能跑更快,但如果测试环境数据隔离做得不好,多 worker 同时写同一批数据会互相干扰,反而制造随机失败。等你把数据清理策略做好,再考虑放开并行。
3.3 第一个完整的 E2E 用例:登录→跳转→断言
一个最经典的用例:用户登录后进入工作台。完整代码如下:
import { test, expect } from '@playwright/test'; test('用户可以使用账号密码登录并进入工作台', async ({ page }) => { await page.goto('/login'); await page.getByLabel('用户名').fill('demo_user'); await page.getByLabel('密码').fill('demo_password'); await page.getByRole('button', { name: '登录' }).click(); await expect(page).toHaveURL(/\/workspace/); await expect(page.getByRole('heading', { name: '工作台' })).toBeVisible(); });我来拆解一下这几个关键动作。getByLabel('用户名')会根据 label 关联关系找到输入框,getByRole('button', { name: '登录' })会找语义化角色是按钮、文本是“登录”的元素。这么做的好处是,只要界面文案不变,按钮类名怎么换测试都不会挂。
最后两个断言也很有讲究。toHaveURL校验路由变化,toBeVisible校验关键 UI 出现。Playwright 的 expect 会自动重试,直到元素满足条件或超时,所以不需要在断言前自己写waitForTimeout(1000),那样反而是反模式。
如果登录后要经过一个中间引导页再跳转,可以加一句await page.waitForURL('/workspace', { timeout: 10_000 }),显式等一下,比固定 sleep 靠谱得多。
3.4 处理登录态:全局前置 hook 与持久化
一个项目可能有几百条用例,如果每一条都从登录页开始输入账号密码,效率太低,遇到图形验证码或短信验证码更是直接卡死。常规做法是先用一个用例完成登录,把 cookie 和 localStorage 存成文件,后续用例直接加载。
生成登录态:
import { test } from '@playwright/test'; test('保存登录态供后续用例使用', async ({ page }) => { await page.goto('/login'); await page.getByLabel('用户名').fill('demo_user'); await page.getByLabel('密码').fill('demo_password'); await page.getByRole('button', { name: '登录' }).click(); await page.context().storageState({ path: 'e2e/.auth/user.json' }); });然后在playwright.config里加载:
use: { storageState: 'e2e/.auth/user.json', }这里有个大坑:登录态会过期。如果 token 有效期只有一小时,而 CI 排队排了半小时,全量用例就会集体失败。我的做法是在 CI 前置脚本中重新跑一次生成登录态的用例,拿到新 token 再开始全量执行;同时把“生成登录态”单独抽成一个测试,不上报告,只做准备工作。
4. 写稳定 E2E 用例的十个实战技巧
4.1 选择器策略:能用角色和标签,就别用 CSS 类名和深层次 DOM 结构
技巧 1:优先使用 role、label、text 定位。getByRole('button', { name: '提交' })比.btn-primary稳定得多。组件库升级、视觉稿重构,只要语义没变,用例就不会断。
技巧 2:组件封装较重时,给关键元素加 这样报告里会清楚显示哪一步开始失败,CI 上一眼就能定位。比较一下:trace 适合“为什么失败”,截图适合“失败时页面长什么样”,视频适合“整个操作过程发生了什么”。CI 上建议 trace 和 screenshot 都开,视频按需开,否则产物体积会很大。 我之前遇到最典型的一个坑是 Modal 遮罩层。页面上按钮明明存在,但被一个半透明的 E2E 跑在共享测试环境上时,最容易出三类问题: 共享账号被改了密码。有人手动登录改了密码,第二天 CI 全红。解决办法是测试账号密码由脚本统一维护,不给人手工登录的入口,或者定期自动重置。 测试数据被手工清理。某个同事为了调试,直接清了数据库里的商品表,结果依赖这些数据的用例全部失败。解决办法是数据初始化放用例内,不依赖上次运行残留,或者维护一套独立于手工测试的数据模板。 登录态过期时间太短。前面说过,token 有效期一小时,CI 排队时间一长就集体失效。我的方案是每天定时任务在 CI 前置阶段重新登录生成新状态,同时把依赖登录态的用例和匿名用例分开,避免一次过期影响全部。 很多团队一看到 E2E 失败就按重试键,这其实是在掩盖问题。我自己的排查流程一般是这么走的: 这个流程走完,通常能找到问题根源。然后再决定是修产品、修用例还是修环境。 很多团队把 E2E 当成一种负担,但在我看来,只要选对工具、把配置和测试数据结构管理好,它其实是 CI 里最可靠的一条守门线。你在实际项目里如果也遇到过 flaky 的老大难问题,不妨顺着这套思路重新排查一遍,多半会有新的发现。>await test.step('打开商品详情页', async () => { await page.goto('/product/1001'); await expect(page.getByRole('heading')).toBeVisible(); });5. 常见问题与排查实录
5.1 用例随机失败?先查这三处
现象 最可能原因 第一排查动作 点击按钮偶发超时 元素被弹窗或遮罩层盖住,动画未结束 打开 trace,看点击前一刻的 DOM 快照 断言文本偶发不一致 数据异步加载完成后才渲染,断言太早 改用 expect(locator).toHaveText,断言自带重试本地过、CI 失败 并行 worker 争抢同一份数据,或环境变量不同 核对 CI 的 E2E_BASE_URL,确认测试环境是否干净ant-modal-mask挡住,点击动作被判为“元素不可点击”。解决办法不是无脑force: true,而是先在测试里等待遮罩消失,或断言弹层关闭后再点下一步。force: true虽然能绕过遮挡,但同时也绕过了真实用户的点击路径,滥用会让 E2E 失去意义。5.2 测试环境被污染、数据冲突、登录态过期
5.3 快速定位问题的实操套路