ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

测试重试策略深度解析:从Flaky Test治理到CI/CD流水线可信度提升

测试重试策略深度解析:从Flaky Test治理到CI/CD流水线可信度提升 先分享一个真实场景。上个月我们团队一位同学提交了MRGitLab流水线跑完3个测试用例红了。他没多想点了重试流水线变绿合并上线。结果第二天线上出了Bug正是被重试掩盖的那条用例对应的功能。这件事让我重新审视了一个在CI/CD里被反复争论的话题测试重试策略到底是失败就重跑还是直接失败。这个问题看起来简单背后却牵扯到测试稳定性治理、流水线可信度、发布效率与工程质量之间的平衡。如果重试策略定得不对要么让团队在红灯面前麻木要么让流水线变成碰运气的赌场。本篇文章结合我们团队的实际落地经验把重试策略的决策模型、四种主流方案、流水线配置方法和避坑经验一次讲清楚适合正在搭建CI/CD流水线、或者被不稳定测试Flaky Test折磨的研发、测试工程师参考。读完你至少能回答三个问题什么失败可以重跑重跑几次才合理重试之后怎么防止问题被掩盖。1. 重试问题背后的核心矛盾效率与可信度1.1 为什么失败就重跑会让人夜不能寐先说一个大家可能都听过的段子某团队的流水线规则是一次失败重试三次三次还失败就换一个时间再跑——这已经不是测试了是抽卡。为什么很多人会下意识选择失败就重跑因为不稳定测试实在太多了尤其是在集成阶段前端并行用例、后端微服务联调、依赖外部接口的用例经常因为网络抖动、测试数据冲突、异步时序导致偶发失败。重试一下确实能快速让流水线变绿让功能能上线。但问题恰恰出在这里。重试策略的本质是在提升流水线通过率和掩盖真实缺陷之间做权衡。无脑重试会导致一个非常危险的局面团队对红灯失去敬畏看到失败第一反应是再跑一次看看而不是去看日志。久而久之流水线这个质量门禁就形同虚设。而且重试并不是零成本的每次重跑都在消耗CI资源排队时间变长其他同学的构建被阻塞严重的甚至要扩容CI集群。效率与可信度这才是重试策略真正要平衡的矛盾。1.2 重试决策模型先把失败分类再决定要不要重跑我在实际工程里总结了一个经验法则不要先讨论重试几次先讨论哪种失败允许重试。失败的根因不同处理方式完全不同。我们可以把测试失败分成三大类环境性失败容器拉取镜像失败、runner节点失联、测试数据库连接超时、依赖服务启动超时。这类失败和被测代码本身无关是基础设施抖动重试的合理性最高。不稳定测试Flaky Test同一段代码、同一条用例上次跑通过这次跑挂了日志里看不出明确的断言错误方向或者错误指向超时、端口占用、时序竞态。这类失败允许重试但重试之后必须标记出来进入治理清单。真实缺陷Real Bug断言失败、异常堆栈指向业务逻辑、输出结果和预期不符。这类失败是测试在替产品把关坚决不能重试重试就是给Bug开后门。每次测试失败之后先做一次两分钟判定确认属于哪一类再决定是否纳入重试范围。这个决策模型是后面所有策略的基石没有这个分类任何重试配置都是碰运气。2. 测试失败的类型拆解不是所有红灯都值得重试2.1 环境性失败基础设施抖动重试就是为它设计的环境性失败是最冤的失败类型。我遇到过的典型场景包括Docker executor拉取基础镜像超时、GitLab runner所在节点磁盘写满、并行任务把测试数据库的连接池打满、外部SaaS服务临时限流。这类失败的特点很明显失败信息里通常找不到任何与业务断言相关的线索更多的是一堆超时、连接中断、资源不足的错误。对于环境性失败重试的价值最大。因为大概率下一次跑的时候基础设施已经恢复了。不过要注意一个细节如果基础设施持续抖动靠重试是兜不住的。比如一个镜像源连续超时半小时你重试三次大概率三次全挂。这时候该做的是去修基础设施而不是调大重试次数。我在团队里立过一个规矩环境性失败重试两次仍然失败的自动触发基础设施告警而不是继续重试。2.2 不稳定测试Flaky Test代码没变结果变了不稳定测试是重试策略里最让人纠结的存在。它的典型画像是这些用例本身价值很高但就是不稳定。比如一个用例断言点击按钮后列表刷新出3条数据如果接口响应在500ms上下浮动那么异步等待时间不够的时候就会偶发失败。又比如两个测试用例共享了一份测试数据前置用例清理不彻底后置用例就会随机失败。不稳定测试允不允许重试我的答案是允许但必须有条件的允许。核心原则是重试之后绿了这条用例会被自动打上Flaky标签进入一个不稳定的测试治理看板。连续多次被标记的用例会触发稳定性整改任务要么修好要么降级跳过绝不允许长期带着不稳定标签在流水线里裸奔。重试只是给自己争取排查时间不是让不稳定测试原地养老。实践中我们遇到过最极端的情况一条用例跑了8次才通过所有人都选择性忽视了这个问题直到线上出了严重事故才发现这条用例的断言逻辑本身就是错的。2.3 真实缺陷这类失败是底线一次都不能重试真实缺陷指的是被测代码确实存在错误测试用例通过断言、异常、堆栈信息明确告诉了你这里不对。比如接口返回了500、订单金额算错、列表数据为空但预期不为空。这类失败最怕被重试机制消化掉一旦重试后通过了要么是断言本身有随机性要么是测试环境的数据状态掩盖了问题无论哪种情况都极其危险。怎么区分真实缺陷和不稳定测试我有一个比较实用的判断标准重跑之后失败的用例是不是同一批。如果重跑之后红的是完全不同的用例大概率是环境或数据问题如果重跑之后红的还是那几条用例那就要高度怀疑是真实缺陷了。另一个辅助判断是看失败日志里的断言信息如果断言信息明确且可复现比如两组数据对比不相等、某个字段为空基本可以确定是Bug直接进缺陷跟踪系统不走重试流程。2.4 一分钟快速判定法失败之后先做这四个检查为了帮团队统一判断口径我整理了一个快速判定清单测试失败之后先过一遍这四个检查再决定要不要触发重试查日志中的异常栈如果异常栈指向业务代码的某个方法直接判定为真实缺陷不允许重试。查失败用例的分布如果失败用例集中在某一个测试类或模块优先怀疑有共享状态污染属于需要治理的不稳定测试。查前置操作是否依赖异步时机比如测试中有sleep、轮询等待、并发操作失败大概率是时序问题可以重试并打上Flaky标。查基础设施状态面板如果CI runner、数据库、依赖服务的监控在同时段有异常判定为环境性失败放心重试。这四条规则我们直接写进了团队的Wiki和流水线注释里新同学也能快速上手。不过要强调一点快速判定是为了止损真正解决不稳定测试问题还是要靠后续的专项治理不能停留在每次都靠人工判断的阶段。3. 四种主流重试策略从简单粗暴到精细决策3.1 策略一全量重试简单粗暴但成本翻倍全量重试是最容易想到的方案逻辑很直白流水线跑失败了不管哪条用例挂了把所有测试用例全部重新跑一遍。这个方案在测试数量少、执行速度快的小项目里能凑合用因为成本可控。但它有两个致命问题。第一是资源浪费明显一个包含5000条用例、执行时长40分钟的测试任务一次重试就额外消耗40分钟的CI时间和一倍的并发资源。测试规模一大CI队列马上告急。第二是问题定位困难全量重试之后流水线绿了你很难确认之前失败的那几条用例在重跑里到底通过了没有过程追溯成本很高。现在的大型项目中全量重试作为默认策略已经不现实了只适合放在万不得已的兜底位置。3.2 策略二失败用例定向重试性价比最高的入门方案定向重试是目前我推荐团队优先采用的方案。核心逻辑是只重跑上一次失败的那几条用例通过的用例一概不跑。这样既解决了不稳定测试的偶发问题又把重试成本压缩到了最低。以Java生态为例用Maven Surefire插件可以配置rerunFailingTestsCount2/rerunFailingTestsCount只让失败用例重跑2次。Python生态可以使用pytest-rerunfailures插件pytest --reruns 2 --reruns-delay 5只重跑失败的用例。Node生态的Jest也有--retry相关配置或者借助jest-circus的retry能力。实施定向重试之后我们的流水线平均重试成本从全量重试的40分钟降到了3到5分钟效果立竿见影。3.3 策略三带退避的重试给系统一点喘息时间重试策略里有一个容易被忽视的问题如果失败是因为依赖服务过载或数据库压力大你立刻重跑大概率会再次失败因为服务还没恢复。这时候就需要指数退避Exponential Backoff算法。指数退避的核心思路是每次重试的等待时间按指数递增。第一次失败后等5秒第二次失败后等10秒第三次失败后等20秒。在CI脚本里可以这样实现#!/bin/bash MAX_ATTEMPTS3 BASE_DELAY5 for attempt in $(seq 1 $MAX_ATTEMPTS); do pytest tests/test_order.py --junitxmlreport.xml if [ $? -eq 0 ]; then exit 0 fi if [ $attempt -lt $MAX_ATTEMPTS ]; then delay$((BASE_DELAY * 2 ** (attempt - 1))) echo Test failed, waiting ${delay}s before retry... sleep $delay fi done exit 1这个策略特别适合测试用例依赖外部接口或共享测试环境的场景。不过要注意等待时间不宜过长否则流水线整体耗时会被拉得很高。我们把单次等待上限控制在30秒以内超过三次就直接失败不搞无限等待。3.4 策略四分级重试按失败类型动态决策分级重试是更精细的做法需要测试框架或CI脚本配合对不同失败类型执行不同的重试策略。大致思路是环境性失败连接超时、资源不足允许重试2到3次使用较短退避时间因为基础设施通常恢复快。不稳定测试Flaky标签允许定向重试1次不重复跑整包重试后仍然失败则进入缺陷池。断言失败等真实缺陷不重试直接终止流水线并通知负责人。分级重试的好处是每一类失败都按自己的规则处理逻辑清晰不会一刀切。缺点是实现复杂度高需要测试框架能区分失败类型。我们目前的实现方式是通过自定义测试监听器在用例失败时捕获异常类型打上分类标签再由CI脚本读取标签决定是否重试。如果团队人力充足我建议逐步往这个方向演进。4. 工程落地把重试策略写进CI/CD流水线4.1 在GitLab CI/CD中使用retry关键字GitLab CI是很多团队的首选好在它原生提供了重试机制。在job级别配置retry关键字可以控制最大重试次数以及触发重试的条件test: stage: test script: - pytest tests/ retry: max: 2 when: - runner_system_failure - stuck_or_timeout_failure这个配置的含义是只有当runner系统故障或任务超时的时候才重试如果测试断言失败不重试。这正是我们前面讲的分类思想在配置层面的落地。如果想要的是一次失败就重跑的效果可以改成when: always但我强烈建议不要这样除非你明确知道自己在干什么。另外GitLab还支持allow_failure参数可以把某些已知的不稳定测试任务标记为允许失败流水线不会因这些任务失败而整体红掉。这个参数和重试配合使用效果不错适合处理已知待整改的不稳定用例但要注意不能滥用否则等于给不稳定性打开了长期通道。4.2 在Jenkins Pipeline中实现灵活重试Jenkins Pipeline里可以用retry步骤包裹测试执行逻辑加上catchError做更精细的异常处理。一个典型示例pipeline { agent any stages { stage(Test) { steps { script { catchError(buildResult: UNSTABLE, stageResult: FAILURE) { retry(3) { sh pytest tests/ --reruns 1 --junitxmlreport.xml } } } } } } }retry(3)表示整个测试阶段最多执行3次同时底层的pytest --reruns 1又对失败用例做了一次定向重试。两级重试叠加对不稳定测试的容忍度很高。但同样要警惕如果代码真有Bug这种叠加会让流水线在失败边缘反复横跳最终还是要靠日志来定位。所以我建议在Jenkins Pipeline里加一个失败归档动作每次重试失败后自动保存测试报告、截屏和日志文件并按时间戳归档方便事后复盘。4.3 重试次数与资源预算的权衡重试次数不是拍脑袋定的它直接关系到CI资源预算。我建议用一个简单的公式来做估算平均CI并发占用时长 单次执行时长 ×1 失败率 × 重试次数× 执行频率。举例来说一个测试任务单次执行30分钟每天执行20次历史失败率是15%如果重试次数设为2那么平均每天多消耗的资源时长大约是30分钟 × 20 × 15% × 2 180分钟也就是3个小时的额外并发占用。这个数字在小型CI集群里是很大的负担。所以重试次数设置得越小越好前期可以用1次起步观察效果不够再加而不是一上来就配3次或者5次。我们内部还设置了一个重试预算看板每周统计各项目的重试次数、因重试而额外消耗的CI时长以及重试后仍然失败的测试用例清单。这个看板的目的不是考核是很直白地告诉你重试策略到底是让流水线更稳定了还是只是在烧资源。4.4 重试记录与可观测性别让重试成为黑盒重试策略落地以后最怕的问题就是重试过程完全不可见。团队只知道流水线最终绿了但不知道哪些用例是第一次就过的哪些用例是靠重试才过的哪些用例重试了好几次。这种情况在工程管理上非常不健康。最基础的做法是让测试框架输出JUnit格式的XML报告里面记录每一条用例的执行次数和结果CI层再把报告上传到统一的测试报告平台按用例维度展示Flaky指数。我们的具体做法是写一个简单的Python脚本在每次测试执行后解析新增的测试报告对比历史数据把新增的Flaky用例自动同步到缺陷管理系统的测试稳定性项目里指派给对应的用例负责人。如果团队暂时没有这些平台用最笨的方法也可以在重试的shell脚本里加一行echo retry attempt: ${attempt} retry.log每次重试都记录时间和原因。有了数据才有治理的方向。没有数据的重试策略等于在黑暗中放枪。5. 重试策略的反模式这些配置方式千万别学5.1 反模式一所有失败都重试掩盖真实缺陷这是最经典的反模式。我在不少团队的流水线里见过类似配置retry: max: 3 when: always甚至有的团队直接在脚本里写了一个for循环测试失败就整包重跑跑5次直到通过为止。这种配置看起来让流水线通过率变得很好看实际上是把问题全部转嫁给了线上测试环境偶发的不稳定因素被消化掉了但真实缺陷也被一起消化掉了。对这种配置我的建议很简单宁可流水线因为真实缺陷红掉也不能让真实缺陷靠重试溜过去。可以从配置层面强制分离断言失败、编译失败、静态检查失败这类确定性失败永远不参与重试只有超时、资源分配失败这类暂时性失败才允许重试。在GitLab里你甚至可以拆出两个job一个负责跑断言测试且不重试一个负责跑稳定性较差的集成测试且限制重试次数。5.2 反模式二重试变绿后直接合并完全不看日志这个反模式比配置问题更隐蔽它发生在流程管理层面。很多同学在流水线变绿之后第一反应是过了合并吧根本不会去翻之前的红色日志。但重试策略存在的前提下变绿不一定意味着所有用例都一次通过。如果红色日志里记录的是环境抖动倒还好如果红色日志里其实已经出现了疑似业务异常的警告只是测试框架恰好把它当成了已知失败那你合并进去的就是一颗定时炸弹。我强烈建议团队养成一个习惯凡是触发过重试的流水线合并前必须查看历史失败摘要。我们团队在流水线脚本里加了一个步骤如果重试发生了就自动生成一个包含日志摘要和用例清单的Issue备注合并请求页面会强制显示这个备注。虽然多了一个步骤但这条规则直接帮我们拦截了好几次潜在故障上线。5.3 反模式三无限重试把CI集群跑成矿机把重试次数设成无限大或者专门写一个直到成功为止的死循环是我见过最疯狂的做法。它带来的直接后果是某个测试任务一旦进入半死不活状态就会无限消耗CI资源把其他所有项目的构建排队时间全拉长CI集群的账单也会在月末给你一个大惊喜。技术层面上无限重试还有一个更隐蔽的风险它会掩盖资源瓶颈。假设测试环境本身已经扛不住并发压力了正常情况流水线会红掉并提醒你扩容但无限重试会让任务一直卡在重试循环里看似稳定实则整体吞吐量已经崩了。我建议所有重试都必须配两个上限一个是重试次数的上限建议不超过3次一个是单次重试的最大等待时间建议不超过60秒。一旦触发上限应该立刻终止流水线并自动创建运维工单。6. 常见问题排查与实操避坑指南6.1 问题速查表把我在实践中遇到频率最高的问题整理成一个速查表遇到类似现象可以快速对照现象大概率原因正确做法重试前后失败用例不一致测试数据冲突或环境抖动标记Flaky排查共享数据清理逻辑重试后固定同一条用例失败真实缺陷或不稳定断言禁止继续重试进入缺陷管理流程流水线绿灯但测试报告有红色记录重试掩盖了部分失败设置重试即强制人工确认的流程门禁重试后等待时间过长导致整体超时退避策略设置不合理限制退避上限为30秒降低重试次数多个并行任务同时重试导致集群崩溃没有限制全局重试并发在CI层面设置重试并发上限这张表的核心逻辑只有一个重试是手段不是目的。任何重试配置最终都要能回答它是否让缺陷更快暴露而不是它是否让流水线更好看。6.2 实操心得我踩过的几个关键坑第一个坑发生在推行Flaky治理初期我们只关注哪些用例不稳定却没有关注哪些用例不该重试。结果一条长期不稳定的用例每次靠重试混过流水线连续三周没人处理直到一次重构后重试再也救不回来才暴露了底层逻辑的耦合问题。后面我们专门加了一个连续Flaky次数的计数超过3次就自动在缺陷系统建卡这条经验救了我们很多次。第二个坑是指数退避和超时控制打架。我们把重试退避时间设得太长导致整个测试阶段超过了流水线的全局超时任务被强制杀掉系统只留下一条job timeout日志排查起来很痛苦。后面我们把退避上限和流水线超时做了联动确保所有重试等待时间之和 单次执行时间必须小于流水线超时时间再也没出现过这类问题。第三个坑是关于重试数据统计的。早期我们把所有重试信息放在CI日志里没有结构化输出导致周会复盘的时候只能翻日志手动数次数。后来我们把每次重试的原因、用例名、执行次数统一通过JUnit report的属性字段输出配合一个简单的Python解析脚本自动生成每周的重试健康度报表。有了报表之后大家治理Flaky的积极性明显高了很多因为数据摆在那里不用扯皮。6.3 个人体会重试策略必须跟团队文化匹配最后说一点真正想明白之后才懂的事重试策略的最终形态取决于团队对失败的态度。如果团队的文化是只要不耽误上线怎么省事怎么来再精细的重试策略也会被破窗最后变成一把保护伞。如果团队的文化是质量门禁不容妥协那么重试策略就会非常克制只服务于排除环境干扰这一个目标。我在实际运维中发现最理想的组合是技术策略偏宽松流程管理偏严格允许足够的重试次数来排除不稳定因素但每一次重试都要留下记录、都要在合并请求里可见、都要能被统计和追责。这样测试重试策略就从一个技术配置问题变成了一个工程文化问题而后者才是CI/CD真正成熟的分水岭。根据我个人经验重试机制的配置文档至少要写清楚三件事什么失败可以触发、重试上限是多少、重试后需要哪些人工确认步骤。不要以为这是一件小事它就是团队质量意识的试金石。
返回列表