测试稳定性工程:失败用例治理与假失败归因实战
发布时间:2026/9/30 4:15:07 锦皓数字建站

1. 先承认一个事实大多数失败用例是被点重跑掩盖的早上九点进办公室第一件事不是倒水而是打开CI面板。看到一排红订单流程挂了、支付回调超时、商品搜索断言失败。你挨个点开日志心里其实已经有了预判——八成又是环境问题。点一下Rerun failed tests五分钟后绿了。这条流水线就又正常了。这个场景对任何做过测试工程的人都太熟悉了。我可以直接说结论只要团队里还在靠点重跑能不能过来判断一个失败是不是真的严重测试稳定性工程就还没有真正开始。失败用例治理这件事难的不是修一个失败用例而是搞清楚它为什么会失败、为什么只在这个时间点失败、为什么重跑就通过。你以为你在做稳定性的治理其实你只是在给一个慢性病人反复吃止痛药。我习惯把失败用例的根因分成三个层面来看产品代码缺陷功能真的坏了测试正确地报了红。测试代码/用例设计的问题断言条件不合理、等待时间不足、用例之间有依赖、数据没隔离测试自己把自己搞挂了。测试环境/基础设施问题端口冲突、数据库锁、容器资源不足、依赖服务超时、测试数据被污染。大多数团队在治理失败用例时会把所有问题都归到第一类然后去找开发理论。但实际做下来你会发现真正的产品缺陷可能只占三成到四成剩下六成以上是测试代码和测试环境层面的问题。也就是说CI的红色里有很大一部分是测试系统自己生的病。这就是为什么我更愿意用测试稳定性工程来定义这项工作。它不是一个一个地修用例而是要把整个测试执行过程变得可控、可预测。一个用例不稳定可能是写法问题一批用例隔三差五地失败那一定是支撑它的环境、数据或执行机制出了问题。你只盯着单个用例修治标不治本站在工程层面做治理才能让稳定性成为一个持续变好的指标。1.1 那个每天都出现、点重跑就过的用例我印象最深的是一个支付回调的用例。它大概每隔一两天就失败一次失败信息永远是等待回调超时重跑又立刻通过。团队里没人把它当回事因为业务都在正常跑而且每次重跑都过。但这类用例就是典型的亚健康用例——它没有让流水线永久变红但它在持续地消耗大家的注意力和CI资源。每天早上有一个人要花十分钟点开它、看日志、点重跑然后假装问题不存在。一个这样的用例是十分钟十个这样的用例就是一个小时。失败用例治理的第一直觉不应该是把它修好而应该是把它找出来并且分类。后来我认真查了一次这个支付回调用例。不是开发改坏了东西也不是测试环境真的超时。问题出在测试代码里用了固定的sleep(5)而支付回调在CI机器负载高的时候可能需要 8 秒甚至更久。一旦超过 5 秒断言就失败。这个用例本身就写得不稳。更麻烦的是因为大家都在点重跑没人去改这个sleep它就这么带病运行了快两个月。这种事在每个团队都有我只是拿它当例子。它说明一个很扎心的道理一个失败用例如果长期存在却迟迟不被处理本质上不是它难修而是团队的稳定性机制没有把它暴露出来。它需要一个分类、一个责任人和一个处理流程。1.2 失败用例为什么会反复出现三个生病的环节要理解稳定性工程得先理解一次自动化测试执行的生命周期。一个用例从开始到结束至少经历三个环节准备阶段、执行阶段、验证阶段。每一个环节都可能出问题。准备阶段用例需要前置数据、需要依赖服务、需要干净的数据库。这个阶段最常见的病是数据污染和数据冲突。两个用例同时跑用了同一份测试数据后跑的用例把前一个的数据改了前一个就挂了。执行阶段用例发起请求、操作页面、调用接口。这里最容易出问题的是资源竞争和异步时序。端口被占用、线程池不够、回调还没回来就去断言了。验证阶段对结果做断言。常见病是断言写得太脆弱或者反过来写得太严格。比如验证时间戳精确到秒、验证排序完全一致、验证请求返回的字段顺序——这些断言天然容易抖动。任何一环的病没有处理好用例就会间歇性失败。而且这三个环节的问题往往是叠加的一个用例本身时序就不稳那天又碰上CI并发跑得多了点于是失败。你去看日志看到的是执行阶段的问题但真正的根子在准备阶段或验证阶段。所以我的经验是归类失败用例不要只看失败那一刻的报错要把用例回到它自己的完整生命周期里去复盘。是哪个环节最不稳定是每次失败都卡在同一个等待点还是数据集时好时坏这些模式往往比单个报错信息更有价值。1.3 治理失败用例 同时修三个层面的测试代码债从工程层面看我把测试稳定性治理拆成三个部分缺一个都走不远一是基础设施层。包括CI的并发策略、测试环境隔离方式、数据库的清理策略、依赖服务尤其是第三方接口的可用性。这一层解决的是一次性影响大批用例的稳定性问题。哪个团队如果还在多套测试共用一套数据库、共用一组固定端口别谈治理失败用例先把环境隔离做了再说。二是框架与工具层。包括测试框架里有没有统一的等待机制、失败重试机制、上下文信息采集机制。比如你有没有把waitUntil封装成公共方法失败时有没有自动抓取服务端日志、屏幕截图、请求响应体这些工具能力决定了你处理一次失败的效率。三是用例治理层。这是最花时间的部分也是很多人以为的全部。比如逐条审查高失败率用例、修正不合理的断言、消除用例之间的依赖。这个层面需要持续投入但如果没有前两层的支撑修好的用例很快会被环境拖回失败名单。我会在后面的内容里分别讲清楚怎么落地。先记住一句我自己反复跟团队强调的话一个失败用例重跑通过了不代表问题解决了只代表它把问题藏起来了。治标和治本的差别就从这里开始。2. 把假失败和真缺陷分开失败归因体系是第一步很多团队对失败用例的治理方式是谁看到谁处理或者开发被了就去看一眼。这不能说错但效率很低。因为失败的类型不同处理路径完全不同产品缺陷要提单给开发环境问题要找运维或基础设施测试代码问题要自己改。如果一开始不分类大家就会在错误的路径上反复折腾。所以我到任何一个团队做测试稳定性工程第一件事永远是建立一套失败归因标签体系。没有标签体系你连我们的假失败率到底是多少都答不上来。2.1 给每次失败贴标签而不是只看红绿你可以直接照搬这么一套标签我用了很多年按实际团队情况调整即可标签含义典型现象ProductBug产品功能确实存在缺陷用例失败后手工复现或开发确认存在bugTestCodeIssue测试代码写错或断言不恰当修改测试代码后通过产品逻辑未变EnvIssue测试环境问题服务不可用、端口冲突、数据库连接失败DataIssue测试数据缺失或被污染同一用例单独跑一定通过并发执行大概率失败TimingIssue异步等待或时序竞争问题重跑大概率通过失败点固定在某一个等待处InfraIssueCI/基础设施问题容器被杀、磁盘满、资源配额不足Unknown暂时无法定位用现有日志无法判断需要补充采集后再归因标签不是给人随便拍的要尽量用证据说话。我会在CI的失败通知里直接让脚本把错误日志的摘要、失败步骤、截图链接一起带上这样收到通知的人不用点好几个页面就能做初步判断。还有一个很容易被忽视的做法每个标签都要带一个归因人和归因时间。比如EnvIssue - 张三 - 2024-05-12 10:23。没有责任属性的标签等于没贴。因为标签存在的意义不是为了统计好看而是为了推动下一步动作。2.2 五分钟快速归因从看日志到看上下文归因这事新手和老手最大的差别在于新手喜欢盯着报错信息本身想老手会围绕用例在哪一步、什么条件下、依赖了什么去还原现场。我给团队定的快速归因流程是五步正常五分钟内能走完看失败步骤是前置准备挂了还是业务操作挂了还是断言挂了。失败步骤不同处理思路完全不同。看错误类型连接类错误优先怀疑环境断言类错误优先怀疑数据和业务逻辑超时类错误优先怀疑时序和性能。单独重跑一次单独跑也失败多半是稳定复现的真问题单独跑通过、并发跑失败多半是数据隔离或资源竞争问题。看相关联的失败用例如果同时间还有别的用例报数据库连接失败那几乎可以确定是环境级问题不是一个用例的问题。看上次通过的时间和改动记录是最近一次代码变更引入的还是长期偶发这一步能帮你快速缩小范围。这套流程走完百分之七八十的失败都能归到正确的标签上。剩下的Unknown不可怕可怕的是把Unknown直接重跑当成已解决。我建议每个没定位的用例至少要保留原始日志和失败截图方便下一步补充观察。2.3 什么样的重试策略不算掩盖问题重试机制是稳定性工程里绕不开的工具。很多人听到重试就觉得是在掩盖问题其实不然。重试本身没有原罪有罪的是无差别重试和不记录重试。我见过比较合理的做法是分层设置重试框架层重试针对TimingIssue和EnvIssue这类可以自动恢复的失败设置1到2次重试重试之间留一个指数退避的间隔。比如第一次失败后等 3 秒重试第二次失败后等 10 秒。用例层重试针对确实存在偶发抖动的用例在用例级别加上重试装饰器但必须同时记录重试前后的状态。人工重试CI面板上保留一键重试入口但每次人工重试都要选择一个归因标签。关键在最后一条记录上。每次重试都必须留下日志第几次重试、失败原因、重试后是否通过。如果没有记录等于把失败的证据给销毁了。另外我给自己定过一个死规矩一个用例如果连续三天都在靠重试通过就必须进入清理流程。要么在限定时间内修掉要么暂时禁用并建卡跟踪。靠重试维持的绿灯是假绿它只会让问题越藏越深。3. 四类高频失败场景的根治方案直接抄作业分类归因做完了接下来就是硬仗具体怎么修。我挑四类最高频的失败场景给你一套可以直接抄的方案。每一类我尽量讲清楚原理因为只看步骤不看原理的人换个场景就不会了。3.1 环境类失败端口冲突与资源残留环境类失败是新手团队最容易遇到的问题特点是一次挂一大片而且报错五花八门连接拒绝、超时、SSL握手失败、数据库连接池耗尽。表面看是不同问题根子往往是同一类多套测试共用一套资源。最常见的两个元凶是固定端口和资源没释放。我见过某团队所有用例共用一个固定的8090端口起服务只要有一个用例没正常退出后面的用例全部连不上。解决方案说穿了不值钱让每个测试运行实例使用动态端口。如果你的测试框架支持BeforeSuite或者启动阶段动态占端口优先用框架能力。不能用框架的就在启动脚本里做一次先探测再启动用一个随机高位端口起来然后通过环境变量传给测试用例。这样做的好处是用例之间天然隔离不会再互相踩脚。资源残留则是另一个隐蔽的坑。比如用例里连接了数据库但没关连接、创建了临时文件但没清理、起了线程池但没 shutdown。这些资源在单条用例里看不出来在CI里反复跑上几百次就开始出问题连接池满了文件系统满了线程数暴涨。治理这类问题没有捷径就是两件事测试代码里统一用try-with-resources/with语法保证资源一定被释放。CI执行机上加一个环境巡检脚本每次跑完一轮后检查端口占用数、临时文件大小、进程数超过阈值就告警。一个能长期稳定运行的测试环境一定是用完即走的环境任何留下残留的操作都会被清理机制兜住。3.2 数据类失败测试数据污染与并发串号数据类失败是间歇性失败的大户。典型症状是同一个用例单独跑必过和别的用例一起跑就会挂挂的原因还是千奇百怪的断言失败。根因通常是两个用例共享了一份测试数据。比如订单用例A假设数据库里有一条待支付状态的订单用例B在它之前跑完把订单状态改成了已支付。A再跑读到的不再是待支付下一跳就直接失败了。治理数据类失败我建议分三步走第一步每个用例用独立的数据集。不要共享一份公共测试数据。你需要一个数据工厂Data Factory在执行前动态创建这条用例独有的数据。可以给数据加随机后缀比如order_20240512_1601_7f3a确保不会撞车。第二步用例执行完做数据清理钩子。别指望统一清库因为并发执行时清库会把别人的数据也清了。正确做法是每个用例只清理自己创建的数据通过一个全局唯一的标记比如runId来识别。第三步涉及财务、状态流转这类强业务含义的数据尽量用事务回滚或者独立库。如果回滚做不到就把这类用例打上serial标签串行执行避免和其他用例并发。数据隔离做得好不好直接决定假失败率的上限。你可以做个实验把全部用例跑三遍统计三次结果的一致性。一致性越差数据污染和并发冲突越严重。3.3 时序类失败固定sleep是万恶之源我在前面提到的支付回调用例就是典型的时序类失败。这里我多说一点因为这是我见过团队里最多人踩的坑。很多测试工程师写作弊时最爱写sleep(5)等5秒再断言。这个写法有两大问题一是机器负载低的时候根本不用等5秒纯浪费时间二是机器负载高的时候5秒根本不够用例必挂。固定sleep的稳定性取决于最坏情况下的执行时间而CI环境的负载波动又特别大所以它天然就是失败之源。正确的做法是轮询等待polling wait每隔一小段间隔去检查条件是否满足直到超时。比如等一个异步任务执行完成就每 500 毫秒查一次状态最长等 30 秒。条件满足就立刻继续不满足则到超时点报失败。我用Java写过一个最小封装Python、JS团队改成对应语法即可public static void waitUntil(SupplierBoolean condition, Duration timeout, Duration interval, String desc) { long deadline System.currentTimeMillis() timeout.toMillis(); while (System.currentTimeMillis() deadline) { if (condition.get()) { return; } try { Thread.sleep(interval.toMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } } fail(等待超时: desc); }调用方可以写成waitUntil(() - orderService.getStatus(orderId).equals(PAID), Duration.ofSeconds(30), Duration.ofMillis(500), 订单支付状态变为PAID);这个封装看起来简单但解决了三类问题去掉了固定sleep、统一了超时报告、让每个等待点都有明确的语义描述。真正去改历史用例时你会发现很多等待失败其实是业务逻辑压根没走到那一步轮询等待能把真实的失败原因暴露出来而不是一脸茫然地超时。另外如果测试对象是异步回调能 mock 回调就 mock 回调能控制事件触发就主动触发。等一个真实的外部回调无论怎么wait稳定性都很难保证。3.4 断言类失败断言写得太死或太松断言问题往往被低估。一个不稳定的断言比十处环境问题还折磨人因为它会随机地让稳定的功能报红。我在代码评审里总结过两类极端太死的断言精确校验了整个响应体的JSON包括时间戳、随机ID、顺序、甚至临时生成的变量。这些字段每跑一次都不一样断言它们没有意义。更合理的做法是只断言关键业务字段比如status、amount、orderId的格式而不去比较整串。太松的断言只判断HTTP 200不看里面的内容。这种用例永远绿但什么都测不出来。等它开始失败了通常说明系统已经崩得很厉害了。我建议每个断言都问自己一句这个断言会不会因为数据合理变动而失败会就说明断言条件需要放宽边界不会说明这个断言是靠谱的。比如查询结果包含10条记录可能不稳定改成查询结果包含至少1条记录且总数不大于20就合理得多。还有一个经验是能不用精确值就不用精确值能用范围就用范围能用枚举就用枚举。比如校验金额不等于0比校验等于99.9更稳校验状态在[PAID, REFUNDED]集合里比校验等于PAID更适合多分支流程。当然前提是业务上能讲得通不能为了绿而绿。4. 稳定性不是修好一次而是可度量地持续变好失败用例治理最怕的一件事是做一阵子好转了然后没人管又恶化。要避免这个循环必须让稳定性变成一组可以持续观察的数据而不是凭感觉。4.1 四个核心指标失败率、假失败率、恢复时长、用例健康度我建议团队直接从四个指标入手先跑起来再逐步细化失败率Flake Rate / Failure Rate失败用例数除以总执行用例数。它反映了整体稳定水平。对于一个长期成熟的测试资产失败率长期高于5%就值得警惕了。假失败率False Failure Rate被归因为非产品缺陷的失败数除以总失败数。这个指标是测试团队自己的体检指标。假失败率越高意味着测试资产本身越脆弱。我见过刚起步的团队假失败率能到70%以上那说明CI看起来天天红其实是环境、数据、时序三座大山的锅。平均恢复时长MTTR从用例开始连续失败到重跑通过/修复通过之间的平均时间。这个指标衡量的不只是修复速度还衡量团队有没有及时发现并处理失败。如果一次失败要等三天才被发现那修复快也没用。用例健康度Health Score给每个用例打一个分比如最近20次执行里失败次数少于2次的算健康失败2到5次的算亚健康超过5次或连续失败3天以上的算濒死。这个指标用来做存量治理的优先级排序。这些指标不需要搞复杂的平台直接用一个配置文件加一个定时统计脚本就能算出来。我甚至见过用Python脚本每天从CI的API拉一次数据写入SQLite再用一个简单Dashboard展示的团队。关键是先把数据沉淀下来。4.2 失败用例看板与每周稳定性评审有了指标还需要一个固定的跟进机制。我比较推荐两个动作一是失败用例看板。不是让你去搭一个复杂的系统在现有的缺陷管理工具里建一个failed-case-triage看板把每一条失败用例按标签和负责人列出来就行。看板的核心列可以是待归因、待修复、待验证、已闭环。二是每周稳定性评审会。固定三十分钟只看数据不看人。议程就是过三件事上周失败率有没有下降假失败率的高频标签是哪个濒死用例清理进度如何这个会很容易开成问责会所以要立个规矩复盘是为了改系统不是为了找责任人。一个用例频繁失败责任一定不全在写它的人身上而是审查、环境、机制没有拦住它。带着这种心态开会大家才会愿意把真实的数据拿出来。4.3 用例健康度分级健康、亚健康、濒死对存量用例我用一个很粗暴但有效的分级方式管理健康近20次执行失败不超过2次不需要干预。亚健康近20次失败3到5次且归因标签不明确。进入观察列表两周内如果再次失败就升级处理。濒死近20次失败超过5次或连续失败3天。立即处理如果一周内修不好直接暂时禁用防止它继续污染统计数据。禁用濒死用例这件事很多团队接受不了总觉得禁用了就是测试覆盖缺失。我的观点很明确一个不可靠的用例其价值是负的。它不但不提供保护还让团队对红灯产生免疫力顺带浪费大量CI资源。暂时禁用并跟踪好过让它每天在CI里捣乱。等修好了再重新启用一点都不丢人。5. 让治理落地的协作机制一个人救不了所有失败到这里治理的技术手段已经聊得差不多。但真正决定成败的其实是团队协作方式。失败用例治理如果只靠测试工程师一个人默默修很快就会被新产生的失败淹没。5.1 失败分诊谁来看早上的CI报告我强烈建议团队设置一个稳定性值班角色可以按周轮值。值班人每天上班第一件事过一遍昨晚至今的失败列表完成第一轮归因然后按优先级分发。分诊的优先级怎么定我的经验是先看批量失败同一时间大量用例失败优先排查环境问题这可能影响所有人。再看濒死用例连续失败的用例优先处理。最后看单点偶发这类可以稍后处理。批量失败必须在30分钟内响应最好直接拉上基础设施的同学一起看。单点偶发则可以在当天的空档里慢慢处理。值班人不需要把每个问题都修完但他要保证每一条失败都有标签、有负责人、有下一步动作。5.2 用例责任人与缺陷单联动每条用例最好有一个明确的Owner。尤其是核心链路和高失败率用例不能是大家都能改大家都不改。Owner要负责的事情包括用例的稳定性优化、定期检查健康度、当用例开始频繁失败时第一时间响应。Owner不一定是当初的写作者也可以是当前最熟悉这块业务的测试工程师。当归因结果确认是产品缺陷时也要有一条顺畅的联动通道。我在实际操作中的流程是测试先写好复现步骤附上失败日志和重试可忽略的说明再提单给开发。开发修完后测试验证的不只是这个用例通过还要看看它最近几次的失败历史是否都和这个缺陷相关。这样提单才是联动的而不是各干各的。5.3 测试代码评审别把烂用例合进主干很多团队对产品代码有严格的Code Review对测试代码却随便放行。这是一个很大的问题。烂用例一旦合入主干你不是多了一个测试你是多了一个将来的失败源。我建议测试代码的评审里至少看这几点有没有用固定sleep有一律改成轮询等待或事件驱动。断言里是否全是具体值是要求说明这些值是否为稳定业务常量。用例是否依赖执行顺序依赖其他用例的共享状态直接打回要求改成独立数据。有没有在测试代码里修改公共环境比如改全局配置、清公共表这种基本都会被拒。一开始卡得严一点团队会不习惯但坚持一个月后CI的噪音会肉眼可见地减少。好的测试代码和好的产品代码一样需要被认真对待。6. 复盘与收尾我不追求100%通过率最后聊一个容易被人误解的点。你以为测试稳定性工程的终点是让CI全绿、通过率100%其实不是。正常的业务系统加合理的测试集一定存在偶发性失败的可能性。过度追求100%只会让大家学会藏问题。我见过最极端的例子有的团队为了保住100%通过率的看板把容易失败的用例全部禁掉最后留下来的都是永远不会失败的躺平用例。稳定是稳定了但测试也已经死了。这不是稳定性工程的目标。我更愿意追求的目标是失败率稳定在一个可接受的低位上每一次失败都能被快速分类、快速处理而不会让团队对红灯麻木。这意味着真缺陷能被及时暴露假失败能尽快修复测试资产能持续变干净。最后再分享一个小技巧。我会在每个迭代结束前把最近一周的失败列表导出来标记出每一个重跑通过的用例然后问团队一个问题如果不允许点重跑你会怎么做这个问题几乎每次都会逼出几个真正需要修的隐患。保持测试稳定从来不是靠一把重跑按钮而是靠每一次失败都被认真地对待。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。