ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

端到端自动化测试实战:从工具选型到CI接入的完整复盘

端到端自动化测试实战:从工具选型到CI接入的完整复盘

上个月,我们电商后台又一个版本因为一条订单状态流转e2e用例没覆盖,生产环境直接沉了。我盯着监控大屏,脑子里只有一个念头:如果发版前让端到端测试(e2e)完整跑过那条核心用户路径,这个事故本来可以完全避免。很多人把e2e当成“有时间再补”的锦上添花,但真实项目里,它恰恰是最该提前铺的生命线。这篇文字是我在多套业务系统里落地端到端自动化测试的完整复盘,包含工具选型、用例设计、数据隔离、稳定性优化,以及接入CI流水线后真正踩出来的坑。无论你是测试工程师、前端开发还是负责质量保障的技术负责人,只要你正在考虑“我的项目要不要上e2e”,这篇文章应该能帮你少走不少弯路。

1. 先别急着写脚本:端到端测试到底要解决什么问题

1.1 我和e2e的第一次正面交锋

五年前我第一次接触e2e,是在一家做SaaS的公司。当时的任务听起来很简单:“把注册到开通的整个流程用自动化跑通。”我脑子里马上浮现的就是录脚本:打开浏览器,点按钮,填表单,录个回放完事。结果真正动手之后才发现,我录出来的脚本第二天就挂了——因为开发把登录页的按钮文案从“立即注册”改成了“免费试用”。一个按钮文案的改动,就能让十几条用例一夜变红。

这件事成了我的第一个教训:e2e自动化不是“录一遍就完”的脚本,它本质上是在用一个可持续演进的测试系统,替代每一次发版前人工重新点一遍完整流程的重复劳动。如果你只是把它当脚本写,那写100条也是废的;如果你把它当产品来建设,才能尝到甜头。

1.2 端到端测试和单元测试、集成测试的边界在哪里

很多团队不是不想做e2e,而是没想清楚它和单元测试、集成测试的区别。我习惯用一个生产线类比:单元测试像是在车间里单独检查每个齿轮是否合格,集成测试像是检查齿轮和齿轮之间咬合是否顺畅,而端到端测试则是整条生产线真正开动起来,一路看到汽车开出工厂大门。它不是要覆盖所有功能,而是要覆盖“用户真实会走的关键路径”。

为了说清楚边界,我在团队内部经常用下面这个表格做立项沟通:

测试类型验证对象反馈速度稳定要求失败时定位成本
单元测试函数/类/模块行为秒级极高极低,基本一眼看到
集成测试模块间接口与数据流分钟级较高中等,需要看日志
端到端测试用户路径+系统整体行为分钟到小时级容易被环境影响较高,需要截图/视频辅助

从这个表格就能看出一个关键结论:e2e绝对不是覆盖范围越广越好。单测能覆盖的边界,交给单测;接口层面的异常,交给集成测试;e2e只需要聚焦在“一旦出问题会让用户无法完成核心动作”的少数几条链路上。比如电商的加购-下单-支付-查单,后台管理的建流-审批-发布,这些才是e2e的主场。

1.3 什么阶段最适合引入e2e

经常有人问我:“我们的项目才刚起步,现在做e2e会不会太早?”我的经验是:太早和太晚都会痛。太早,页面结构一天一变,写十条用例要维护十条;太晚,核心路径已经堆了无数分支状态,补测试时要处理的历史债足够让你怀疑人生。

我认为最理想的时间点是MVP发布前后,或者说“第一个用户真正开始使用”的时候。判断标准很简单:那条用户主路径已经连续两个迭代没发生结构性变化,同时你开始担心“这次改动会不会把主流程搞挂”。一旦出现这种担忧,就是该铺e2e的时机。别等测试环境都烂成一锅粥再动手,那时候成本会翻倍。

2. 工具选型:我为什么从Selenium换成了Playwright

2.1 Selenium不是不好,只是维护成本超出了我的预期

老实说,Selenium WebDriver在很长一段时间里是E2E自动化的默认答案。我对它也有感情,毕竟最早能跑通的e2e就是靠它。但用了两三年后,我被几件事反复折磨:

第一是环境依赖问题。Selenium需要额外的driver(ChromeDriver、GeckoDriver),浏览器一升级,driver版本对不上,脚本就罢工。CI里更惨,每个新环境都要重新处理一遍driver安装。第二是等待策略写起来太原始。WebDriver的WebDriverWait虽然能用,但一旦页面是异步渲染,你得自己频繁写Expected Condition,代码里全是等待逻辑,测试用例本身反而看不清了。第三是浏览器新特性支持慢。碰到新开Tab、跨域iframe、文件下载这些场景,写起来都很别扭。

2.2 Playwright vs Cypress:四个决定因素

后来我在一个新项目里做了一次完整的选型调研,候选就剩Playwright和Cypress两家。最后选了Playwright,不是Cypress不好,而是四个维度比较下来更符合我们的场景:

对比维度PlaywrightCypress
多浏览器支持Chromium / Firefox / WebKit 原生支持主要支持Chrome全家桶,Firefox体验一般
多标签页与iframe原生支持,上下文控制灵活对多标签页支持较弱,历史上有不少限制
网络拦截与模拟内置route拦截,可精细控制请求有cy.intercept,但某些场景不如Playwright顺手
测试语言与生态TypeScript/JavaScript等,和前端栈贴近JavaScript/TypeScript,社区资料更热闹

还有一个决定性因素是我们的后台系统大量使用了iframe嵌套旧版页面,同时要在测试过程中打开新Tab进行外部核验。Cypress在多Tab场景下需要额外插件且容易踩坑,Playwright则是原生支持。加上Playwright的codegen可以快速生成交互代码,调试体验确实好,我们最终定了它。

2.3 我最终落地的技术栈

选型定下来之后,我搭建了一套至今仍在用的组合:Node.js + TypeScript + Playwright + Allure Report。TypeScript让测试代码在编译期就能发现选择器拼写错误之类的问题;Playwright负责驱动浏览器和执行断言;Allure负责把失败原因组织成团队看得懂的报表。

这套组合在写用例时的体感是:不用再担心driver过期,不用再跟浏览器进程勾心斗角,写用例就像在写业务代码一样自然。我建议新项目直接从这个组合起步,不用再重复我当年走Selenium的老路。

3. 从零搭一套可跑的E2E骨架:目录、配置与第一用例

3.1 项目目录与命名规范

搭建E2E项目最容易犯的错,是把所有逻辑都堆在tests目录里,一个文件跑几百行。我的建议是建一个结构清晰的目录,让用例、配置、工具、测试数据各归其位。下面是我在一个中型后台项目里用过的目录骨架:

e2e/ ├── config/ │ ├── e2e.profile1.ts │ ├── e2e.profile2.ts │ └── environments.ts ├── tests/ │ ├── auth.spec.ts │ ├── order.spec.ts │ └── workflow.spec.ts ├── fixtures/ │ ├── users.ts │ └── orders.ts ├── utils/ │ ├── api-helper.ts │ └── mock-services.ts ├── .env ├── playwright.config.ts └── package.json

注意我给配置文件取名叫e2e.profile1.ts、e2e.profile2.ts,这在我们的团队里也算统一交流语言了:一个profile就是一组针对特定运行环境的完整配置集合。profile1一般指冒烟测试配置,只跑核心链路;profile2是全量回归配置,覆盖所有关键业务线。用profile区分环境之后,再也不用在多个配置文件里翻来翻去找该改哪个URL。

3.2 配置管理与环境变量

E2E测试最忌讳的事情之一,就是把环境地址、登录账号、密钥硬编码在测试代码里。原因不用多说:一旦换环境或者账号过期,你要改的就不是一个文件,而是散落各处的字符串。

我的做法是:环境相关配置统一放config目录,每个profile包含baseURL、accountList、featureToggle这些字段;敏感信息(密码、token)放.env,通过process.env读取;运行时通过命令行参数指定使用哪个profile。示例配置长这样:

// config/e2e.profile1.ts export const profile1 = { name: 'smoke', baseURL: process.env.STAGING_BASE_URL!, accounts: { normalUser: { username: process.env.NORMAL_USER_NAME!, password: process.env.NORMAL_USER_PASSWORD!, }, adminUser: { username: process.env.ADMIN_USER_NAME!, password: process.env.ADMIN_USER_PASSWORD!, } }, featureToggle: { mockExternalService: true, skipCaptcha: true, } };

把配置抽出来之后,CI流水线只需要切换--profile=profile1或--profile=profile2,就能在不同环境跑不同范围的用例。这个设计后来救了我很多次——比如线上出问题时我们想对生产环境做只读验证,只需要新加一个profile3,把mockExternalService关掉,再配一个只读账号就行。

3.3 写出第一版可运行的用户主流程用例

有了配置,接下来写第一条真正能跑通全链路的用例。我强烈建议用Page Object模式(POM)来组织页面元素和交互,不要在每个用例里直接写选择器。这样做的好处是:当页面元素变更时,你只需要改一个Page类,而不是几十条用例。

import { test, expect } from '@playwright/test'; test('用户完成从登录到创建订单的完整流程', async ({ page }) => { // 登录 await page.goto(profile1.baseURL); await page.getByRole('textbox', { name: '用户名' }).fill('tester01'); await page.getByRole('textbox', { name: '密码' }).fill('pass1234'); await page.getByRole('button', { name: '登录' }).click(); await expect(page.getByText('工作台')).toBeVisible(); // 进入商品列表 await page.getByRole('link', { name: '商品中心' }).click(); await page.locator('.product-item').filter({ hasText: '智能音箱' }).click(); // 加入购物车并下单 await page.getByRole('button', { name: '加入购物车' }).click(); await page.getByRole('button', { name: '去结算' }).click(); await page.getByRole('button', { name: '提交订单' }).click(); // 断言订单状态 await expect(page.getByText('订单创建成功')).toBeVisible(); await expect(page.locator('.order-status')).toHaveText('待支付'); });

这段代码看着简单,但背后有一个值得反复强调的原则:e2e用例的断言不要只断“按钮点没点穿”,要断“用户关心的结果出现了”。比如上面最后一条断言,等的是“订单状态变成待支付”,而不是等某个接口返回200。这样即使前端改了交互方式,只要业务结果正确,测试依然有效。

3.4 跑起来之后:并发执行与轮询

Playwright默认就会根据workers配置并发执行用例。我一开始图省事,开了8个worker,结果登录接口直接被压到超时。后来学乖了:并发数要根据测试环境的承受能力来定,我们的后台压测下来4个worker最稳,所以playwright.config.ts里写死workers: 4。

另外,如果同一套测试账号被多个worker同时使用,会出现“A用例把B用例的会话挤下线”的灵异现象。最简单粗暴但有效的方案是:每个worker分配独立账号,或者允许多会话登录。具体怎么做我会在下一节数据隔离里展开。

4. 数据准备与状态隔离:e2e翻车的第一高发区

4.1 一条用例跑10次挂5次,多半是数据污染

如果你跑e2e的时候发现“这条用例昨天还好好的,今天无缘无故挂了”,而且挂的时机飘忽不定,那八成不是代码问题,是测试数据被污染了。最常见的情况有三种:一是上一次运行创建的订单残留到了库里,导致查询列表时出现了预期之外的数据;二是全局唯一的手机号/邮箱被上一轮用例注册过,这轮再注册就提示“已存在”;三是用例之间共享了同一个用户状态,A用例改掉了用户昵称,B用例还在按旧昵称断言。

数据污染这东西,最恶心的地方在于它很难稳定复现。偶尔绿、偶尔红,你需要很长的排错时间才能定位到是脏数据。所以我们定了两条硬性规则:用例能自己造的数据绝不共用;用例产生的业务数据尽量在使用后清理或走独立标记。

4.2 用API预置数据,不要全靠UI慢慢填表单

第二个让我吃了大亏的教训是:不要用UI一步步填表单来准备数据。假设你要测试“管理员审批一个流程”,那管理员登录、找到待审批列表、点开详情这些步骤确实需要跑UI,但那个“待审批的流程本身”完全不需要通过UI去创建——直接调后台API造一条数据,让用例从“审批入口”开始,效率高得多,也稳定得多。

Playwright自带requestfixture,我封装了一个api-helper.ts用来做数据预置:

import { request, APIRequestContext } from '@playwright/test'; export async function createOrderViaApi(token: string, productId: string) { const context: APIRequestContext = await request.newContext(); const res = await context.post(`${profile1.baseURL}/api/v1/orders`, { headers: { Authorization: `Bearer ${token}` }, data: { productId, quantity: 1, remark: `e2e-order-${Date.now()}`, }, }); if (!res.ok()) { throw new Error(`创建订单失败:${res.status()} ${await res.text()}`); } return await res.json(); }

为什么用时间戳后缀?因为我们内部约定,所有e2e创建的数据都要带e2e-前缀,方便每天定时任务统一清理。如果你的系统也支持软删除或清理接口,建议把“测试数据生命周期管理”这件事也做成自动化,不然跑一周之后库里全是垃圾数据,测试环境的性能也会被拖累。

4.3 登录态管理的三种方式与取舍

登录态处理得好不好,决定你的e2e用例写得痛不痛苦。我试过三种方案,各自的适用场景完全不一样:

方案做法优点缺点
UI登录每次都用界面输账号密码最直观,接近真实用户慢,且遇到验证码就头疼
API登录+StorageState用接口拿token再注入浏览器状态快,稳,可复用需要额外维护会话获取逻辑
完全Mock会话拦截所有鉴权请求,返回固定用户最快真实度低,漏掉后端鉴权问题

我现在的默认做法是API登录+StorageState:先调登录接口拿token,再通过page.context().storageState()保存为JSON文件,后续用例直接复用。在profile1冒烟场景里,我们把“登录”这一步单独抽成fixture,于是每个用例都从“已经登录后的状态”开始,节省了少则几秒多则十几秒的登录时间。

这段经历也让我理解了为什么网上那些录屏式e2e教程看着简单、落地却总翻车:他们往往把所有时间耗在了登录上,却没仔细考虑数据与状态的隔离。而真实项目里,数据隔离才是e2e稳定性的基石。

5. 稳定性优化:把CI里的红灯从80%降到5%

5.1 不要把固定sleep写进代码里:自动等待的正确姿势

我见过太多人写的e2e代码长这样:

await page.click('#submit'); await page.waitForTimeout(3000); await page.click('#next-step');

waitForTimeout就是测试界的定时炸弹。网络慢一点它过了,网络快一点它没跑满;页面渲染慢一点3秒不够,渲染快一点又白白浪费时间。更糟糕的是,一旦出现偶发失败,读者根本分不清是产品Bug还是断言过早执行。

正确的姿势是利用Playwright的自动等待机制:click、fill、getByText这些操作默认会在动作前等待元素可操作。真正需要手动等待的场景,也是等一个“业务条件”,不是等固定时间:

await expect(page.locator('.order-status')).toHaveText('待支付', { timeout: 15000 });

这条断言的等待逻辑是“轮询直到文本变成待支付或15秒超时”,而不是傻等15秒。把waitForTimeout从代码里删除干净,是我优化e2e稳定性的第一步,效果立竿见影。

5.2 重试与超时的标准设定

E2E用例天然带偶发性,所以重试策略是必要的。但这里有个坑:不是所有用例都能安全重试。比如创建订单用例跑了一半失败,重试时可能再创建一个重复订单。所以在设计用例时要区分“幂等重试”和“不可重试”:可重试的是查询、详情、列表这类只读操作;不可重试的是提交、创建、审批这类写操作。

Playwright配置里可以单独设置重试次数:

// playwright.config.ts export default defineConfig({ retries: process.env.CI ? 2 : 0, timeout: 30000, expect: { timeout: 10000, }, });

本地调试我不开重试,因为挂了就要立刻看到现场;CI里开两次重试,通常能把Flaky问题的红灯消掉大半。留好截图和日志,重试失败时能看清第一次挂在哪。

5.3 外部依赖的隔离:验证码、第三方支付、短信平台

真实业务里最让e2e崩溃的,往往不是我们自己的页面,而是外部依赖。验证码弹出来、短信平台限流、第三方支付接口返回风控,任何一个环节出问题,用例必红。

我们的解决方案是在profile1冒烟环境里把这些外部服务全部mock掉。用Playwright的路由拦截:

await page.route('**/api/verify-captcha', async (route) => { await route.fulfill({ json: { pass: true } }); });

然后支付网关替换为测试桩,短信验证码固定使用万能验证码“123456”。这样一来,e2e验证的是“我们系统的功能链路本身”,而不是第三方服务的稳定性。等需要联调时,切到profile2关掉mock,再去测真实交互。

5.4 截图、视频、trace:失败现场证据怎么留

用例挂了不可怕,可怕的是你不知道它到底挂在哪一步。我刚做e2e时,失败定位基本靠猜——看Console日志、翻数据库,效率极低。后来把Playwright的失败现场采集开全,定位速度直线提升。

use: { screenshot: { mode: 'only-on-failure', fullPage: true }, video: { retainOnFailure: true }, trace: { retainOnFailure: true }, }

这套配置会在用例失败时自动留下三样东西:全屏截图、操作视频、包含网络请求和控制台日志的trace文件。trace甚至可以逐帧回放操作过程,看Playwright在哪一步点击无效、哪个请求返回了500,一眼就能判断是产品Bug还是测试脚本问题。我经常跟同事说:写了e2e不给失败留证据,等于白写。

6. CI接入:从“本地能跑”到“流水线天天跑”的跨越

6.1 在CI里装浏览器依赖的那点事

很多团队的e2e死在从本地迁到CI这一步。本地跑得好好的,到了CI容器里一堆问题:浏览器没装、缺系统库、headless模式下字体缺失导致文字断言失败。Playwright其实提供了一个救命指令:

npx playwright install --with-deps

--with-deps会顺带安装浏览器运行时需要的所有系统依赖,比如libnss3、libatk、libxkbcommon之类。如果CI镜像比较精简,直接用官方提供的Playwright Docker镜像最省事。我踩过一次坑是CI容器里没有中文字体,导致页面渲染出来的中文变成了方块,所有文本断言全挂——后来在Dockerfile里加了一行fonts-noto-cjk解决。

6.2 测试报告如何成为团队同事愿意看的“证据”

E2E报告要是不好看,团队根本不会看,也就失去了拦截Bug的价值。我们用的是Allure,每次CI跑完把报告上传到构建产物,并把失败摘要自动贴在Merge Request的评论里。评论里包含:失败用例名称、失败截图链接、trace入口、以及一段简短的失败原因分类(断言失败/超时/404/500)。

做到这一步之后,最大的变化是:以前开发觉得e2e是测试的事,现在他们看到红色评论会主动来问“我这个改动是不是影响了主流程”。因为e2e拦截到的往往是真实用户路径上的问题,说服力远高于单测失败。

6.3 分级策略:冒烟、回归、全量

最后一个想分享的实践,是E2E用例一定要分级,不要一股脑全塞进CI。我们目前分三级:

  • 冒烟级(profile1):登录、首页加载、一条核心下单链路。每次MR触发,跑完要求全绿,这套控制在10分钟以内。
  • 回归级(profile2):所有核心业务的端到端用例,每天夜间定时跑,跑完汇总报告次日查看。
  • 全量级(profile3):包括边界场景、异常场景在内的所有用例,每周跑一次,作为版本发布前的最后一道闸门。

分级之后CI速度不会因为e2e拖慢,同时核心风险又被牢牢锁住。如果你刚开始推e2e,我建议从冒烟级入手,先让团队感知到“一条主链路每天都是绿的”这件事带来的安全感,再逐步扩大范围。

踩过这么多坑之后,我最大的体感是:e2e不是测试团队的自嗨工具,它是整个研发链路里最能直接体现用户价值的质量防线。它远没有很多人想象得那么难,但也绝不是一个周末就能写好的脚本。先拴住一条最核心的用户路径,再慢慢扩展覆盖范围,这条路径我一直觉得是上手e2e最务实的方式。如果你正准备在自己的项目里落地端到端测试,不妨从今天就开始,用最小的成本搭一条冒烟链路,然后看它能给你带来多少“没出事”的安心。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进