☰
Flaky Test治理指南:八项实用技巧让自动化测试告别“重跑就过”
2026/10/10 11:25:06 网站建设 项目流程

凌晨一点,手机上的CI通知把我震醒。打开日志一看,又是那张老面孔:登录用例在支付环境挂了,断言用户名不对。我揉揉眼睛,点下重跑,全绿。第二天同一时间,同样的用例又红,再重跑,又绿。那种感觉就像在和一个永远不肯给准话的人打交道,你说它有问题吧,它多数时候又很正常;你说它没问题吧,隔三差五就给你来一下。

后来我们团队干脆习惯了“红一次,重跑一次”。直到某次上线事故复盘,才发现一个回归用例在主干上已经连续红了三天,每次都被同事顺手重跑点绿了,真正的缺陷就这样在眼皮底下溜进了生产。也就是从那天起,我下定决心系统性治理自动化测试里的Flaky Tests,今天我把自己沉淀下来的八项实用技巧完整分享一下。不管你是刚维护第一批UI用例,还是已经被上千条用例折磨了几个月,这些方法都值得对照着检查一遍。

1. Flaky Test为什么值得专门治理:从“重跑就过”说起

1.1 一个让我决定不再无视红灯的夜晚

那晚的CI日志我到现在还记得:AssertionError: expected 'success' but got 'timeout'。重跑一遍,所有用例通过。这个现象在自动化测试圈子里有一个专门的名字,叫Flaky Test,中文里大家也叫它“不稳定测试”或“偶发失败”。

它的定义说白了就一句话:同样的代码、同样的环境,执行多次,结果不一致。多数时候它绿,偶尔红一下,红完重跑又绿。很多团队对它的态度是“不影响发布就行”,但我那次事故之后彻底改变了想法:Flaky Tests不只是测试代码的问题,它会直接侵蚀一个团队的工程质量底线。

从根上讲,Flaky Test往往不是被测产品的Bug,而是测试代码或者测试环境的Bug。但它比产品Bug更难处理,因为产品Bug有一个确定的复现路径,你可以顺着路径查;而Flaky Test是“薛定谔的失败”,你不知道它什么时候出现,也不知道为什么消失。

1.2 “不稳定”的代价远比你想象的大

先算一笔最简单的账。假设你的自动化测试套件有1000条用例,Flaky率只有1%,一次全量回归就有大约10个假失败。每个假失败从发现、查看、确认“不是真问题”到重跑,至少吃掉5到10分钟。也就是说,光处理假失败,团队每天就要白扔1到2个小时。这还只是算术层面。

更可怕的是信任崩塌。当失败被反复解释成“这个用例本来就不稳定”“重跑就好了”,团队成员会下意识忽略所有红灯。等到真正的回归缺陷出现,你的第一反应依然是“重跑看看”,黄金排查窗口就这样被浪费掉。我遇到的那个上线事故,本质不是用例写坏了,而是整个团队对红色构建已经脱敏。

1.3 我的治理底线:把不确定性变成可控

踩过几次坑之后,我给自己定了一条硬规矩:**每次测试结果都必须是可解释的。**偶发失败可以接受,未知原因的偶发失败不可接受。

具体落到执行层面就是:同一个用例,排除环境因素之外,如果出现两次不同的结果,就必须当作一个缺陷来跟踪,进入排期,而不是“重跑通过”就归档。先立住这条规矩,后面讲到的八项技巧才有落地的意义。否则再多的技巧也只是给测试套件打补丁,过两个月又是一地鸡毛。

2. 动手治理前,先把偶发问题变成可复现问题

2.1 第一步:拿到完整的第一现场

很多团队失败后的第一反应就是“重跑一遍”,这是最毁现场的操作。你连日志都没看,截图也没存,数据库当时是什么状态也不清楚,问题就永远成了一个无法复现的玄学。

我们后来对CI流水线做了硬性改造:失败任务自动归档全部原始日志、截图、浏览器console输出、网络请求记录,以及数据库在失败时刻的dump,然后在PR上推送一条带归档链接的评论。并且立了一条规矩——没有现场资料的失败,不允许直接重跑。

这条规矩一开始执行得很痛苦,大家都觉得“就一个偶发失败,何必这么麻烦”。三个月后所有人都真香了,因为归档资料齐全,很多问题只要拉一次dump就能定位,根本不需要再去抽丝剥茧。

2.2 三种复现手段,按场景选

拿到现场之后,我用三个方法尝试复现问题,成功率大概能覆盖五成以上。

第一种是单用例高频重跑。装好pytest-repeat之后,把一个用例连跑100次,时序类问题最容易暴露:

# 反复运行同一个用例100次,看是否存在偶发失败 pytest tests/test_login.py -q --count=100

第二种是乱序执行。装pytest-randomly后跑一次pytest --shuffle,把执行顺序打乱。如果发现顺序一乱就出现失败,那大概率是有用例在悄悄依赖别人的执行结果。第三种是放大并发。用pytest-xdist把并发数拉得比平时更高,资源竞争类的Flaky会快速现形:

# 打乱测试顺序,定位顺序依赖 pytest tests/ -q --shuffle # 高并发放大资源竞争 pytest tests/ -q -n 8

2.3 给Flaky分类,决定修复路径

复现不了也不要慌,先把问题归类。我一般把Flaky问题分成四个大桶,每一类对应的修复思路完全不同:

类别典型特征修复方向
时序类单跑全过、整套就挂;固定等待导致超时显式等待、时间冻结、消除顺序依赖
数据类残留数据冲突、唯一键重复、断言被脏数据污染测试数据独立、用完即清
环境类换一台机器结果不同、本地过CI挂容器化、统一环境基线
资源类并发一高就失败、端口冲突、连接超时资源隔离、限制并发规模

下面这八项技巧,本质上是按这四个分类逐个击破。顺序上有讲究:先把最容易出问题的顺序依赖和数据隔离做掉,再谈重试和监控,最后从架构层面彻底降低UI层的脆弱度。

3. 技巧一:让每个用例拥有独立的生命周期,切断顺序依赖

3.1 顺序依赖为什么是头号制造机

如果说Flaky Test有一个排行榜,顺序依赖一定是头号种子。它的典型症状是:单个用例连跑100次全过,整套一起跑就挂。原因通常是前面的用例修改了共享数据且没有还原,后面的用例拿着被污染的数据做断言。

更隐晦的是隐式依赖。我见过不少冒烟测试用例,B用例默认A用例一定先执行过,A创建了登录态,B直接复用。一旦A被跳过、被重排序、或者因为某种原因提前失败,B就跟着一片红,而且报错信息莫名其妙。单独看B的日志完全看不出问题。

3.2 用fixture把数据隔离在每个用例内部

解决顺序依赖最有效的手段,是让每个用例自己准备数据、自己清理。pytest的fixture天然就是干这个的,关键是作用域一定要用对。我常写这样的fixture:

import uuid import pytest from myapp.models import User @pytest.fixture def new_user(db): # 每个用例都创建独立用户,绝不依赖其他用例的产物 user = User.objects.create_user( username=f"user_{uuid.uuid4().hex[:6]}", password="Passw0rd!" ) yield user # 用例结束后立即清理 user.delete()

fixture默认是function作用域,意味着每个用例拿到的都是新用户,用完即删,不会传给下一个用例。这里有个很容易踩的坑:有人为了省事把fixture的scope改成module或session,然后在里面放可变状态,比如登录后的会话对象。并发一上来,这个共享对象必然出问题。我的经验是,非特殊情况一律用function作用域,需要加载的公共配置另开只读fixture。

3.3 别把业务链路做成测试链

还有一类顺序依赖来自业务流程本身。比如支付用例依赖订单用例,订单用例又依赖购物车用例,跑起来像一条单行道。业务有依赖很正常,正确的做法是让每个用例自己准备完整的数据链,而不是拿另一个用例的产物去喂它。

不要让“下单用例”的输出物去给“支付用例”当输入。正确姿势是用工厂函数、数据构造工具或者直接调API,把整条链路需要的用户、订单、商品一次性造好。尤其注意:UI不是好的数据准备器。为了准备一条业务数据去跑一遍UI,又慢又容易把测试环境搅乱,是典型的吃力不讨好。

4. 技巧二:随机数、时间、并发顺序,能固定就固定

4.1 固定随机种子,失败才有“回放键”

用Faker生成用户名、手机号、地址这些测试数据很方便,但它有一个隐藏风险:每次跑测试,生成的数据都不一样。某些随机值偶尔会撞上业务规则,比如生成了一个不合法的手机号、用户名超过了字段长度限制,或者两条数据莫名其妙产生了冲突。

这类问题的解法很简单:固定随机种子。

import random from faker import Faker random.seed(42) Faker.seed(42)

如果你用的是独立的Faker实例,记得调seed_instance(42)。固定种子之后,当年的失败就能稳定回放,修复完再换一个种子跑一遍验证。还有一点很重要:随机种子初始化最好统一放在conftest.py的fixture里,而不是散落在各个用例中,否则维护起来非常痛苦。

4.2 时间不“冻结”,断言就永远在赌运气

时间相关的Flaky是最阴的,因为问题通常看起来不像问题。比如断言“订单创建时间约等于当前时间”,数据库插入有几十毫秒延迟,断言一写死就经不起推敲;再比如某个定时任务执行耗时不稳定,有时0.2秒完成,有时2秒。

我常用的手段有三个:一是用freezegun这类库冻结时间,让被测代码看到的时钟完全可控;二是被测代码里支持注入Clock,测试时传固定时间;三是在断言里留出合理的时间容忍区间。轮询异步任务状态时,也要把超时时间设得合理,避免整个用例被全局等待拖成“看起来像超时”的假失败。

4.3 并发顺序同样要固定

除了数据随机,执行顺序的随机也值得固定。pytest-randomly支持传种子:

# 固定随机顺序,便于回放疑似顺序依赖问题 pytest tests/ -q --shuffle --shuffle-seed=42

固定顺序的价值不是让测试去依赖这个顺序,而是当你怀疑某个用例和顺序有关时,有一个回放现场的能力。顺序依赖的根治还是回到技巧一,但能够稳定复现是迈出修复的第一步。

5. 技巧三:用条件等待替代固定sleep,告别“看运气”

5.1 固定等待:看似简单,实则脆弱

time.sleep(3)是我见过最泛滥、也最害人的写法。它有一个隐含假设:某件事一定会在3秒内发生。可现实是,机器快的时候1秒就够,你白等2秒;机器慢的时候5秒才完成,你在第3秒的断言就炸了。等你把时间加到10秒,又拖得整个测试套件像老牛拉车。

用生活里的例子类比:固定等待等于和人约时间时说“我3分钟后出门”,而条件等待是“你微信告诉我已经下楼了我再出发”。前者靠猜,后者靠信号。

5.2 显式等待的正确姿势

Selenium里的显式等待是标准答案:

from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "submit-success")) )

这个等待的逻辑是“盯着元素直到出现,10秒内没等到就报超时”。它不是傻等10秒,而是每500毫秒检查一次,元素一出现立刻继续。接口测试里同样如此,提交一个异步任务后立即查结果,很容易撞上“任务还没执行完”,这时候轮询就是正确的打开方式:

def wait_until_done(task_id, timeout=30): deadline = time.time() + timeout while time.time() < deadline: status = requests.get(f"/tasks/{task_id}").json()["status"] if status in ("success", "failed"): return status time.sleep(0.5) raise TimeoutError(f"Task {task_id} timeout")

注意轮询的间隔不要小于0.5秒,太频繁的请求本身就是一种给服务器增加压力的不稳定因素。

5.3 两种等待混用,超时会更难查

Selenium还有一个特别常见的坑:隐式等待和显式等待混着用。隐式等待会给每次元素查找都加一个全局轮询,显式等待又自己独立轮询一遍,两个叠加之后,超时时间会变成“两者之和”。我见过一个用例,明明设置了5秒超时,实际等了10秒才报错,排查时让人一头雾水。

我的建议是,一个项目里只保留一种等待策略。比较推荐的方式是:全局关闭隐式等待,所有关键路径统一使用显式等待,并且给每个等待都设置明确的超时时间。这样既稳定,又能在失败时给出清晰超时报错。

6. 技巧四:资源边界划清楚,并发不是越大越好

6.1 共享环境是并发Flaky的温床

当多个worker并发跑同一个测试套件、共享同一个数据库的时候,各种莫名其妙的失败就来了。两个用例同时创建用户,唯一键偶尔冲突;一个用例做数据清理,正好把另一个刚刚创建的记录删掉;两个任务抢同一个端口,后启动的直接连接失败。

这些都是典型的资源竞争问题,和被测代码本身没有半点关系,但表现方式却千奇百怪,特别容易让人误判成业务Bug。

6.2 划清边界的三板斧:随机端口、独立库、临时文件

资源隔离有三板斧,都属于“做了就有效”的事情:

一是每个测试进程分独立的数据库schema,推荐用database结合pytest-xdist的fixture实现,测试跑完整个schema销毁,数据天然干净。二是服务端口不要写死8080之类,启动服务时用0端口让系统分配一个随机可用端口,从根上避免端口冲突。三是文件读写不要用固定路径,pytest的tmp_pathfixture就是干这个的,它给每个用例一个临时目录,用例结束自动清理。

这三件事都谈不上技术含量,但大部分团队的测试环境就是没做。只要能落地,环境类的Flaky通常能减少一半以上。

6.3 控制并发规模,别让CI变成压力测试

还有一类资源问题是自己“卷”出来的。有些团队追求极致速度,把并发拉到8、16,CI机器负载飙满,所有用例集体变慢,原本1秒的接口响应变成5秒,于是一大批超时型Flaky集中爆发。

CI环境不是性能测试环境。我个人的做法是:根据CI机器的实际规格定一个最大并发数,比如4核机器就跑4个worker,然后保持这个参数跑一周,观察超时率和成功率,再决定要不要上调。加并发之前先想清楚:节省的3分钟,值不值得用一整天的偶发失败去换。

6.4 容器化让环境问题一次性消失

更进一步的做法是环境容器化。把数据库、缓存、被测服务都用Docker容器拉起来,测试跑完整个环境销毁,下次再跑重新拉起,物理上保证环境一致。Java生态里的testcontainers可以直接在用例代码里临时起一个MySQL或Redis,测完自动销毁;Python生态也有类似的方案,比如docker-compose配合pytest的session级fixture。

容器化的成本主要在初期搭一遍环境,但换来的是“本地能过、CI就一定能过”的确定性,这笔账非常划算。

7. 技巧五:测试数据一例一档,用完即清

7.1 共享测试数据的三种死法

共享测试数据有三个常见死法:被别的用例改掉、被定时清理任务删掉、两个并发用例同时写同一条数据触发主键冲突。它们的共同点是,大家都在同一份可变数据上做操作,谁先动手谁就赢,整套执行顺序稍有变化,失败就跟着变。

处理思路其实很朴素:每个用例必须拥有只属于自己的数据,并且这份数据对其他用例不可见。

7.2 唯一前缀,让数据自带“身份证”

给数据打上唯一标识是最直接的实践。凡是测试创建的数据,名字里都带UUID前缀:

import uuid prefix = f"QA_{uuid.uuid4().hex[:8]}" username = f"{prefix}_user"

用factory_boy这类工具时,可以直接组合出永不重复的数据:

import factory from myapp.models import User class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User username = factory.Sequence(lambda n: f"user_{n}_{uuid.uuid4().hex[:6]}")

这样生成的每条数据都有独立身份,不会撞Key,也不会被别人清掉。断言的时候也不要再用“用户名等于固定值”这种写法,改成先把创建时生成的变量保存下来,再用这个变量做断言。

7.3 清理策略:先清、后用、即焚

创建数据之前,先按自己的标识前缀清一遍同类数据,避免历史残留;用例结束后,在teardown里删掉自己创建的数据,这就是“用完即焚”。

如果数据库支持事务,还有一个更好的玩法:把用例内的所有数据库操作包进一个事务,测试结束直接回滚,数据根本不落库。这样既不会留脏数据,速度也快得多。

需要提醒的是,数据创建方式的取舍也要讲究。关键业务链路的数据尽量走API创建,更贴近用户真实路径;大批量准备的数据直接走SQL直插,速度快。最不推荐的是所有数据都靠UI跑一遍,又慢又不稳定,纯属给自己制造Flaky。

8. 技巧六:重试可以缓冲偶发,也会掩盖真Bug

8.1 哪些场景值得重试

重试机制并不是洪水猛兽。某些外部依赖的偶发问题,比如第三方服务网络抖动、容器刚启动还没就绪、短暂的连接超时,重试一次往往就好了。这时重试起到了缓冲作用,避免了因为基础设施抖动导致整条流水线变红。

pytest里有现成的插件pytest-rerunfailures,可以直接给用例加标记:

import pytest @pytest.mark.flaky(reruns=2, reruns_delay=5) def test_third_party_payment_notify(): ...

8.2 哪些场景绝不重试

到处都能重试,就是最大的危险:断言失败绝不重试。如果断言失败了还能通过重试变绿,那你永远得不到真实的失败原因。数据断言失败也一样,多半是数据被污染,重试只会把污染源头继续藏下去。

我不赞成开启全局重试,那是用“运气”换“绿”,本质上还是自欺欺人。真正正确的姿势是:重试只给明确标记过的用例开,并且每次重试成功都要留痕。

8.3 建立重试白名单,而不是一键全开

我在团队里维护了一个“重试白名单”机制。只有标记了known_flaky的用例才允许重试,而且每次重试成功后,必须输出一行带标记的日志,比如:

[FLAKY-RETRY-SUCCESS] test_payment_flow

这行日志会被收集脚本汇总,进入每周的Flaky评审清单,相关人员必须在限期内修复。这样既保留了重试缓解偶发问题的价值,又不会让重试变成掩盖问题的遮羞布。重试是缓冲垫,不是免死金牌,这个边界一定要清楚。

9. 技巧七:把Flaky Test当作一等缺陷去跟踪

9.1 让每次重试成功都变成一条可见记录

很多团队用了重试机制之后,失败的用例重跑通过了,大家都松了一口气,没人记录,也没人跟进。结果是同一个用例反复flaky,反复重跑,问题永远躺在那里。

解决办法就是让Flaky“显形”。重试成功的用例必须打印唯一标记、测试名、失败原因,脚本每天从CI日志里自动拉取这些记录。别小看这一步,它能把原本隐藏在流水线里的假失败全部摆到桌面上来。你连问题都看不见,后面的一切治理都是空谈。

9.2 Dashboard和失败自动分类

有了原始记录后,下一步是汇总成Dashboard。不需要上什么重平台,一张统计表就够了,包含:用例名、失败次数、首次发现时间、最近失败时间、自动归类、负责人。每天早上自动跑一遍,生成Top Flaky列表推送到团队群。

失败自动分类也可以交给脚本做。根据异常类型和日志关键字,把问题分进“等待超时”“唯一键冲突”“元素未找到”“连接超时”这些桶里,然后自动打标签。这样排查效率会提高很多,尤其是有多个分支并行跑的时候,人工根本看不过来。

9.3 修复优先级和硬性闭环

我采用的修复策略是三层:当天能修的立刻修,进不了当天的排进本周迭代,涉及框架层面改造的单独立项。关键是规则要硬,不能靠自觉。

我见过治理效率最高的团队,用的是一套“断红”机制:连续三个版本出现在Top Flaky列表且无人认领的用例,直接禁止它所在模块通过发布门禁。这个规则看起来很粗暴,但效果立竿见影——没有人愿意让整个模块卡在自己手上,于是flaky的修复优先级立刻上来了。再配一个指标,比如月度Flaky率不超过0.5%,超了就冻结该团队新增UI用例,治理会推进得非常快。自动化的意义是给人提供安全感,而不是变成新的焦虑源。

10. 技巧八:UI层做减法,接口层做加法

10.1 UI自动化天生脆弱,稳定要靠“设计”

UI自动化之所以Flaky高发,是因为它依赖三层东西:浏览器渲染、网络、资源加载。任何一层抖动都可能让测试失败。但这不代表UI自动化不该写,而是应该认清它的定位:UI层适合做关键路径的冒烟验证,不适合把所有业务断言都堆在上面。

给UI测试做稳定化有几招很常用:用>

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

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

立即咨询