
1. 从“e2e”说起端到端测试到底在测什么第一次看到“e2e”这个词很多人会愣一下——它其实是 end-to-end 的缩写中文叫“端到端”。在软件研发领域e2e 测试指的是从用户视角出发把整个系统当作一个黑盒模拟真实用户操作路径验证从入口到出口的完整链路是否走得通。它不关心某个函数内部怎么实现只关心“用户点了按钮之后到底能不能完成他想完成的事”。我做了十多年一线研发和测试见过太多团队在单元测试覆盖率 90% 的情况下上线依然翻车。原因很简单每个零件都合格组装起来未必能跑。e2e 测试就是那个“整车路试”环节。它解决的核心问题是系统集成后的真实行为是否符合业务预期。适合谁看前端、后端、测试、DevOps甚至产品经理都该懂一点——因为 e2e 用例本质上就是可执行的用户故事。而最近热词里出现的“e2e profile1”则指向一个更具体的实践方向用不同的 profile配置档来管理 e2e 测试的运行环境、浏览器矩阵、数据准备策略等。比如 profile1 可能代表“本地 Chrome 快速回归”profile2 代表“CI 上多浏览器全量跑”。这套玩法在 Playwright、Cypress 等现代框架里越来越常见。下面我就把 e2e 从设计思路到落地实操再到踩坑排查完整拆一遍。2. 整体设计与思路拆解为什么 e2e 不能随便写2.1 e2e 的定位金字塔尖的少数派测试金字塔理论大家都听过底层单元测试最多中间集成测试次之顶层 e2e 最少。但实际工作中很多人把金字塔倒过来做——写了几百条 e2e跑一次要半小时失败率还高得离谱。这是典型的定位错误。e2e 测试的成本极高启动浏览器、准备数据、等待网络、清理环境每一步都比单元测试慢几个数量级。所以它的设计原则应该是少而精只覆盖最核心的业务链路。什么叫核心链路我通常用三个问题来筛选这条路径断了用户会不会直接流失这条路径涉及三个以上模块的协作吗这条路径的 bug 用单元测试很难发现吗三个都答“是”才值得写 e2e。比如电商系统的“登录→搜索→加购→下单→支付”就是典型核心链路而“修改头像后图片圆角是否生效”这种交给组件测试就够了。2.2 方案选型Playwright、Cypress 还是 Selenium选框架这件事我踩过的坑比吃过的饭还多。早期用 Selenium灵活但什么都得自己搭后来 Cypress 火了开发体验好但跨域和多标签页支持有限现在团队主力用 Playwright原因很实在维度SeleniumCypressPlaywright多浏览器支持强中强自动等待弱强强多标签页/多窗口支持弱强网络拦截需额外库强强并行执行需自建付费原生语言支持多JS/TS多Playwright 的原生并行和自动等待机制能把 e2e 的 flaky不稳定问题压到最低。所谓 flaky就是同一条用例有时过有时不过这是 e2e 最大的敌人。Cypress 的自动等待做得也不错但多标签页场景确实吃亏。Selenium 适合维护老项目新项目我基本不推荐。2.3 profile 机制一套用例多种跑法“e2e profile1”这个热词背后的思路很实用。所谓 profile就是把运行配置抽出来让同一套用例在不同场景下复用。常见的 profile 维度包括环境local、staging、prod-readonly浏览器chromium、firefox、webkit数据策略mock、seed、real执行范围smoke、full、regression我一般会在项目根目录放一个e2e.config.ts用环境变量切换 profile。比如E2E_PROFILEsmoke时只跑标记为smoke的用例且只跑 chromiumE2E_PROFILEfull时跑全部用例、三个浏览器、开启视频录制。这样本地开发快速反馈CI 上全量验证互不干扰。提示profile 不要设计得太复杂超过五个就会没人记得住。我建议从三个起步local、ci-smoke、ci-full。3. 核心细节解析与实操要点把 e2e 写稳的关键3.1 选择器策略别让 UI 改版毁掉你的用例e2e 用例最脆弱的地方就是选择器。今天用.btn-primary明天设计师改成.button-main用例全红。我的经验是优先用面向测试的专用属性比如>button>await page.getByTestId(submit-order).click();如果团队不愿意加>const responsePromise page.waitForResponse( res res.url().includes(/api/order) res.status() 200 ); await page.getByTestId(submit-order).click(); await responsePromise;这样比等元素出现更可靠因为元素可能先渲染出来但数据还没回来。我踩过的坑一个列表页骨架屏先出现用例以为加载完了就去断言数据结果全挂。后来改成等接口响应稳如老狗。3.3 数据准备与清理别让用例互相污染e2e 用例之间必须相互独立这是铁律。如果用例 A 创建了一条订单用例 B 依赖这条订单存在那并行执行时必然出问题。我的做法是每个用例自己造数据通过 API 直接创建比走 UI 快十倍。用唯一标识比如时间戳或 UUID 作为用户名后缀避免冲突。用完即删在afterEach里清理或者用测试数据库定期重置。test.beforeEach(async ({ request }) { const uniqueId Date.now(); await request.post(/api/test/seed, { data: { userId: user_${uniqueId} } }); });注意如果测试环境的数据清理不干净跑几天后数据库里全是垃圾数据查询变慢用例超时。我见过一个团队因此把 e2e 套件从 10 分钟拖到 40 分钟。3.4 断言策略断言用户可见的结果e2e 的断言应该聚焦在用户能感知的东西上页面文字、元素可见性、URL 变化、下载文件内容。不要断言内部状态、CSS 具体值、DOM 结构。比如好await expect(page.getByText(订单提交成功)).toBeVisible();差expect(await page.locator(.order-status).innerHTML()).toBe(span classsuccess1/span);前者改样式不影响后者改个 class 就崩。断言要少而准一条用例三到五个断言足够太多会让失败原因难以定位。4. 实操过程与核心环节实现从零搭一套 e2e4.1 环境初始化与依赖安装假设你用的是 Node.js 项目先装 Playwrightnpm init playwrightlatest这个命令会引导你选择语言TypeScript 推荐、测试目录、是否加 GitHub Actions。装完后目录结构大致是e2e/ tests/ example.spec.ts playwright.config.ts然后装浏览器npx playwright install如果 CI 环境是 Linux还需要装系统依赖npx playwright install-deps这一步经常被忽略导致 CI 上浏览器起不来。我建议把这两条命令都写进 CI 脚本。4.2 配置文件与 profile 实现playwright.config.ts是核心。下面是我常用的配置骨架支持 profile 切换import { defineConfig, devices } from playwright/test; const profile process.env.E2E_PROFILE || local; const profileConfig { local: { projects: [{ name: chromium, use: { ...devices[Desktop Chrome] } }], grep: /smoke|full/, retries: 0, }, ci-smoke: { projects: [{ name: chromium, use: { ...devices[Desktop Chrome] } }], grep: /smoke/, retries: 1, }, ci-full: { projects: [ { name: chromium, use: { ...devices[Desktop Chrome] } }, { name: firefox, use: { ...devices[Desktop Firefox] } }, { name: webkit, use: { ...devices[Desktop Safari] } }, ], grep: /smoke|full/, retries: 2, }, }; export default defineConfig({ testDir: ./e2e/tests, timeout: 30000, retries: profileConfig[profile].retries, grep: profileConfig[profile].grep, use: { baseURL: process.env.BASE_URL || http://localhost:3000, trace: on-first-retry, video: profile ci-full ? on : off, screenshot: only-on-failure, }, projects: profileConfig[profile].projects, });这里有几个关键点trace: on-first-retry会在第一次重试时记录完整追踪方便排查 flakyvideo只在全量跑时开省磁盘grep用标签筛选用例实现 smoke 和 full 的区分。4.3 编写第一条核心链路用例以登录为例写一条完整的 e2eimport { test, expect } from playwright/test; test.describe(用户登录 smoke, () { test(正确账号密码可以登录成功, async ({ page }) { await page.goto(/login); await page.getByTestId(username).fill(testuser); await page.getByTestId(password).fill(Test123456); await page.getByTestId(login-button).click(); await expect(page).toHaveURL(/\/dashboard/); await expect(page.getByTestId(welcome-message)).toContainText(欢迎); }); test(错误密码提示错误信息, async ({ page }) { await page.goto(/login); await page.getByTestId(username).fill(testuser); await page.getByTestId(password).fill(wrong-password); await page.getByTestId(login-button).click(); await expect(page.getByTestId(error-message)).toContainText(用户名或密码错误); }); });注意smoke标签写在describe标题里这样grep: /smoke/就能筛选到。每条用例都从page.goto开始不依赖上一条的状态。4.4 并行执行与分片Playwright 默认按文件并行。如果用例多可以用--shard分片npx playwright test --shard1/4 npx playwright test --shard2/4在 CI 上开四个 job每个跑四分之一总时间降到四分之一。但要注意分片是按文件分的所以单个文件不要写太多用例否则分片不均。我一般每个文件控制在 5 到 10 条用例。4.5 CI 集成与报告GitHub Actions 的配置示例name: E2E on: [push] jobs: test: runs-on: ubuntu-latest strategy: matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 20 - run: npm ci - run: npx playwright install --with-deps - run: npx playwright test --shard${{ matrix.shard }}/4 env: E2E_PROFILE: ci-full - uses: actions/upload-artifactv4 if: failure() with: name: playwright-report-${{ matrix.shard }} path: playwright-report/失败时上传报告里面包含 trace、视频、截图排查效率翻倍。我强烈建议把 trace 保留下来它就像 e2e 的“行车记录仪”能回放每一步操作和网络请求。5. 常见问题与排查技巧实录5.1 flaky 用例的根因分析flaky 是 e2e 的头号公敌。我总结的排查顺序是看 trace回放失败步骤看当时页面状态。查网络是否有接口偶发超时或返回顺序不一致。查数据是否用例间数据冲突。查等待是否缺少显式等待。查环境CI 机器性能波动导致超时。常见根因和解决对照表现象可能原因解决方案元素找不到页面未加载完用 getByRole 自动等待点击无效元素被遮挡先等遮挡层消失断言失败数据未返回waitForResponse偶发超时CI 资源不足增加 timeout 或重试用例互扰数据未隔离唯一标识 清理5.2 登录态复用别每条用例都走登录如果每条用例都从登录开始套件会慢得无法忍受。Playwright 提供 storageState 机制登录一次保存 cookie后续用例直接复用// global-setup.ts import { chromium } from playwright/test; async function globalSetup() { const browser await chromium.launch(); const page await browser.newPage(); await page.goto(/login); await page.getByTestId(username).fill(testuser); await page.getByTestId(password).fill(Test123456); await page.getByTestId(login-button).click(); await page.waitForURL(/dashboard/); await page.context().storageState({ path: e2e/.auth/user.json }); await browser.close(); } export default globalSetup;然后在 config 里引用storageState: e2e/.auth/user.json。这样只有登录相关的用例才真正走登录流程其他用例秒开。我实测下来套件时间能砍掉一半以上。5.3 文件上传与下载的处理文件上传用setInputFilesawait page.getByTestId(file-input).setInputFiles(e2e/fixtures/sample.pdf);下载则要监听 download 事件const downloadPromise page.waitForEvent(download); await page.getByTestId(export-button).click(); const download await downloadPromise; await download.saveAs(e2e/downloads/export.csv);注意下载路径要提前创建否则会报错。另外 CI 上无头模式下下载也能正常工作不用担心。5.4 跨域与 iframe 场景跨域是 e2e 的老大难。Playwright 对多域名支持较好但要注意baseURL只作用于相对路径。iframe 里的元素需要先定位 frameconst frame page.frameLocator(#payment-iframe); await frame.getByTestId(card-number).fill(4242424242424242);如果 iframe 是动态加载的先等 frame 出现再操作。我遇到过支付 iframe 加载慢导致用例失败加个waitForSelector就好了。5.5 独家避坑心得说几个文档里不会写的不要在用例里 console.log 敏感信息CI 日志可能被多人看到。测试账号要有独立权限别用管理员账号跑所有用例万一误删数据就是事故。定期清理测试数据我一般加一个定时任务每天凌晨清空测试库的临时数据。用例命名要能一眼看出意图比如“登录失败-密码错误”比“test login 2”强一百倍。失败截图要保留上下文Playwright 默认截全屏但有时需要滚动到元素再截可以用element.screenshot()。6. 影响范围与扩展思考e2e 不止于测试e2e 做深了价值远不止“验证功能”。它其实是一份可执行的系统文档——新同学看 e2e 用例比看需求文档快得多。它也是监控的补充——定时跑 smoke 用例能在用户报障前发现问题。我甚至见过团队用 e2e 做竞品对比自动跑一遍核心流程记录耗时和成功率。profile 机制还能进一步扩展比如加一个profileperf在跑 e2e 的同时收集性能指标加一个profilea11y用 axe-core 做无障碍扫描。一套用例多种用途这才是 e2e 的完全体。最后分享一个小技巧把 e2e 用例按业务域分目录比如e2e/tests/order/、e2e/tests/user/每个目录一个fixtures.ts放该域的公共操作。这样用例可读性和可维护性都会好很多。我团队现在维护着三百多条 e2e靠的就是这套结构两年下来依然清爽。