ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

端到端测试实践:从E2E环境搭建到CI稳定运行的完整指南

端到端测试实践:从E2E环境搭建到CI稳定运行的完整指南

前阵子我们把线上商城的核心回归从手工点验换成了自动化e2e,跑了一个季度之后我把它单独拎出来维护,起名就叫 profile1。这篇文章就聊聊e2e这套东西到底是什么、我在搭建过程中做了什么决策、踩了哪些雷,以及最后接入CI之后的实际收益。如果你是测试开发、后端开发,或者团队正在犹豫要不要上端到端测试,这篇文章应该能给你不少可以直接抄作业的细节。

先交代背景。我们的项目是一个典型的“前端页面 + 后端API + 数据库 + 第三方支付”结构的电商系统。之前每次发版前,手工回归至少要两个人花半天时间,从注册走到退款,点一遍主流程。后来业务迭代快,手工回归慢慢变成走过场——点完了,但不敢说一定没问题。于是我们决定把e2e做起来。这里说的e2e,就是端到端测试:从用户视角,通过真实浏览器或真实API调用,把一条完整业务链路从头到尾跑通,验证整个系统在真实部署形态下能不能正常工作。

一开始我以为e2e就是“写一堆自动化脚本替代手工点击”,做到一半才发现完全不是这么回事。它的核心难点在于稳定复现和数据可控。这也是为什么我会单独建一个profile1——把最核心的一条主链路用最严格的方式保护起来,先保证它天天能过,再谈覆盖面。

1. 为什么我要单独建一个Profile1:一次凌晨上线的教训

1.1 事故经过:接口单测全绿,上线还是炸了

这事发生在做e2e之前。那天凌晨我们要上线一个新版本,改动点很小——只是调整了订单状态机的流转规则。后端单测全绿,前端组件测试也全绿,代码评审过了,大家觉得稳了,直接发布。结果第二天一早用户反馈:下单成功之后,支付回调一直不更新订单状态,导致订单卡在“待支付”状态,仓库那边也看不到新订单。

排查了一上午,根因是前端在创建订单时往请求里塞了一个新的渠道字段,而后端的校验逻辑对这个字段的枚举值处理不兼容。单测覆盖的是后端单个函数,前端测试覆盖的是组件渲染,两个层面各自都对,但合在一起,接口契约对不上,问题立刻暴露。这种问题,恰恰是单测和集成测试都很难覆盖到的——它必须在真实的前后端联调环境里跑一条完整链路才能发现。

1.2 e2e与其他测试的边界到底在哪

很多团队对e2e的理解是“自动化测试的一种”,但我觉得更准确的说法是:e2e是站在用户角度对系统的最终验收。单元测试验证的是“零件没问题”,集成测试验证的是“两个零件能对上”,而e2e验证的是“整条流水线转起来,产品能正常交付”。

举一个类比:单测像检查发动机每个气缸的压缩比,集成测试像把所有管线接好之后试转一次,而e2e是你真正坐到驾驶座上,踩油门、打方向盘、开出去溜一圈。发动机每个零件都对,管线连接也对,但真正开起来可能方向盘跑偏、刹车踏板行程不对——这种体验级别的错误,前面两类测试都发现不了。

所以我在项目里定了一个规矩:所有涉及跨系统交互的核心链路,必须要有e2e覆盖。其他零碎的逻辑可以交给单测,但用户能直接感知的主流程,必须有一条自动化链路天天验证。

1.3 Profile1的含义:先保住那条最要命的链路

建立e2e的过程里,最容易犯的错就是“贪多”。我们刚开始写了二十多个用例,覆盖了商品、订单、优惠券、售后、支付、用户中心等各个模块。结果CI跑一次要一个多小时,而且频繁因为环境问题挂掉,最后谁都不看测试报告,自动化逐渐沦为摆设。

后来我做了一件关键的事:重新梳理业务,挑出最核心的一条用户主链路,单独建立一个profile(配置集合),命名为profile1。这条链路的选择标准很简单——如果它坏了,用户就无法完成交易,业务直接停摆。我们选的是:

注册 → 登录 → 浏览商品 → 加入购物车 → 创建订单 → 支付 → 支付回调 → 订单状态变成已支付 → 查看订单详情 → 发起退款 → 退款成功

Profile1里只放这一条链路的用例,其他模块的用例全部挪进profile2、profile3,暂时不纳入CI关键拦截。这样做的直接好处是:执行时间从一小时缩到不到二十分钟,失败率大幅下降,每天早上CI跑完,我扫一眼profile1的结果,就能对系统核心功能有没有问题给出明确判断。

2. 环境编排:让e2e在本地和CI都能稳定复现

2.1 依赖服务清单与版本锁定

e2e能不能稳定跑,第一步不是写用例,而是环境能不能稳定复现。我们踩过最痛的一个坑是:本地跑得好好的,一上CI就挂,原因是对手环境的MySQL版本不一致,某个字段的默认值行为不同。

我把被测系统需要的依赖服务列了个清单,并且全部锁版本:

服务用途版本锁定
MySQL业务主库8.0.x,固定镜像tag
Redis会话与缓存7.x,固定镜像tag
后端API服务业务逻辑构建产物固定为某个commit
前端Web服务页面与静态资源构建产物固定为某个commit
Mock支付服务模拟第三方支付回调本地服务,代码固定

这里要特别强调Mock支付服务。真实支付通道不能出现在自动化环境里,我们自己在本地起了一个极简的HTTP服务,接收下单时生成的支付单号,然后模拟支付平台回调我们的后端接口。这个设计在后面讲”踩坑“时还会展开。

2.2 Docker Compose编排与健康检查

环境统一这件事,我们直接用Docker Compose解决。下面是我们profile1用的compose文件骨架:

version: "3.8" services: mysql: image: mysql:8.0.36 environment: MYSQL_ROOT_PASSWORD: testpass MYSQL_DATABASE: shop_e2e ports: - "33061:3306" healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-ptestpass"] interval: 3s timeout: 5s retries: 20 redis: image: redis:7.2-alpine ports: - "6380:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 3s timeout: 3s retries: 20 mock-pay: build: ./mock-pay ports: - "9001:3000" environment: CALLBACK_URL: http://api:8080/payment/callback api: image: shop-api:test depends_on: mysql: condition: service_healthy redis: condition: service_healthy environment: DB_HOST: mysql DB_PORT: 3306 REDIS_HOST: redis PAY_CALLBACK_BASE: http://mock-pay:3000 ports: - "8080:8080" web: image: shop-web:test depends_on: - api ports: - "3000:80"

有几个细节值得注意。端口全部用非默认端口(33061、6380),避免和本地开发环境冲突。健康检查一定要配,不然api服务可能在MySQL还没就绪时就启动,连接建立失败直接报错。我们之前就吃过这个亏:依赖没就绪,然后用例偶尔成功偶尔失败,非常难排查。

2.3 本地一条命令跑起来

编排写好后,本地起环境就变成了固定套路:

# 1. 构建被测服务镜像 docker build -t shop-api:test -f api/Dockerfile api/ docker build -t shop-web:test -f web/Dockerfile web/ # 2. 启动全部依赖 docker compose up -d # 3. 等所有服务健康(用compose的healthcheck,配合一条等待命令) # 4. 执行e2e npx playwright test --config=playwright.profile1.config.ts

重点是每次都在干净环境上跑。我执行e2e前习惯先把旧容器清掉:

docker compose down -v docker compose up -d

-v会删掉MySQL和Redis的数据卷,让测试永远从一张白纸开始。这很关键,后面讲数据污染时会再提。

3. Profile1的用例设计:按用户生命周期而不是按页面堆用例

3.1 我一开始犯过的错:按页面模块写e2e

第一版e2e用例,我是照着前端路由表写的:首页一堆用例、商品列表一堆、购物车一堆、订单详情一堆。结果就是每个页面都测了,但关键链路从来没有在一个用例里跑完过。用户从注册到支付这个过程中间任何一个环节断了,我的用例集根本发现不了。

后来我意识到一个问题:e2e的价值不在“每个页面都点了一遍”,而在“像真实用户一样连续完成一件事”。页面跳转之间携带的上下文(商品ID、订单号、支付单号、登录态)恰恰是系统最容易出错的接缝。于是profile1的用例设计完全改成了按用户生命周期组织。

3.2 一条完整的主链路用例清单

Profile1里我放了5个用例,名字和覆盖内容如下:

  1. 新用户完整注册并完成首单支付
  2. 已登录用户基于购物车创建订单并查询订单状态
  3. 支付回调到来后订单状态流转与库存扣减一致
  4. 用户对已支付订单发起退款,退款成功且状态联动
  5. 会话过期后重新登录,能正常恢复购物车数据

每个用例都从“用户操作”发起,用真实浏览器点击、填写、跳转,而不是直接调接口。只有支付回调这一步,因为我们对接的是Mock支付服务,会在用例中途由mock服务主动触发。

以第一个用例为例,具体步骤:

  • 访问注册页,填入随机手机号、密码,提交注册
  • 断言:登录态已建立,页面跳转到首页
  • 打开某商品详情页,点击加入购物车
  • 去购物车页,勾选商品,点击结算
  • 填收货地址,选择支付方式,提交订单
  • 捕获订单号与支付单号
  • 模拟支付成功后跳转到订单详情页
  • 断言:订单状态显示“已支付”,页面出现支付完成时间

3.3 断言不只写在页面上,还写在数据库和服务状态里

页面断言能证实“用户看到了什么”,但系统是否真的对,还得看服务端状态。比如支付回调成功、前端页面显示已支付,这只能说明页面渲染对了,不能说明订单在数据库里也是已支付。

所以我在profile1的用例里加了接口层面和数据库层面的断言:

// 验证数据库订单状态(通过API的查询接口间接验证) const orderResp = await request.get(`/api/v1/orders/${orderId}`); expect(orderResp.status()).toBe(200); const orderData = await orderResp.json(); expect(orderData.data.status).toBe('PAID'); expect(orderData.data.payTime).not.toBeNull(); // 验证库存扣减(通过商品详情接口间接验证) const skuResp = await request.get(`/api/v1/skus/${skuId}`); const skuData = await skuResp.json(); expect(skuData.data.stock).toBe(stockBefore - 1);

页面+接口+数据库三层断言都通过,才算这条用例真正过了。这样做一开始会比较累,但一旦系统出问题,测试报告能直接告诉你到底断在哪一层,排查效率高很多。

3.4 数据隔离:每个用例都有自己的账号和订单

e2e最大的敌人是测试数据互相污染。手机号唯一约束、订单号重复、状态被别的用例改掉,这些都是经典问题。

Profile1的解法很简单粗暴:

  • 手机号用时间戳后缀生成,比如138${Date.now()}
  • 订单号由系统生成,天然唯一,用例不去做任何手工指定
  • 每个用例只操作自己的数据,不以“查列表第一条”这种脆弱方式来定位数据
  • 用例结束后记录产生的业务数据,在全局清理阶段统一删除

我写了一个简易的清理器,在每次测试执行完扫一遍数据库,把本轮产生的测试订单、测试用户、测试购物车记录全部物理删除。这个清理器保证了每次执行跟第一次执行拥有同样干净的起点。

4. 稳定性治理:等待、幂等、清理三件事决定了e2e的生死

4.1 等待策略:固定sleep是最懒也最坑的做法

写e2e的人一定都写过sleep(3000),然后总有那么几次,系统慢了半秒,用例就挂了。固定等待的问题在于:快的时候浪费时间,慢的时候猜不中,怎么都不好。

我们profile1里的统一标准是显式条件等待。用Playwright的话说,就是expect(locator).toHaveText这类断言本身自带自动等待,它会不断轮询直到满足条件或超时。我之前写的是Selenuim风格的WebDriverWait,切到Playwright之后整个人都轻松了:

// 不要这写:await page.waitForTimeout(5000); // 而是这样:等待某个真实业务条件达成 await expect(page.locator('.order-status')).toHaveText('已支付', { timeout: 10000 });

这里的逻辑是:等待业务结果,而不是等待时间流逝。你要等的不是“大概五秒”,而是“订单状态变成已支付”这个事实。如果系统三秒就完成了,用例三秒就继续;如果十秒才完成,用例十秒才继续。系统快,用例就快;系统慢,用例也等得起。

4.2 接口幂等与重复执行:同一用例跑十遍,结果必须一致

我们建profile1时定了一条硬指标:每个用例连续跑十次,不允许出现一次失败。做不到这条,说明用例本身包含不稳定因素,得先修用例而不是先加重试。

幂等性跟两个东西密切相关:一是用例执行前数据是否干净,二是用例内部是否重复创建了同名同约束的数据。

比如注册用例,第一次执行生成了手机号138123,第二次执行如果还用同一个手机号,后端返回“手机号已注册”,用例就挂了。解决办法是动态生成唯一数据。我们写了一个辅助函数:

function uniqueMobile() { return `138${Date.now().toString().slice(-8)}`; }

这个非常简单,但解决了90%的数据冲突问题。

4.3 数据清理的三种方式:事务回滚、软删除+清理Job、独立租户

清理策略其实有三种做法,我各说一句:

  1. 事务回滚:用例开头开启数据库事务,用例结束手动回滚,数据就像从没存在过。好处是清理很干净,坏处是web服务通常在不同的连接池里,事务隔离很难跨服务生效,实际可行性低。
  2. 软删除+清理Job:测试数据标记来源,比如用户的source=test,然后定期跑一个Job清理。实现简单,但不立即清理会导致“前一次执行留下的脏数据”影响“后一次执行”。
  3. 独立租户/独立库:每个环境一套库,跑前重建表结构。最干净,成本也最高。

我们profile1里选的是方案2的加强版:用例执行完立即通过清理器物理删除,如果清理器挂了,CI会在下一次执行前先执行一遍docker compose down -v来兜底。相当于把数据清理由“单一手段”变成了“双保险”。

4.4 全局超时与失败兜底

除了单个等待条件有超时,我们还在全局配置了超时兜底。一个用例最长允许执行90秒,整个profile1项目最长20分钟。如果某个服务挂了导致页面一直加载,全局超时会强制中断,并输出当时页面的截图和网络请求日志。

这个兜底非常重要。没有它,用例可能因为某个组件永远转圈而卡到CI超时,整个流水线被拖死。

5. 踩坑实录:我亲手埋进Profile1的三个雷

5.1 雷一:测试数据互相污染,连续跑三次必挂

这个坑是profile1上线第一个月最让我头疼的。现象很规律:当天的第一次执行永远全绿,第二次开始出现零星失败,第三次基本挂掉一半。

排查链路是这样的:

  • 先看失败日志,发现是创建订单接口返回“商品库存不足”
  • 打开数据库看库存,发现商品SKU只剩几十个了
  • 再往前翻,发现第一次执行已经扣了很多库存,但用例结束后的订单都被清理器删了,库存却没有恢复

根因很清楚:我用例通过API下单,真实调用了扣减库存的逻辑,但清理器只删了订单记录和用户记录,没有恢复库存。库存是共享资源,一次执行扣掉一部分,第二次执行的初始状态就不对了。

修复方式是在清理器里增加一步:通过数据库直接重置涉及的商品库存为预设初始值。更严谨的说法是:所有共享资源,都要在每个用例开始前重置为确定值。我把这条原则写进了profile1的用例规范里。

5.2 雷二:依赖系统时间戳的过期校验

第二个雷特别隐蔽。我们系统有条业务规则:订单创建后30分钟内未支付,自动取消。正常情况下profile1执行很快,不会碰到这个边界。但有一次CI负载异常高,支付环节卡了25分钟,结果支付接口返回“订单已取消”,用例挂掉。

这个挂法很“环境化”——不是代码逻辑错了,是测试跑太慢。修复思路不是在用例里加等待,而是让测试环境下的订单过期时间可配置,把30分钟改成2小时。我们在后端增加了一个配置项order.expire.minutes,从环境变量读取,默认线上为30分钟,测试环境注入order.expire.minutes=120。

这个事给我的启发是:e2e测试环境必须要能和线上环境隔离出一些“可加速等待”的开关。比如短信验证码,测试环境直接固定一个万能验证码;比如支付回调,测试环境用mock服务了几秒钟就触发。这些在线上不能乱开,在测试环境必须能控制。

5.3 雷三:登录态跨域丢失,接口401

还有一次,页面操作全都成功,但跑到某个接口断言时突然报401。这用例前一天还是好的,为什么今天挂了?

用浏览器手动打开页面,登录、下单、查单,全正常。但是Playwright自动化跑下来,就会偶现401。后来我把网络请求日志拉出来对比,发现了一个规律:自动化用例里,前端页面和接口请求出现的域不一样。

我们的部署结构是前端在app.example.com,后端API在api.example.com,登录成功后Cookie写在app.example.com域下。浏览器里用户从app.example.com发起的API请求带上了Cookie,没问题。但Playwright用例直接通过request上下文访问api.example.com时,请求Cookie没有传给该域,于是后端觉得未登录,返回401。

修复方案有两个方向。一个是让前端的API请求带token而不是依赖Cookie;另一个是把Cookie的Domain设置为.example.com,让API子域也能收到。我们两个都做了:线上走token方案,测试环境顺手把Cookie的Domain设成了公共父域。改完之后,401再也没出现过。

5.4 复盘一下这些雷的共同点

三个雷表面上看是“数据没清理”“订单过期”“登录态丢失”,但本质上都是同一个问题:e2e用例依赖了环境里不受控的隐式状态。库存是隐式状态,系统时间也是隐式状态,Cookie的域规则还是隐式状态。后来我把“控制所有隐式状态”写成了profile1的检查清单,每次加新用例都会过一遍这个清单:

  • 这个用例用到的数据,初始值是否确定?
  • 这个用例跑得快慢,是否会影响结果?
  • 这个用例的登录态、Cookie、token,来源是否可控?

6. 接入CI之后的执行效果与成本账

6.1 CI流水线设计:定时任务+PR关键拦截

Profile1跑稳之后,下一步就是接入CI。我们在CI里做了两层使用:

一层是每天早上定时跑。跑完把结果发到企业微信机器人,全组人都能看到。这样即使当天不发版,关键卖货链路每天也被验证了一遍。

另一层是合并主干前的必过拦截。只有PR通过code review,同时profile1执行通过,才能合并。一开始团队有人觉得“每次合并都要等十几分钟太慢了”,但我们把profile1的执行时间控制在8分钟左右之后,这个阻力基本消失了。

6.2 失败拦截与告警分级

失败处理上,我们做了分级:

  • 用例失败:自动截图+网络请求日志+控制台日志,发送到测试报告平台
  • 环境失败:如果Mock支付服务起不来、MySQL连不上,这类属于环境问题,告警到运维组,不把锅甩给用例
  • 多次失败:连续两天早上同一用例失败,自动建一个issue,assign给最近修改过相关代码的人

这个分级非常重要,不然测试报告全是“环境异常”,大家看几次就不看了。

6.3 跑一个季度的实际数据

下面是我们profile1跑了一个季度之后,测试资产本身的成本数据:

指标接入前(手工回归)接入后(profile1)
核心链路回归耗时约4小时(两人)约8分钟(无人值守)
平均失败率人工无记录从28%降到6%以内
上线前人工加测成本每次发版必备从第2个月起不再重复人工点主链路
发现问题到定位问题时间平均30分钟平均5分钟(测试报告直接带日志和截图)

最直接的收益不是“省了多少人力”,而是上线前我们对核心功能有信心了。以前是“点过了也不知道对不对”,现在是“profile1过了,核心链路就是能用的”。这种感觉上的差距,做过发版的人一定懂。

6.4 从profile1到profileN的扩展路线

profile1跑稳之后,我们再往里面加其他profile就有了方法论。目前计划里的profile2是“营销活动链路”,包括领券、使用优惠券、满减校验;profile3是“售后全流程”,包括换货、退货、物流单号回传。每个profile都遵循同样的环境编排、数据隔离、稳定性治理要求。

我个人的建议是:不要一上来就规划20个profile,先把profile1做到“连续两周0失败”,再开始扩展。e2e这个领域,稳定性比覆盖率值钱得多。一个平均每次跑都挂的测试资产,等于没有测试资产。

最后分享一个实用的收尾技巧:我们会在profile1执行时,往前端埋点日志里自动注入一个e2e_trace_id标记,跑完一条链路之后,只要搜这个trace id,就能把整个用户操作路径的所有日志串联出来。后来线上排查问题我也经常复用这个思路——给一条业务链路打上唯一的追踪标记,不管测试还是线上,排查时间都能大幅缩短。

RELATED READING

延伸阅读

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