☰
测试稳定性工程:失败用例治理与假失败归因实战
2026/9/30 4:15:06 网站建设 项目流程

1. 先承认一个事实:大多数失败用例是被"点重跑"掩盖的

早上九点进办公室,第一件事不是倒水,而是打开CI面板。看到一排红:订单流程挂了、支付回调超时、商品搜索断言失败。你挨个点开日志,心里其实已经有了预判——八成又是环境问题。点一下"Rerun failed tests",五分钟后绿了。这条流水线就又"正常"了。

这个场景对任何做过测试工程的人都太熟悉了。我可以直接说结论:只要团队里还在靠"点重跑能不能过"来判断一个失败是不是真的严重,测试稳定性工程就还没有真正开始。失败用例治理这件事,难的不是修一个失败用例,而是搞清楚它为什么会失败、为什么只在这个时间点失败、为什么重跑就通过。你以为你在做稳定性的治理,其实你只是在给一个慢性病人反复吃止痛药。

我习惯把失败用例的根因分成三个层面来看:

  1. 产品代码缺陷:功能真的坏了,测试正确地报了红。
  2. 测试代码/用例设计的问题:断言条件不合理、等待时间不足、用例之间有依赖、数据没隔离,测试自己把自己搞挂了。
  3. 测试环境/基础设施问题:端口冲突、数据库锁、容器资源不足、依赖服务超时、测试数据被污染。

大多数团队在治理失败用例时,会把所有问题都归到第一类,然后去找开发理论。但实际做下来你会发现,真正的产品缺陷可能只占三成到四成,剩下六成以上是测试代码和测试环境层面的问题。也就是说,CI的红色里,有很大一部分是测试系统自己生的病。

这就是为什么我更愿意用"测试稳定性工程"来定义这项工作。它不是一个一个地修用例,而是要把整个测试执行过程变得可控、可预测。一个用例不稳定,可能是写法问题;一批用例隔三差五地失败,那一定是支撑它的环境、数据或执行机制出了问题。你只盯着单个用例修,治标不治本;站在工程层面做治理,才能让稳定性成为一个持续变好的指标。

1.1 那个每天都出现、点重跑就过的用例

我印象最深的是一个支付回调的用例。它大概每隔一两天就失败一次,失败信息永远是"等待回调超时",重跑又立刻通过。团队里没人把它当回事,因为业务都在正常跑,而且每次重跑都过。

但这类用例就是典型的亚健康用例——它没有让流水线永久变红,但它在持续地消耗大家的注意力和CI资源。每天早上有一个人要花十分钟点开它、看日志、点重跑,然后假装问题不存在。一个这样的用例是十分钟,十个这样的用例就是一个小时。失败用例治理的第一直觉不应该是"把它修好",而应该是"把它找出来,并且分类"。

后来我认真查了一次这个支付回调用例。不是开发改坏了东西,也不是测试环境真的超时。问题出在测试代码里用了固定的sleep(5),而支付回调在CI机器负载高的时候可能需要 8 秒甚至更久。一旦超过 5 秒,断言就失败。这个用例本身就写得不稳。更麻烦的是,因为大家都在点重跑,没人去改这个sleep,它就这么"带病运行"了快两个月。

这种事在每个团队都有,我只是拿它当例子。它说明一个很扎心的道理:一个失败用例如果长期存在却迟迟不被处理,本质上不是它难修,而是团队的稳定性机制没有把它暴露出来。它需要一个分类、一个责任人和一个处理流程。

1.2 失败用例为什么会反复出现:三个"生病"的环节

要理解稳定性工程,得先理解一次自动化测试执行的生命周期。一个用例从开始到结束,至少经历三个环节:准备阶段、执行阶段、验证阶段。每一个环节都可能出问题。

  • 准备阶段:用例需要前置数据、需要依赖服务、需要干净的数据库。这个阶段最常见的病是数据污染和数据冲突。两个用例同时跑,用了同一份测试数据,后跑的用例把前一个的数据改了,前一个就挂了。
  • 执行阶段:用例发起请求、操作页面、调用接口。这里最容易出问题的是资源竞争和异步时序。端口被占用、线程池不够、回调还没回来就去断言了。
  • 验证阶段:对结果做断言。常见病是断言写得太脆弱,或者反过来写得太严格。比如验证时间戳精确到秒、验证排序完全一致、验证请求返回的字段顺序——这些断言天然容易抖动。

任何一环的"病"没有处理好,用例就会间歇性失败。而且这三个环节的问题往往是叠加的:一个用例本身时序就不稳,那天又碰上CI并发跑得多了点,于是失败。你去看日志看到的是执行阶段的问题,但真正的根子在准备阶段或验证阶段。

所以我的经验是:归类失败用例,不要只看失败那一刻的报错,要把用例回到它自己的完整生命周期里去复盘。是哪个环节最不稳定?是每次失败都卡在同一个等待点,还是数据集时好时坏?这些模式往往比单个报错信息更有价值。

1.3 治理失败用例 = 同时修三个层面的"测试代码债"

从工程层面看,我把测试稳定性治理拆成三个部分,缺一个都走不远:

一是基础设施层。包括CI的并发策略、测试环境隔离方式、数据库的清理策略、依赖服务(尤其是第三方接口)的可用性。这一层解决的是一次性影响大批用例的稳定性问题。哪个团队如果还在多套测试共用一套数据库、共用一组固定端口,别谈治理失败用例,先把环境隔离做了再说。

二是框架与工具层。包括测试框架里有没有统一的等待机制、失败重试机制、上下文信息采集机制。比如你有没有把waitUntil封装成公共方法?失败时有没有自动抓取服务端日志、屏幕截图、请求响应体?这些工具能力决定了你处理一次失败的效率。

三是用例治理层。这是最花时间的部分,也是很多人以为的全部。比如逐条审查高失败率用例、修正不合理的断言、消除用例之间的依赖。这个层面需要持续投入,但如果没有前两层的支撑,修好的用例很快会被环境拖回失败名单。

我会在后面的内容里分别讲清楚怎么落地。先记住一句我自己反复跟团队强调的话:一个失败用例重跑通过了,不代表问题解决了,只代表它把问题藏起来了。治标和治本的差别,就从这里开始。

2. 把"假失败"和"真缺陷"分开:失败归因体系是第一步

很多团队对失败用例的治理方式,是"谁看到谁处理"或者"开发被@了就去看一眼"。这不能说错,但效率很低。因为失败的类型不同,处理路径完全不同:产品缺陷要提单给开发,环境问题要找运维或基础设施,测试代码问题要自己改。如果一开始不分类,大家就会在错误的路径上反复折腾。

所以我到任何一个团队做测试稳定性工程,第一件事永远是:建立一套失败归因标签体系。没有标签体系,你连"我们的假失败率到底是多少"都答不上来。

2.1 给每次失败贴标签,而不是只看红绿

你可以直接照搬这么一套标签,我用了很多年,按实际团队情况调整即可:

标签含义典型现象
ProductBug产品功能确实存在缺陷用例失败后手工复现或开发确认存在bug
TestCodeIssue测试代码写错或断言不恰当修改测试代码后通过,产品逻辑未变
EnvIssue测试环境问题服务不可用、端口冲突、数据库连接失败
DataIssue测试数据缺失或被污染同一用例单独跑一定通过,并发执行大概率失败
TimingIssue异步等待或时序竞争问题重跑大概率通过,失败点固定在某一个等待处
InfraIssueCI/基础设施问题容器被杀、磁盘满、资源配额不足
Unknown暂时无法定位用现有日志无法判断,需要补充采集后再归因

标签不是给人随便拍的,要尽量用证据说话。我会在CI的失败通知里直接让脚本把错误日志的摘要、失败步骤、截图链接一起带上,这样收到通知的人不用点好几个页面就能做初步判断。

还有一个很容易被忽视的做法:每个标签都要带一个"归因人"和"归因时间"。比如"EnvIssue - 张三 - 2024-05-12 10:23"。没有责任属性的标签,等于没贴。因为标签存在的意义不是为了统计好看,而是为了推动下一步动作。

2.2 五分钟快速归因:从看日志到看上下文

归因这事,新手和老手最大的差别在于:新手喜欢盯着报错信息本身想,老手会围绕"用例在哪一步、什么条件下、依赖了什么"去还原现场。

我给团队定的快速归因流程是五步,正常五分钟内能走完:

  1. 看失败步骤:是前置准备挂了,还是业务操作挂了,还是断言挂了。失败步骤不同,处理思路完全不同。
  2. 看错误类型:连接类错误优先怀疑环境;断言类错误优先怀疑数据和业务逻辑;超时类错误优先怀疑时序和性能。
  3. 单独重跑一次:单独跑也失败,多半是稳定复现的真问题;单独跑通过、并发跑失败,多半是数据隔离或资源竞争问题。
  4. 看相关联的失败用例:如果同时间还有别的用例报数据库连接失败,那几乎可以确定是环境级问题,不是一个用例的问题。
  5. 看上次通过的时间和改动记录:是最近一次代码变更引入的,还是长期偶发?这一步能帮你快速缩小范围。

这套流程走完,百分之七八十的失败都能归到正确的标签上。剩下的"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(Supplier<Boolean> 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报告

我强烈建议团队设置一个"稳定性值班"角色,可以按周轮值。值班人每天上班第一件事:过一遍昨晚至今的失败列表,完成第一轮归因,然后按优先级分发。

分诊的优先级怎么定?我的经验是:

  1. 先看批量失败:同一时间大量用例失败,优先排查环境问题,这可能影响所有人。
  2. 再看"濒死"用例:连续失败的用例,优先处理。
  3. 最后看单点偶发:这类可以稍后处理。

批量失败必须在30分钟内响应,最好直接拉上基础设施的同学一起看。单点偶发则可以在当天的空档里慢慢处理。值班人不需要把每个问题都修完,但他要保证:每一条失败都有标签、有负责人、有下一步动作。

5.2 用例责任人与缺陷单联动

每条用例最好有一个明确的Owner。尤其是核心链路和高失败率用例,不能是"大家都能改,大家都不改"。

Owner要负责的事情包括:用例的稳定性优化、定期检查健康度、当用例开始频繁失败时第一时间响应。Owner不一定是当初的写作者,也可以是当前最熟悉这块业务的测试工程师。

当归因结果确认是产品缺陷时,也要有一条顺畅的联动通道。我在实际操作中的流程是:测试先写好复现步骤,附上失败日志和重试可忽略的说明,再提单给开发。开发修完后,测试验证的不只是这个用例通过,还要看看它最近几次的失败历史是否都和这个缺陷相关。这样提单才是联动的,而不是各干各的。

5.3 测试代码评审:别把烂用例合进主干

很多团队对产品代码有严格的Code Review,对测试代码却随便放行。这是一个很大的问题。烂用例一旦合入主干,你不是多了一个测试,你是多了一个将来的失败源。

我建议测试代码的评审里至少看这几点:

  • 有没有用固定sleep:有,一律改成轮询等待或事件驱动。
  • 断言里是否全是具体值:是,要求说明这些值是否为稳定业务常量。
  • 用例是否依赖执行顺序:依赖其他用例的共享状态,直接打回,要求改成独立数据。
  • 有没有在测试代码里修改公共环境:比如改全局配置、清公共表,这种基本都会被拒。

一开始卡得严一点,团队会不习惯,但坚持一个月后,CI的噪音会肉眼可见地减少。好的测试代码和好的产品代码一样,需要被认真对待。

6. 复盘与收尾:我不追求100%通过率

最后聊一个容易被人误解的点。你以为测试稳定性工程的终点是让CI全绿、通过率100%,其实不是。正常的业务系统,加合理的测试集,一定存在偶发性失败的可能性。过度追求100%,只会让大家学会藏问题。

我见过最极端的例子:有的团队为了保住100%通过率的看板,把容易失败的用例全部禁掉,最后留下来的都是永远不会失败的"躺平用例"。稳定是稳定了,但测试也已经死了。这不是稳定性工程的目标。

我更愿意追求的目标是:失败率稳定在一个可接受的低位上,每一次失败都能被快速分类、快速处理,而不会让团队对红灯麻木。这意味着真缺陷能被及时暴露,假失败能尽快修复,测试资产能持续变干净。

最后再分享一个小技巧。我会在每个迭代结束前,把最近一周的失败列表导出来,标记出每一个"重跑通过"的用例,然后问团队一个问题:如果不允许点重跑,你会怎么做?这个问题几乎每次都会逼出几个真正需要修的隐患。保持测试稳定,从来不是靠一把重跑按钮,而是靠每一次失败都被认真地对待。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询