首页 / 资讯中心 / 文章详情

端到端测试实战:从e2e profile1到Playwright完整指南

端到端测试实战:从e2e profile1到Playwright完整指南 ★ FEATURED ARTICLE
1. 从“e2e”说起一个被低估的测试体系第一次看到“e2e”这个词很多人会愣一下——它既不像“unit test”那样直白也不像“integration test”那样有明确的边界感。e2e全称 end-to-end端到端。字面意思就是“从这一端到那一端”放到软件研发语境里就是从用户发起操作的那一端到系统返回结果的那一端整条链路完整跑一遍。我做了十多年一线研发和测试见过太多团队在单元测试上写得热火朝天覆盖率报告漂亮得像艺术品结果一上线用户点两下就崩了。为什么因为单元测试只验证了“零件”没问题但零件装成整机之后能不能跑那是另一回事。e2e 测试要解决的恰恰就是这个“整机能不能跑”的问题。这篇文章适合谁看如果你是刚接触自动化测试的开发者或者正在为团队搭建 e2e 测试体系的技术负责人又或者你只是好奇“e2e profile1”这个最近频繁出现在技术圈的热词到底在说什么那接下来的内容应该能给你一些可以直接抄作业的东西。我会从设计思路、核心细节、实操过程到问题排查把 e2e 测试这件事掰开揉碎讲清楚。2. e2e 测试的整体设计与思路拆解2.1 为什么单元测试和集成测试不够用先讲一个我亲身经历的事。早年间我参与过一个电商项目单元测试覆盖率到了 85%集成测试也跑得七七八八团队信心满满地上了预发布环境。结果呢用户从商品详情页点击“加入购物车”购物车图标上的数字确实变了但点进购物车一看商品数量是 0。问题出在哪前端调用的接口返回了成功但后端在写入购物车时因为某个字段的序列化格式不一致实际写入了一条空记录。单元测试里前端 mock 了接口返回后端 mock 了数据库写入两边各自都“正确”但拼在一起就是错的。这就是 e2e 测试存在的根本理由它不关心你内部怎么实现只关心用户看到的结果对不对。单元测试验证函数逻辑集成测试验证模块间接口而 e2e 测试验证的是“用户完成一个完整任务”的全流程。三者是互补关系不是替代关系。从测试金字塔的角度看e2e 测试位于最顶端数量应该最少但价值密度最高。因为它模拟的是真实用户行为跑一次 e2e 测试的成本时间、资源、维护远高于单元测试但一旦跑通它给你的信心也是单元测试给不了的。2.2 e2e 测试的核心设计原则设计 e2e 测试体系我总结下来有几个原则是必须遵守的否则很容易做成“看起来很美但没人维护”的摆设。第一以用户旅程为单位不以页面为单位。很多新手写 e2e 测试喜欢一个页面写一个用例比如“打开登录页输入账号密码点击登录断言跳转到首页”。这没错但不够。真正的 e2e 应该覆盖完整的用户旅程比如“新用户注册→浏览商品→加入购物车→下单→支付→查看订单状态”。这条链路里涉及多个页面、多个服务、多个数据状态变化只有完整跑通才能发现那些跨页面的问题。第二测试数据要独立且可预测。e2e 测试最怕的就是数据污染。你跑了一次下单流程数据库里多了一条订单下次再跑可能因为库存不足或者优惠券已被使用而失败。所以每个 e2e 用例都应该有自己独立的测试数据跑完之后要么清理要么用全新的数据。我通常的做法是给 e2e 测试专门准备一套测试账号和测试商品每次跑之前重置数据状态。第三断言要面向用户可见的结果。不要断言“接口返回了 200”而要断言“页面上出现了‘下单成功’的提示”。不要断言“数据库里多了一条记录”而要断言“订单列表里能看到刚才下的单”。e2e 测试的断言越接近用户感知越能发现真实问题。第四稳定性优先于覆盖率。e2e 测试最容易被诟病的就是“flaky”——时好时坏。一个不稳定的 e2e 测试比没有 e2e 测试更糟糕因为它会消耗团队对测试的信任。所以宁可少写几个用例也要保证每个用例都是稳定的。遇到不稳定的用例要么修复要么删掉不要留着“偶尔跑一下”。2.3 工具选型为什么是 Playwright 而不是 Selenium说到 e2e 测试工具Selenium 是绕不开的老牌选手但这两年我基本全面转向了 Playwright。原因很简单Playwright 的自动等待机制和网络拦截能力让 e2e 测试的稳定性上了一个台阶。Selenium 时代我们写测试最头疼的就是“元素还没加载出来就去找它”于是满屏的time.sleep(3)或者WebDriverWait。Playwright 内置了智能等待它会自动等待元素可操作、可点击、可见你不需要手动加等待。而且 Playwright 支持直接拦截网络请求可以轻松 mock 第三方接口这在测试支付、短信验证码等场景时特别有用。当然Selenium 也有它的优势比如生态更成熟、支持的语言和浏览器更多。如果你的团队已经有大量的 Selenium 测试资产没必要全部重写。但如果是新项目我建议直接上 Playwright。下面是一个简单的对比维度SeleniumPlaywright自动等待需要手动实现内置智能等待网络拦截需要借助代理原生支持多浏览器支持广泛支持 Chromium/Firefox/WebKit执行速度较慢较快调试体验一般有 Trace Viewer学习曲线较陡较平缓2.4 e2e profile1 到底是什么最近“e2e profile1”这个词在技术社区里出现得越来越频繁。我查了一些资料也结合自己的理解这里说一下我的看法。所谓 profile在测试语境里通常指的是一组配置或者一个测试套件的集合。e2e profile1 很可能是指“第一套端到端测试配置方案”或者某个工具比如 Playwright里的一个配置文件名。在 Playwright 里你可以通过--project参数指定不同的测试配置每个配置可以有不同的浏览器、不同的视口大小、不同的环境变量。比如你可以定义profile1为“桌面端 Chrome 环境”profile2为“移动端 Safari 环境”。这样同一套测试代码可以在不同环境下跑验证响应式布局和跨浏览器兼容性。如果你在团队里听到有人说“跑一下 e2e profile1”大概率意思是“用第一套配置跑一遍端到端测试”。具体这套配置里包含什么取决于你们团队的约定。我建议在项目里维护一个e2e-profiles.md文档把每个 profile 的用途、环境、执行命令写清楚避免新人一头雾水。3. 核心细节解析与实操要点3.1 环境准备从零搭建 e2e 测试项目假设你现在要在一个 Web 项目里从零搭建 e2e 测试我以 Playwright 为例把完整步骤走一遍。首先初始化项目。如果你已经有 Node.js 环境直接执行npm init playwrightlatest这个命令会引导你完成一系列选择用 TypeScript 还是 JavaScript、测试文件放在哪个目录、是否添加 GitHub Actions 工作流。我建议选 TypeScript因为类型提示能帮你少犯很多低级错误。测试目录我习惯用e2e和单元测试的__tests__区分开。安装完成后你会得到一个playwright.config.ts文件。这个文件是整个 e2e 测试体系的核心配置我通常会做以下调整import { defineConfig, devices } from playwright/test; export default defineConfig({ testDir: ./e2e, timeout: 30000, retries: 1, workers: 4, reporter: [[html], [list]], use: { baseURL: http://localhost:3000, trace: on-first-retry, screenshot: only-on-failure, video: retain-on-failure, }, projects: [ { name: profile1, use: { ...devices[Desktop Chrome] }, }, { name: profile2, use: { ...devices[iPhone 13] }, }, ], webServer: { command: npm run start, port: 3000, reuseExistingServer: !process.env.CI, }, });这里有几个关键点解释一下。retries: 1表示失败的用例自动重试一次这能过滤掉大部分因为网络抖动导致的偶发失败。trace: on-first-retry表示第一次重试时记录完整的执行轨迹方便事后排查。webServer配置让 Playwright 自动启动你的应用不用手动开终端跑npm run start。3.2 编写第一个 e2e 用例登录流程环境搭好了来写第一个用例。登录是几乎所有系统都有的功能也是 e2e 测试的经典入门场景。import { test, expect } from playwright/test; test.describe(用户登录, () { test(使用正确的账号密码可以登录成功, async ({ page }) { await page.goto(/login); await page.getByLabel(用户名).fill(testuser); await page.getByLabel(密码).fill(testpass123); await page.getByRole(button, { name: 登录 }).click(); await expect(page.getByText(欢迎回来)).toBeVisible(); await expect(page).toHaveURL(/dashboard); }); test(使用错误的密码会提示错误信息, async ({ page }) { await page.goto(/login); await page.getByLabel(用户名).fill(testuser); await page.getByLabel(密码).fill(wrongpass); await page.getByRole(button, { name: 登录 }).click(); await expect(page.getByText(用户名或密码错误)).toBeVisible(); await expect(page).toHaveURL(/login); }); });这段代码看起来很简单但里面有几个细节值得说。getByLabel和getByRole是 Playwright 推荐的定位方式它们基于可访问性属性比 CSS 选择器更稳定。如果你的页面没有正确设置 label 和 role测试会失败这反过来也促使你写出更规范的 HTML。await expect(page.getByText(欢迎回来)).toBeVisible()这行断言Playwright 会自动等待这个文本出现默认超时 5 秒。如果 5 秒内没出现测试失败。你不需要写waitForSelector或者sleep。3.3 处理测试数据注册、登录、清理登录用例跑通了但你会发现一个问题testuser这个账号必须提前存在。如果测试环境重置了或者换了一台机器测试就会失败。所以 e2e 测试必须自己管理测试数据。我的做法是在测试开始前通过 API 创建测试账号测试结束后删除。Playwright 提供了request上下文可以直接发 HTTP 请求test.beforeAll(async ({ request }) { await request.post(/api/test/create-user, { data: { username: testuser, password: testpass123 }, }); }); test.afterAll(async ({ request }) { await request.delete(/api/test/delete-user, { data: { username: testuser }, }); });这里的关键是你的应用需要提供一套测试专用的 API只在测试环境启用。这套 API 可以绕过正常的业务逻辑直接操作数据库。比如创建用户时不需要走邮箱验证、短信验证码这些流程。很多团队会担心“测试 API 会不会有安全风险”我的经验是只要确保这套 API 只在测试环境部署并且有 IP 白名单或者密钥保护风险是可控的。3.4 网络拦截与 Mock处理第三方依赖e2e 测试最头疼的场景之一就是依赖第三方服务。比如支付、短信、地图、天气。这些服务在测试环境里要么不可用要么调用一次要花钱。这时候就需要网络拦截。Playwright 的page.route可以拦截指定的请求返回自定义的响应test(下单时支付成功, async ({ page }) { await page.route(**/api/payment/**, async (route) { await route.fulfill({ status: 200, contentType: application/json, body: JSON.stringify({ success: true, transactionId: mock-txn-001 }), }); }); await page.goto(/checkout); await page.getByRole(button, { name: 确认支付 }).click(); await expect(page.getByText(支付成功)).toBeVisible(); });这样测试就不依赖真实的支付网关跑起来又快又稳。但要注意mock 的响应格式必须和真实接口一致否则测试通过了线上还是可能出问题。我通常会把真实接口的响应保存下来作为 mock 数据的模板。3.5 注意事项与实操心得这里分享几个我踩过坑之后总结的经验。注意不要用page.waitForTimeout()这是硬等待会让测试变慢且不稳定。Playwright 的自动等待已经覆盖了绝大多数场景如果确实需要等待某个条件用page.waitForFunction()或者expect().toPass()。注意测试用例之间必须相互独立。不要写“用例 A 创建了数据用例 B 依赖这个数据”。Playwright 默认并行执行用例执行顺序是不确定的。每个用例都应该自己准备数据、自己清理数据。还有一个心得给测试用例打标签。比如smoke表示冒烟测试regression表示回归测试slow表示耗时较长的测试。这样在 CI 里可以灵活选择跑哪些。Playwright 支持--grep参数过滤npx playwright test --grep smoke4. 实操过程与核心环节实现4.1 完整用户旅程从注册到下单前面讲的都是单个功能的测试现在来看一个完整的用户旅程。这是 e2e 测试最有价值的地方——验证跨页面、跨服务的完整链路。假设我们有一个电商系统用户旅程是注册→登录→浏览商品→加入购物车→结算→支付→查看订单。这条链路涉及用户服务、商品服务、购物车服务、订单服务、支付服务。任何一个环节出问题用户都会感知到。test(新用户完整下单流程, async ({ page, request }) { const username user_${Date.now()}; const password Test123456; // 1. 注册 await page.goto(/register); await page.getByLabel(用户名).fill(username); await page.getByLabel(密码).fill(password); await page.getByLabel(确认密码).fill(password); await page.getByRole(button, { name: 注册 }).click(); await expect(page.getByText(注册成功)).toBeVisible(); // 2. 登录 await page.goto(/login); await page.getByLabel(用户名).fill(username); await page.getByLabel(密码).fill(password); await page.getByRole(button, { name: 登录 }).click(); await expect(page).toHaveURL(/dashboard); // 3. 浏览商品 await page.goto(/products); await page.getByText(测试商品A).click(); await expect(page.getByText(商品详情)).toBeVisible(); // 4. 加入购物车 await page.getByRole(button, { name: 加入购物车 }).click(); await expect(page.getByText(已加入购物车)).toBeVisible(); // 5. 结算 await page.goto(/cart); await page.getByRole(button, { name: 去结算 }).click(); await expect(page).toHaveURL(/checkout); // 6. 支付mock 支付网关 await page.route(**/api/payment/**, async (route) { await route.fulfill({ status: 200, body: JSON.stringify({ success: true }), }); }); await page.getByRole(button, { name: 确认支付 }).click(); await expect(page.getByText(支付成功)).toBeVisible(); // 7. 查看订单 await page.goto(/orders); await expect(page.getByText(测试商品A)).toBeVisible(); await expect(page.getByText(已支付)).toBeVisible(); // 清理删除测试用户 await request.delete(/api/test/delete-user, { data: { username }, }); });这个用例跑一次大概需要 15-30 秒但它覆盖了 7 个关键步骤涉及 5 个后端服务。如果这条链路能稳定跑通说明系统的主流程是健康的。4.2 参数化与数据驱动上面的用例里商品名是写死的“测试商品A”。如果我想测试多个商品或者测试不同品类的商品就需要参数化。const testCases [ { product: 测试商品A, category: 电子产品 }, { product: 测试商品B, category: 服装 }, { product: 测试商品C, category: 食品 }, ]; for (const { product, category } of testCases) { test(下单流程 - ${category} - ${product}, async ({ page }) { await page.goto(/products); await page.getByText(product).click(); await page.getByRole(button, { name: 加入购物车 }).click(); await expect(page.getByText(已加入购物车)).toBeVisible(); }); }这样三个商品会生成三个独立的测试用例并行执行。如果某个商品有问题能快速定位到具体是哪个品类。4.3 CI 集成让 e2e 测试自动跑起来e2e 测试如果只在本地跑价值会大打折扣。必须集成到 CI 里每次代码提交或者合并请求时自动执行。以 GitHub Actions 为例配置文件大概长这样name: E2E Tests on: push: branches: [main] pull_request: branches: [main] jobs: e2e: runs-on: ubuntu-latest 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 --projectprofile1 - uses: actions/upload-artifactv4 if: failure() with: name: playwright-report path: playwright-report/这里有几个关键点。npx playwright install --with-deps会安装浏览器和系统依赖这一步在 CI 环境里必须做。--projectprofile1指定只跑桌面端 Chrome 的测试移动端的可以放在另一个 job 里或者定时跑。失败时上传playwright-report里面包含了截图、视频和 trace方便排查问题。4.4 执行策略什么时候跑哪些测试e2e 测试跑得慢不可能每次提交都全量跑。我的策略是分层执行触发时机执行范围预计耗时本地开发当前修改相关的用例1-2 分钟合并请求smoke 标签的用例5-10 分钟合并到主分支全量用例30-60 分钟每日定时全量用例 跨浏览器60-90 分钟本地开发时可以用--grep只跑相关的用例。比如你改了购物车逻辑就只跑购物车相关的npx playwright test --grep 购物车合并请求时跑冒烟测试确保主流程没断。合并到主分支后跑全量确保没有遗漏。每日定时跑跨浏览器测试确保兼容性。5. 常见问题与排查技巧实录5.1 e2e 测试不稳定怎么办这是被问得最多的问题。e2e 测试跑十次总有一两次失败但手动重跑又过了。这种“flaky”问题怎么排查首先看失败时的截图和 trace。Playwright 的 Trace Viewer 非常强大可以回放整个测试过程看到每一步的 DOM 快照、网络请求、控制台日志。我通常先看失败的那一步页面上到底是什么状态。常见的 flaky 原因有这么几类第一类等待不足。虽然 Playwright 有自动等待但有些场景它判断不了。比如你点击一个按钮按钮触发了异步请求请求返回后页面才更新。Playwright 看到按钮可点击就点了但页面还没更新断言就失败了。解决办法是用expect().toPass()包裹断言让它自动重试await expect(async () { const text await page.getByTestId(order-status).textContent(); expect(text).toBe(已支付); }).toPass({ timeout: 10000 });第二类测试数据冲突。两个用例同时跑都去操作同一条数据互相干扰。解决办法是给每个用例生成唯一的数据标识比如用时间戳或者 UUID。第三类动画和过渡效果。页面上的动画可能导致元素位置变化点击落空。可以在配置里禁用动画use: { launchOptions: { args: [--disable-animations], }, },或者在 CSS 里加* { transition: none !important; animation: none !important; }只在测试环境生效。5.2 元素定位不到怎么排查“元素找不到”是 e2e 测试的另一大痛点。Playwright 报错会说“locator resolved to 0 elements”意思是根据你的选择器页面上找不到匹配的元素。排查步骤我一般是这样打开 Trace Viewer看失败那一刻的页面快照。元素到底在不在如果在是不是被遮挡了如果不在是不是页面还没加载出来检查选择器。getByText(登录)可能匹配到多个元素比如按钮上的“登录”和标题里的“登录”。这时候需要用更精确的选择器比如getByRole(button, { name: 登录 })。检查 iframe。如果元素在 iframe 里需要先page.frameLocator()切换到对应的 frame。检查 Shadow DOM。如果元素在 Shadow DOM 里Playwright 默认能穿透但如果 Shadow DOM 是关闭的closed mode就找不到。这种情况需要和开发协商给测试留一个入口。我习惯给关键元素加>button>await page.getByTestId(submit-order).click();>export default defineConfig({ workers: process.env.CI ? 4 : 2, });CI 环境给 4 个 worker本地给 2 个避免本地跑测试时电脑卡死。复用登录状态是另一个大招。如果每个用例都要走一遍登录流程那太浪费时间了。Playwright 支持保存登录后的 storageState后续用例直接复用// 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.getByLabel(用户名).fill(testuser); await page.getByLabel(密码).fill(testpass123); await page.getByRole(button, { name: 登录 }).click(); await page.waitForURL(/dashboard); await page.context().storageState({ path: e2e/.auth/user.json }); await browser.close(); } export default globalSetup;然后在配置里引用use: { storageState: e2e/.auth/user.json, },这样除了登录相关的用例其他用例都跳过登录步骤直接以登录状态开始。我实测下来一个 50 个用例的套件复用登录状态能省 3-5 分钟。减少不必要的等待也很重要。检查你的代码里有没有waitForTimeout全部替换成基于条件的等待。检查有没有networkidle等待这个等待会等到所有网络请求都完成但现代应用经常有轮询请求networkidle可能永远等不到。用具体的元素出现作为等待条件更可靠。5.4 常见问题速查表问题现象可能原因排查方法解决方案元素找不到页面未加载完看 Trace 快照用 expect 自动等待元素找不到选择器不唯一看匹配数量用 getByRole/getByTestId点击无效元素被遮挡看截图滚动到元素或关闭弹窗断言失败数据未更新看网络请求用 toPass 重试测试超时接口响应慢看网络面板增加超时或 mock 接口偶发失败数据冲突看测试数据用唯一标识隔离数据本地通过 CI 失败环境差异看 CI 日志统一 Node 版本和依赖5.5 独家避坑技巧最后分享几个不太常见但很实用的技巧。技巧一用test.step组织用例。一个长的 e2e 用例如果失败了你很难一眼看出是哪一步出的问题。用test.step把每个阶段包起来报告里会清晰展示每一步的耗时和状态await test.step(用户登录, async () { await page.goto(/login); // ... }); await test.step(添加商品到购物车, async () { // ... });技巧二给测试加test.slow()。有些用例天生就慢比如涉及文件上传或者复杂计算的。用test.slow()标记Playwright 会把超时时间放宽三倍避免因为超时导致误报。技巧三定期清理测试数据。e2e 测试跑多了测试环境里会积累大量垃圾数据。我通常会在 CI 里加一个定时任务每天凌晨清理一次测试数据。或者用独立的测试数据库每次跑测试前重建。技巧四关注测试执行时间趋势。如果某个用例的执行时间突然变长往往意味着性能退化。可以在 CI 里记录每个用例的耗时生成趋势图。我见过一个案例某个接口的响应时间从 200ms 涨到了 2s单元测试和集成测试都没发现因为它们的 mock 数据量很小。e2e 测试用真实数据跑暴露了这个问题。技巧五不要追求 100% 的 e2e 覆盖率。e2e 测试的成本很高维护成本更高。把 e2e 测试集中在核心用户旅程上比如注册登录、下单支付、搜索浏览。边缘功能和异常分支用单元测试和集成测试覆盖就够了。我通常建议 e2e 用例数量控制在 30-50 个覆盖 5-8 条核心链路。6. 从 e2e profile1 看测试体系的演进回到“e2e profile1”这个热词。它之所以被频繁讨论背后反映的是团队对 e2e 测试规范化的需求。早期大家写 e2e 测试各写各的有人用 Selenium有人用 Cypress有人用 Playwright配置五花八门。后来大家意识到需要一套统一的 profile让所有人按照同样的标准写测试、跑测试。profile1 可以理解为“第一套标准配置”。它定义了用什么浏览器、什么视口、什么超时时间、什么重试策略、什么报告格式。团队里所有人都基于这套配置写用例保证一致性。后续如果有新的需求比如要支持移动端就加一个 profile2要支持暗黑模式就加一个 profile3。这种 profile 化的思路其实和 Docker 的镜像分层、Kubernetes 的配置管理是一个道理把可变的部分抽象成配置把不变的部分固化成标准。这样既能保证一致性又能灵活扩展。我在团队里推行 e2e 测试时第一步就是定 profile。先和团队一起确定一套基础配置写进playwright.config.ts然后所有新用例都必须基于这套配置。运行命令也统一成npx playwright test --projectprofile1新人来了看文档就能上手。这套做法跑了一年多最大的感受是e2e 测试的维护成本大幅下降。以前每个人写的测试风格不一样排查问题要花很多时间理解别人的代码。现在大家都用同样的定位方式、同样的断言风格、同样的数据管理策略代码可读性好了很多。如果你正在搭建 e2e 测试体系我的建议是不要一上来就追求大而全。先定一套 profile写 5-10 个核心用例跑通 CI 流程。然后根据实际遇到的问题逐步完善配置和规范。e2e 测试是一个长期投入的事情持续维护比一次性建设更重要。我个人在实际操作中的体会是e2e 测试最大的价值不是发现 bug而是给团队信心。当你改了一行代码CI 告诉你“核心流程都跑通了”那种踏实感是单元测试给不了的。当然前提是你的 e2e 测试足够稳定、足够快、足够可信。这需要时间也需要耐心但绝对值得。
阅读完成 · 觉得有帮助?
咨询建站