
前阵子我们把线上商城的核心回归从手工点验换成了自动化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固定镜像tagRedis会话与缓存7.x固定镜像tag后端API服务业务逻辑构建产物固定为某个commit前端Web服务页面与静态资源构建产物固定为某个commitMock支付服务模拟第三方支付回调本地服务代码固定这里要特别强调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 --configplaywright.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个用例名字和覆盖内容如下新用户完整注册并完成首单支付已登录用户基于购物车创建订单并查询订单状态支付回调到来后订单状态流转与库存扣减一致用户对已支付订单发起退款退款成功且状态联动会话过期后重新登录能正常恢复购物车数据每个用例都从“用户操作”发起用真实浏览器点击、填写、跳转而不是直接调接口。只有支付回调这一步因为我们对接的是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、独立租户清理策略其实有三种做法我各说一句事务回滚用例开头开启数据库事务用例结束手动回滚数据就像从没存在过。好处是清理很干净坏处是web服务通常在不同的连接池里事务隔离很难跨服务生效实际可行性低。软删除清理Job测试数据标记来源比如用户的sourcetest然后定期跑一个Job清理。实现简单但不立即清理会导致“前一次执行留下的脏数据”影响“后一次执行”。独立租户/独立库每个环境一套库跑前重建表结构。最干净成本也最高。我们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.minutes120。这个事给我的启发是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连不上这类属于环境问题告警到运维组不把锅甩给用例多次失败连续两天早上同一用例失败自动建一个issueassign给最近修改过相关代码的人这个分级非常重要不然测试报告全是“环境异常”大家看几次就不看了。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就能把整个用户操作路径的所有日志串联出来。后来线上排查问题我也经常复用这个思路——给一条业务链路打上唯一的追踪标记不管测试还是线上排查时间都能大幅缩短。