资讯详情

资讯详情

从零搭建第一个E2E测试:用Playwright守住核心业务链路

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({ // 并发线程数本地调 4CI 建议 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 serverCI 里可以指向临时部署的预览环境完全不用改代码。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-userexample.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-userexample.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-userexample.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/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 20 - run: npm ci # 启动前端和依赖的后端服务 - run: npm run build npm run start:preview # 启动 E2E 测试 - uses: playwright-community/playwright-actionv2 with: command: npx playwright test这里的思路是CI 里先拉代码、装依赖、启动被测试服务的临时实例然后再跑 Playwright。注意数据库我是用一个 MySQL 服务容器模拟的目的是让测试写操作落在隔离库不影响本地数据。5.2 把 E2E 放进合入门禁前先想清楚策略我见过两种极端团队一种是把 E2E 挂在主干上只在代码合并后跑。缺点是出问题了代码已经进主干修复成本变大另一种是把 E2E 挂在每个 PR 上但用例一多跑一次要半小时开发者怨声载道。我现在的推荐策略是分级跑场景用例范围说明PR 阶段仅 P0 核心路径8~15 个用例5 分钟内跑完快速反馈合入主干后P0 P150~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 把用户最核心的那条路堵得死死的。剩下的旁支细节交给接口测试和单元测试去兜底。这种分层测试体系才是一个成熟工程真正该有的样子。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →