测试时好时坏(flaky test)是怎么回事?怎么定位和根治?
简化版
flaky test(不稳定测试)是指「代码没改,同一个测试有时通过有时失败」——它的危害比「一直失败的测试」大得多:它会摧毁团队对测试的信任,一旦大家习惯了「挂了?重跑一下就好」,真正的 bug 也会被当成 flaky 而被无视,测试套件就彻底失去了守门的价值。成因可以归为六类:① 时间(依赖当前时间、sleep 等固定时长、跨零点/月末的边界);② 随机(未固定种子的随机数、UUID、哈希顺序);③ 顺序依赖(测试 A 留下的数据/全局状态被测试 B 依赖或干扰——最常见也最隐蔽);④ 并发(共享资源竞争、端口冲突、pytest-xdist 下多进程抢同一份数据);⑤ 外部依赖(真实网络、第三方 API、真实时钟、文件系统);⑥ 资源与环境(CI 机器比本地慢导致超时、内存/磁盘不足、时区和 locale 不同)。定位的核心手段是「制造复现条件」:用 pytest-randomly 打乱顺序能暴露顺序依赖、用 pytest -p no:randomly --count=50(pytest-repeat)反复跑能抓概率性问题、用 -x --lf 缩小范围、并且在 CI 里记录随机种子以便复现。根治的原则是「每个测试自己准备、自己清理、不依赖外部世界」:时间用 freezegun 冻结、随机固定种子、数据库用事务回滚隔离、外部服务用 respx/responses 拦截、等待用「轮询直到条件成立」而不是 sleep。最后一条纪律:发现 flaky 要么当天修,要么用 @pytest.mark.flaky 显式标记并建 issue 跟踪——绝不能靠「重跑」蒙混过关。核心记忆:flaky 摧毁的是对测试的信任;六类成因,顺序依赖最常见;用随机顺序 + 重复运行暴露;别用 sleep,别靠重跑。
详细版
六类成因与对策速查:
| 成因 | 典型症状 | 对策 |
|---|---|---|
| 时间 | 月末/跨零点失败 | freezegun 冻结时间 |
| 随机 | 偶发、无规律 | 固定 seed |
| 顺序依赖 | 单跑过、全跑挂 | pytest-randomly 暴露 |
| 并发 | 加 -n auto 后开始挂 | 资源隔离 |
| 外部依赖 | CI 挂、本地过 | mock 掉网络 |
| 资源/环境 | 只在 CI 挂 | 放宽超时、对齐环境 |
# ① ★★时间:用 freezegun 冻结★★
from freezegun import freeze_time
# ✗ 依赖当前时间(★月末、跨年、闰年都可能挂★)
def test_expiry():
token = create_token(days=30)
assert token.expires_at.month == datetime.now().month + 1 # ★12 月挂★
# ✓
@freeze_time("2026-01-15 10:00:00")
def test_expiry():
token = create_token(days=30)
assert token.expires_at == datetime(2026, 2, 14, 10, 0)
# ★时间推进★
with freeze_time("2026-01-01") as frozen:
obj = create()
★frozen.tick(delta=timedelta(days=31))★ # ★★推进时间,不用 sleep★★
assert obj.is_expired()
# ② ★★随机:固定种子★★
@pytest.fixture(autouse=True)
def fixed_seed():
random.seed(42)
np.random.seed(42)
# ★pytest-randomly 会自动为每次运行设种子并打印★
# ★★失败时复现:pytest -p randomly --randomly-seed=12345★★
# ③ ★★顺序依赖:最常见的元凶★★
# ✗ 测试 A 建了数据,测试 B 依赖它存在
def test_create_user():
User.objects.create(name="alice") # ★★没清理★★
def test_list_users():
assert User.objects.count() == 1 # ★★依赖 A 先跑★★
# ✓ 每个测试自己准备
@pytest.fixture
def user(db):
return User.objects.create(name="alice") # ★事务回滚自动清理★
def test_list_users(user):
assert User.objects.count() == 1
# ★★暴露顺序依赖★★
# pip install pytest-randomly ← ★★每次运行打乱顺序★★
# pytest -p no:randomly ← 临时关闭
# ④ ★全局状态污染★
# ✗
CACHE = {}
def test_a(): CACHE["k"] = 1
def test_b(): assert "k" not in CACHE # ★★取决于 a 有没有先跑★★
# ✓ autouse fixture 清理
@pytest.fixture(autouse=True)
def clear_state():
yield
★CACHE.clear()★
★app.dependency_overrides.clear()★
★cache.clear()★
★some_module._singleton = None★
# ⑤ ★★别用 sleep,用轮询★★
# ✗ 固定等待(★本地够、CI 慢就挂;而且平白拖慢测试★)
def test_async_job():
start_job()
★time.sleep(2)★
assert job_done()
# ✓ 轮询直到条件成立
def wait_until(cond, timeout=10, interval=0.05):
deadline = time.monotonic() + timeout # ★★用 monotonic 不受系统时间影响★★
while time.monotonic() < deadline:
if cond():
return True
time.sleep(interval)
raise TimeoutError(f"条件在 {timeout}s 内未满足")
def test_async_job():
start_job()
★wait_until(job_done, timeout=10)★ # ★快时立刻返回,慢时也不误判★
# ⑥ ★外部依赖:拦截掉★
import respx, httpx
@respx.mock
def test_call_api():
respx.get("https://api.example.com/user").mock(
return_value=httpx.Response(200, json={"id": 1}))
assert fetch_user(1)["id"] == 1
# ⑦ ★★标记已知的 flaky(临时手段)★★
@pytest.mark.flaky(reruns=3, reruns_delay=1) # ★pytest-rerunfailures★
def test_known_flaky(): ...
# ★★必须配 issue 编号和时限,否则会永远留在那★★
⚠️ 三个必须记住的点:① flaky test 真正的危害是「摧毁信任」,而不是浪费的那几分钟。当团队里形成了「CI 挂了?重跑一下」的条件反射,真实的回归缺陷也会被当成 flaky 忽略掉——测试套件从「安全网」退化成「偶尔响的警报器」,最终没人再看它。所以对待 flaky 的纪律应该和对待生产 bug 一样:当天定位,要么修掉、要么显式标记并建 issue 跟踪,绝不能默许「重跑通过就算过」。② 顺序依赖是最常见也最隐蔽的成因,典型症状是「单独跑这个测试能过,跑整个套件就挂」或者反过来。根源是测试之间通过某种全局状态耦合了:数据库里的残留数据、模块级的缓存字典、
app.dependency_overrides没清理、monkeypatch 没还原、单例对象被改了状态。装pytest-randomly让每次运行的顺序都不同,是最有效的暴露手段——它会把「隐藏的顺序依赖」变成「稳定复现的失败」。③time.sleep(n)是 flaky 的头号制造机。固定等待时长面临两难:设短了在较慢的 CI 机器上会失败,设长了整个测试套件被无谓拖慢(几百个测试各 sleep 2 秒就是十几分钟)。正确做法是轮询等待条件成立(wait_until(cond, timeout=10))——条件满足就立刻返回,慢的时候也有足够的余量;并且用time.monotonic()而不是time.time()计时,避免系统时间被 NTP 调整时出现负数间隔。
完整版教学
一、为什么 flaky 比失败更糟
★ ★三种测试状态的危害对比★:
┌──────────────────┬────────────────────────────────────┐
│ ★稳定通过★ │ 理想 │
│ ★稳定失败★ │ ★很好——它明确告诉你有问题★ │
│ ★★时好时坏★★ │ ★★最糟——它训练团队忽略测试★★ │
└──────────────────┴────────────────────────────────────┘
★ ★flaky 的传染链(★真实发生的过程★)★:
① 某个测试偶尔挂 → 大家发现「重跑就好」
② 形成条件反射:★CI 红了先点 retry★
③ ★真实的回归 bug 也被 retry 掉了★
④ ★有人开始给测试加 @flaky(reruns=5) 图省事★
⑤ ★更多测试变得不稳定(因为没人再关心)★
⑥ ★最终:CI 变成走过场,缺陷靠线上发现★
★ ★关键节点在 ②→③★
★ ★量化 flaky 的成本★:
假设 CI 每次 15 分钟,flaky 率 5%
★ 每天 40 次构建 → 2 次误报 → 30 分钟白等
★ 但真正的成本是:
- ★开发者的注意力被打断★
- ★对失败结果的怀疑("是不是又 flaky")★
- ★真 bug 被延迟发现★
★ ★怎么度量★:
① ★记录每个测试的历史通过率★(CI 平台通常有,或自己收集 junit xml)
② ★定义阈值★:同一 commit 下失败过又成功过 = flaky
③ ★建 flaky 看板★,把 top N 列出来定期清理
★ ★没有度量就没有治理★
★ ★团队纪律(★比技术手段更重要★)★:
✓ ★发现 flaky → 当天建 issue + 标记★
✓ ★标记必须带 issue 号和到期时间★
✓ ★定期(如每两周)清理 flaky 列表★
✓ ★禁止无理由的 rerun★(CI 上关掉一键重试,或要求填原因)
✗ ★"先合了再说,回头修"★ → 永远不会修
★ ★flaky 的两种处理方式★:
① ★立即修★(首选)
② ★★隔离★★:
@pytest.mark.flaky # ★自定义标记★
→ CI 里分成两个 job:
- ★主 job:跑稳定测试,红了就必须处理★
- ★flaky job:允许失败,但结果要可见★
✓ ★保住了主 CI 的可信度★
✗ ★别让 flaky job 永远存在★
flaky 比「稳定失败」糟糕得多——稳定失败会明确告诉你有问题,而时好时坏会训练团队忽略测试。传染链的关键节点在「形成 retry 条件反射」到「真 bug 也被 retry 掉」这一步:一旦跨过去,测试套件就从安全网退化成了走过场。治理的前提是度量——记录每个测试的历史通过率,把「同一 commit 下失败过又成功过」定义为 flaky,建看板定期清理。团队纪律比技术手段更重要:发现当天建 issue、标记必须带 issue 号和到期时间、禁止无理由的 rerun。如果一时修不掉,正确的做法是「隔离」而不是「加 reruns」——把 flaky 测试分到独立的 CI job(允许失败但结果可见),保住主 CI 的可信度。
二、六类成因与识别
★ ★① 时间相关★
症状:★特定日期失败★(月末、跨年、闰年 2/29、夏令时切换)
典型代码:
✗ assert result.date() == date.today() # ★跨零点时挂★
✗ assert (end - start).seconds < 1 # ★慢机器上挂★
✗ expires = datetime.now() + timedelta(days=30)
assert expires.month == datetime.now().month + 1 # ★12 月挂★
★ 特别隐蔽的:★时区★
- ★CI 机器 UTC、本地 UTC+8 → 跨天判断不同★
- ★数据库存 UTC、断言用本地时间★
✓ ★freezegun / time-machine 冻结★
✓ ★或注入时钟:def f(now=None): now = now or datetime.now(UTC)★
★ ★② 随机性★
症状:★偶发失败,无明显规律★
来源:
- random / secrets / uuid4
- ★字典和集合的迭代顺序★(3.7+ dict 保序了,★但 set 仍然无序★)
- ★PYTHONHASHSEED★(影响 str 的 hash → set 顺序)
- ★浮点运算的累积误差★
✓ 固定种子 + ★pytest-randomly 会打印本次种子★
✓ ★断言集合时用 sorted() 或 set 比较,不要比 list★
✗ assert list(result_set) == ["a", "b"]
✓ assert sorted(result_set) == ["a", "b"]
✓ assert result_set == {"a", "b"}
★ ★★③ 顺序依赖(最常见)★★
症状:★★单跑过、全跑挂;或者换个顺序就挂★★
来源:
- ★数据库残留数据★(没清理/没回滚)
- ★模块级的可变全局★(缓存字典、单例、注册表)
- ★monkeypatch 没还原★(pytest 的 monkeypatch fixture 会自动还原,
★但手动 setattr 不会★)
- ★app.dependency_overrides / Flask 的 app.config 没清★
- ★环境变量被某个测试改了★
- ★logging 的 handler 被重复添加★
✓ ★pytest-randomly(每次打乱)★
✓ ★pytest-xdist -n auto(分布到多进程,天然打乱)★
✓ ★二分定位:pytest --lf 之后逐步缩小范围★
★ ★④ 并发★
症状:★加了 -n auto 之后开始挂★
来源:
- ★多进程共用同一个数据库/文件/端口★
- ★共享的临时目录★
- ★真实的多线程代码本身有竞态★(★这时 flaky 是在报真 bug!★)
✓ ★每个 worker 用独立资源★:
# conftest.py
@pytest.fixture(scope="session")
def db_url(worker_id): # ★xdist 提供 worker_id★
if worker_id == "master":
return "postgresql://.../test"
return f"postgresql://.../test_{worker_id}" # ★★每 worker 一个库★★
✓ ★端口用 0 让系统分配★
✓ ★tmp_path fixture(pytest 自动隔离)★
★ ★⑤ 外部依赖★
症状:★CI 挂、本地过;或者偶发超时★
来源:真实网络请求、第三方 API、DNS、真实时钟服务
✓ ★全部 mock 掉★(respx / responses / pytest-httpx)
✓ ★契约测试代替端到端★
✓ ★确实要连外部 → 单独的 job,允许失败★
★ ★⑥ 资源与环境★
症状:★只在 CI 挂★
来源:
- ★CI 机器慢 → 超时★
- ★内存/磁盘不足★
- ★locale / 时区 / 文件系统大小写敏感性不同★
(★macOS 不区分大小写、Linux 区分★)
- ★文件路径分隔符★
✓ ★超时留足余量(3~5 倍)★
✓ ★CI 和本地用同一个容器镜像★
✓ ★显式设置 TZ、LANG、PYTHONHASHSEED★
六类成因中:时间类要注意时区(CI 是 UTC、本地是 UTC+8,跨天判断会不同);随机类除了 random 还有 set 的迭代顺序和 PYTHONHASHSEED——断言集合时要用 sorted() 或直接比较 set,不要比 list;顺序依赖最常见,典型症状是「单跑过、全跑挂」,来源包括数据库残留、模块级可变全局、手动 setattr 没还原(pytest 的 monkeypatch fixture 会自动还原,手动的不会)、dependency_overrides 没清;并发类要注意——如果被测代码本身有竞态,这时 flaky 其实是在报真 bug;xdist 下要给每个 worker 独立的数据库(用 worker_id fixture)。环境类的隐蔽点是 macOS 文件系统不区分大小写而 Linux 区分。
三、定位手段
★ ★★核心思路:把"偶尔失败"变成"稳定复现"★★
★ ★手段一:反复运行★
pip install pytest-repeat
pytest ★--count=50★ tests/test_x.py::test_flaky
pytest --count=50 ★-x★ # ★第一次失败就停★
★ ✓ 抓概率性问题(随机、并发)
★ ✗ 抓不到顺序依赖(单个测试反复跑)
★ ★★手段二:打乱顺序(抓顺序依赖)★★
pip install pytest-randomly
pytest # ★默认就会打乱★
pytest ★-p no:randomly★ # 关闭
pytest ★--randomly-seed=12345★ # ★★用特定种子复现★★
★ ★CI 里一定要打印种子★(失败时才能复现)
★ ★手段三:二分定位(找出是哪两个测试冲突)★
# ① 先确认是顺序问题
pytest tests/test_x.py::test_flaky # 单跑 → 过
pytest tests/ # 全跑 → 挂
# ② 二分
pytest tests/ ★-x --lf★ # 从上次失败开始
# ③ 精确定位
pytest tests/test_a.py tests/test_x.py # ★手工组合试★
# ④ ★工具★
pip install pytest-random-order
pytest ★--random-order-bucket=global★
★ ★手段四:查看状态泄漏★
@pytest.fixture(autouse=True)
def detect_leak():
before = {
"env": dict(os.environ),
"cache_keys": set(CACHE.keys()),
"overrides": dict(app.dependency_overrides),
}
yield
after = {...}
★assert before == after, "测试泄漏了全局状态"★
★ ✓ ★主动暴露污染源★
★ ★手段五:CI 上的诊断信息★
# pytest.ini
[pytest]
addopts =
★-p no:cacheprovider★ # CI 上不用缓存
★--junitxml=report.xml★ # ★结构化报告,便于统计通过率★
★-ra★ # ★★显示所有非通过用例的原因★★
★--tb=short★
# ★失败时保存现场★
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
if rep.failed:
★logger.error("失败现场 seed=%s env=%s", SEED, os.environ.get("TZ"))★
★ ★手段六:并发相关的定位★
pytest ★-n 4★ # 开并发
pytest -n 4 ★--dist=loadfile★ # ★同文件的测试在同一 worker★
pytest ★-p no:xdist★ # 关掉对比
★ ★如果关掉并发就稳定 → 资源隔离问题★
★ ★如果是被测代码的竞态 → 这是真 bug,别改测试★
★ ★★定位流程图★★:
测试偶尔失败
├─ ★单跑稳定、全跑挂★ → ★顺序依赖/状态污染★
│ → pytest-randomly + 二分
├─ ★单跑也偶尔挂★ → ★随机/时间/并发/外部依赖★
│ → --count=50 复现
├─ ★只在 CI 挂★ → ★环境/性能/超时★
│ → 对比环境变量、放宽超时、用同一镜像
└─ ★加了 -n 才挂★ → ★资源共享或真实竞态★
定位的核心思路是「把偶尔失败变成稳定复现」。五种手段各有针对:pytest-repeat --count=50 抓概率性问题(但抓不到顺序依赖)、pytest-randomly 打乱顺序抓顺序依赖(CI 里一定要打印种子,否则失败了没法复现)、二分定位找出是哪两个测试冲突、用 autouse fixture 主动检测状态泄漏(比较测试前后的环境变量、缓存、overrides)、以及 CI 上配 -ra 和 --junitxml 保留诊断信息。那张定位流程图很实用:单跑稳定全跑挂 → 顺序依赖;单跑也偶尔挂 → 随机/时间/并发;只在 CI 挂 → 环境或超时;加了 -n 才挂 → 资源共享或真实竞态(后者是被测代码的 bug,别去改测试)。
四、根治手段
★ ★原则:每个测试自己准备、自己清理、不依赖外部世界★
★ ★① 时间:注入或冻结★
# ★方案一:freezegun(最省事)★
@freeze_time("2026-01-15")
def test_x(): ...
# ★方案二:time-machine(更快,C 实现)★
# ★方案三:依赖注入(★最干净但要改生产代码★)★
class Service:
def __init__(self, clock=lambda: datetime.now(timezone.utc)):
self._clock = clock
# 测试:Service(clock=lambda: datetime(2026,1,15, tzinfo=timezone.utc))
★ ★统一用 UTC 的 aware datetime,别用 naive★
★ ★② 随机:固定种子 + 断言不依赖顺序★
@pytest.fixture(autouse=True)
def _seed():
random.seed(0); np.random.seed(0)
# ★断言集合★
✗ assert result == ["b", "a"] # ★顺序不确定★
✓ assert sorted(result) == ["a", "b"]
✓ assert set(result) == {"a", "b"}
✓ assert Counter(result) == Counter(["a","b"]) # ★考虑重复★
★ ★★③ 数据库:事务回滚隔离★★
@pytest.fixture
def db_session(engine):
conn = engine.connect()
trans = conn.begin() # ★★外层事务★★
session = Session(bind=conn)
yield session
session.close()
★trans.rollback()★ # ★★丢弃所有改动★★
conn.close()
★ ✓ 每个测试完全隔离
★ ✗ ★被测代码 commit 就破功★ → 用 SAVEPOINT 嵌套事务
★ 或者:★每个测试 truncate 所有表★(慢但可靠)
★ ★④ 全局状态:autouse 清理★
@pytest.fixture(autouse=True)
def _reset():
yield
cache.clear()
app.dependency_overrides.clear()
★importlib.reload(some_module)★ # ★慎用★
★logging.getLogger().handlers.clear()★
★ ✓ ★用 monkeypatch fixture 而不是手动 setattr(自动还原)★
def test_x(monkeypatch):
monkeypatch.setenv("KEY", "v") # ★★测试结束自动还原★★
monkeypatch.setattr(mod, "f", fake)
★ ★★⑤ 等待:轮询而不是 sleep★★
def wait_until(cond, timeout=10, interval=0.05, msg=""):
deadline = ★time.monotonic()★ + timeout
last_err = None
while time.monotonic() < deadline:
try:
if cond(): return
except Exception as e:
last_err = e
time.sleep(interval)
raise AssertionError(f"超时:{msg} 最后错误={last_err}")
# ★异步版★
async def await_until(cond, timeout=10, interval=0.05):
async with asyncio.timeout(timeout): # ★3.11+★
while not await cond():
await asyncio.sleep(interval)
★ ★为什么用 monotonic:不受 NTP 校时影响★
★ ★⑥ 外部服务:拦截★
# HTTP
@respx.mock # httpx
@responses.activate # requests
# ★时间之外的系统调用★
monkeypatch.setattr(os, "urandom", lambda n: b"\x00" * n)
# ★消息队列/Celery★
celery_app.conf.task_always_eager = True # ★同步执行★
★ ★⑦ 并发:资源隔离★
# ★xdist 下每个 worker 独立资源★
@pytest.fixture(scope="session")
def redis_db(worker_id):
idx = 0 if worker_id == "master" else int(worker_id.lstrip("gw")) + 1
return f"redis://localhost:6379/{idx}" # ★★不同 db 号★★
# ★端口用 0★
sock.bind(("127.0.0.1", 0)); port = sock.getsockname()[1]
# ★临时目录用 tmp_path(pytest 自动隔离)★
★ ★⑧ 超时:留足余量★
✗ assert elapsed < 0.1 # ★CI 上必挂★
✓ ★性能断言放到专门的 benchmark 测试里★,功能测试不断言耗时
✓ 必须有超时时:★用宽松的上限(3~5 倍本地值)★
pytest ★--timeout=60★ # pytest-timeout:防止卡死
根治的八个手段对应六类成因。时间最省事的是 freezegun,最干净的是依赖注入时钟(但要改生产代码);统一用 UTC 的 aware datetime。断言集合时用 sorted()、set 或 Counter(后者考虑重复元素)。数据库用事务回滚隔离(注意被测代码 commit 会破功,要用 SAVEPOINT)。全局状态用 autouse fixture 清理,并且优先用 monkeypatch fixture 而不是手动 setattr(前者会自动还原)。等待一律用轮询,并且用 time.monotonic() 计时(不受 NTP 校时影响)。xdist 下要给每个 worker 独立资源(不同的数据库、不同的 Redis db 号),端口用 0 让系统分配,临时目录用 tmp_path。最后 功能测试里不要断言耗时——性能断言应该放到专门的 benchmark 测试里。
五、CI 与团队实践
★ ★CI 配置要点★:
# ① ★固定环境★
env:
TZ: UTC
LANG: C.UTF-8
★PYTHONHASHSEED: "0"★ # ★消除 hash 随机化★
# ② ★打印诊断信息★
- run: pytest ★-ra --junitxml=report.xml --timeout=120★
# ③ ★上传报告用于统计★
- uses: actions/upload-artifact@v4
with: {name: test-report, path: report.xml}
★ ★★是否该开自动重试★★:
# pytest-rerunfailures
pytest ★--reruns 2 --reruns-delay 1★
★ ✓ 支持:★大型套件里少量已知的外部依赖抖动★
★ ✗ 反对:★它会掩盖问题、让 flaky 永远存在★
★ ★折中方案(推荐)★:
① ★默认不开全局重试★
② ★只给显式标记的测试重试★:
@pytest.mark.flaky(reruns=3)
③ ★★重试成功也要在报告里可见并计入统计★★
④ ★定期审查重试列表★
★ ★关键:让 flaky 可见,而不是让它消失★
★ ★分层 CI(★保住主 CI 可信度★)★:
# ★job 1:稳定测试(红了必须处理)★
pytest -m "not flaky and not slow"
# ★job 2:flaky 测试(允许失败,但结果可见)★
pytest -m flaky ★|| true★
# ★job 3:慢测试/集成测试(nightly)★
pytest -m slow
★ ★标记的规范★:
# pytest.ini
markers =
flaky: 已知不稳定的测试(★必须带 issue 号★)
slow: 慢测试
integration: 需要外部依赖
# 用法
★@pytest.mark.flaky(reason="偶发超时,见 #1234,2026-09 前修复")★
★ ★没有 issue 号和期限的标记 = 永久债务★
★ ★度量与治理★:
① ★收集 junit xml,统计每个测试的通过率★
② ★定义:同一 commit 下有过失败又有过成功 = flaky★
③ ★看板展示 top 10 flaky 测试★
④ ★每个迭代分配固定时间清理★
⑤ ★新增 flaky 标记要 code review 时质疑★
★ ★写测试时就预防(★最划算★)★:
□ ★不依赖测试执行顺序★
□ ★不共享可变状态★
□ ★不用真实时间/随机/网络★
□ ★不用 sleep 等固定时长★
□ ★不断言集合的顺序★
□ ★不断言精确耗时★
□ ★每个测试自己准备数据★
□ ★用完的资源自己清理★
★ ★特别提醒:flaky 可能在报真 bug★:
★ 并发测试偶发失败 → ★可能是被测代码真的有竞态★
★ 依赖时间的测试在特定时刻挂 → ★可能是真的时区/边界 bug★
★ ★别急着"稳定化",先确认不是产品缺陷★
★ ★把测试改稳定 ≠ 把 bug 修了★
CI 配置要固定环境(TZ、LANG、PYTHONHASHSEED=0)并保留诊断信息(-ra、--junitxml)。关于自动重试的争议:折中方案是「默认不开全局重试、只给显式标记的测试重试、重试成功也要在报告里可见、定期审查」——关键是让 flaky 可见而不是让它消失。分层 CI 能保住主 CI 的可信度:稳定测试红了必须处理、flaky 测试允许失败但结果可见、慢测试放 nightly。标记必须带 issue 号和期限——没有这两样的标记就是永久债务。最后一个重要提醒:flaky 可能在报真 bug——并发测试偶发失败可能是被测代码真有竞态、依赖时间的测试在特定时刻挂可能是真的时区 bug,别急着「稳定化」,把测试改稳定不等于把 bug 修了。
六、实践清单
★ ★遇到 flaky 的处理流程★:
① ★确认是 flaky★(同一 commit 有过成功也有过失败)
② ★★先问:是不是真 bug★★(并发/时区/边界)
③ ★复现★:--count=50 / pytest-randomly / 记录种子
④ ★归类★:时间/随机/顺序/并发/外部/环境
⑤ ★修复★(见上面的对应手段)
⑥ ★验证★:--count=100 连跑不挂
⑦ ★如果当下修不了 → 标记 + issue + 期限 + 隔离到独立 job★
★ 预防清单(★写测试时就做★):
□ ★装 pytest-randomly(让顺序依赖立刻暴露)★
□ ★装 pytest-timeout(防止卡死)★
□ ★时间用 freezegun★
□ ★随机固定种子★
□ ★数据库事务回滚隔离★
□ ★autouse fixture 清理全局状态★
□ ★monkeypatch 而不是手动 setattr★
□ ★等待用轮询 + monotonic★
□ ★外部服务全 mock★
□ ★xdist 下资源按 worker 隔离★
□ ★断言集合用 sorted/set/Counter★
□ ★功能测试不断言耗时★
□ ★CI 固定 TZ/LANG/PYTHONHASHSEED★
★ ★常用工具速查★:
┌──────────────────────┬────────────────────────────┐
│ ★pytest-randomly★ │ ★打乱顺序,暴露顺序依赖★ │
│ ★pytest-repeat★ │ --count=N 反复跑 │
│ ★pytest-timeout★ │ ★防止单个测试卡死★ │
│ ★pytest-xdist★ │ 并发(也能暴露隔离问题) │
│ pytest-rerunfailures │ ★重试(★慎用★)★ │
│ ★freezegun / time-machine★│ ★冻结时间★ │
│ ★respx / responses★ │ ★拦截 HTTP★ │
│ ★pytest-socket★ │ ★★禁止真实网络(很有用)★★ │
└──────────────────────┴────────────────────────────┘
★ ★pytest-socket:一劳永逸禁掉网络★:
# pytest.ini
addopts = ★--disable-socket --allow-unix-socket★
# 需要网络的测试显式放行
@pytest.mark.enable_socket
def test_real_api(): ...
★ ✓ ★任何忘记 mock 的网络调用会立刻报错★
★ ✓ ★从根上消灭"外部依赖类 flaky"★
★ 一句话总结:
★"flaky test 真正的危害是摧毁团队对测试的信任——
一旦形成『重跑就好』的条件反射,真 bug 也会被忽略;
六类成因里顺序依赖最常见(单跑过全跑挂),
用 pytest-randomly 打乱顺序 + --count 反复跑来稳定复现;
根治靠『自己准备、自己清理、不依赖外部世界』,
别用 sleep 用轮询,别靠重试要靠隔离和度量。"★
处理流程里第二步「先问是不是真 bug」最容易被跳过。预防清单里两个高价值的工具:pytest-randomly(让顺序依赖立刻暴露)和 pytest-socket——后者能一劳永逸地禁掉真实网络,任何忘记 mock 的调用会立刻报错,从根上消灭「外部依赖类 flaky」。
记忆钩子:「★flaky test(代码没改但时好时坏)真正的危害不是浪费的那几分钟,而是摧毁团队对测试的信任★——传染链是『某测试偶尔挂 → 大家形成重跑就好的条件反射 → ★真实回归 bug 也被 retry 掉★ → 更多人加 reruns 图省事 → CI 变成走过场』,★关键节点在第二步到第三步★。★六类成因★:★① 时间★(月末跨年闰年、★CI 是 UTC 本地是 UTC+8 导致跨天判断不同★)→ freezegun 冻结或注入时钟;★② 随机★(random、★set 的迭代顺序★、PYTHONHASHSEED)→ 固定种子 + ★断言集合用 sorted/set/Counter 而不是比 list★;★③ 顺序依赖(最常见也最隐蔽)★——典型症状是★『单跑过、全跑挂』★,来源有数据库残留、模块级可变全局、★手动 setattr 没还原(monkeypatch fixture 会自动还原)★、dependency_overrides 没清 → ★装 pytest-randomly 让每次顺序都不同是最有效的暴露手段★;★④ 并发★(xdist 下抢同一份资源)→ 按 worker_id 隔离数据库/Redis db 号,★端口用 0 让系统分配★;★⑤ 外部依赖★ → respx/responses 拦截,★更彻底的是 pytest-socket 禁掉真实网络,任何忘记 mock 的调用立刻报错★;★⑥ 资源环境★(CI 慢导致超时、★macOS 文件系统不区分大小写而 Linux 区分★)。★定位的核心思路是把『偶尔失败』变成『稳定复现』★:★—count=50 抓概率性问题(但抓不到顺序依赖)★、★pytest-randomly 抓顺序依赖(CI 里一定要打印种子否则没法复现)★、二分定位、★用 autouse fixture 比较测试前后的全局状态主动检测泄漏★。★流程图:单跑稳定全跑挂→顺序依赖;单跑也偶尔挂→随机/时间/并发;只在 CI 挂→环境或超时;加了 -n 才挂→资源共享或真实竞态★。★time.sleep(n) 是 flaky 的头号制造机★——设短了慢机器上挂、设长了拖慢整个套件,★要用轮询 wait_until(cond, timeout) 且用 time.monotonic() 计时(不受 NTP 校时影响)★。★关于自动重试的正确姿势:默认不开全局 reruns,只给显式标记的测试重试,重试成功也要在报告里可见并计入统计★——★关键是让 flaky 可见而不是让它消失★;更好的是★分层 CI(稳定测试红了必须处理、flaky 测试独立 job 允许失败)★。★标记必须带 issue 号和期限,否则就是永久债务★。★最后一个重要提醒:flaky 可能在报真 bug★——并发测试偶发失败可能是被测代码真有竞态,★把测试改稳定不等于把 bug 修了★。」
七、常见误区与追问
- 误区:测试偶尔挂无所谓,CI 上点一下重试就好。 这个习惯本身就是最大的问题。它会经历一条可预测的退化路径:先是「某个测试偶尔挂,重跑就好」,接着团队形成「CI 红了先点 retry」的条件反射,然后某次真实的回归缺陷也被 retry 掉了(因为没人再认真看失败信息),再然后有人为了省事直接给测试加
@flaky(reruns=5),最终整个 CI 变成走过场——缺陷只能靠线上发现。关键的转折点在第二步到第三步之间,而且这个转变是悄无声息的。所以对 flaky 的纪律应该和生产 bug 一样:当天定位、要么修掉、要么显式标记并建 issue 跟踪。如果一时修不了,正确的做法是隔离(把它移到独立的 CI job,允许失败但结果可见),而不是掩盖(加自动重试让它「看起来过了」)——前者保住了主 CI 的可信度,后者摧毁它。 - 误区:测试单独跑能过,说明测试本身没问题,是环境的锅。 「单跑过、全跑挂」是顺序依赖的典型症状,问题恰恰在测试自己身上。它说明这个测试(或它前面的某个测试)通过某种全局状态和别的测试耦合了。常见的耦合点有六个:数据库里的残留数据(前一个测试建的记录没清理,导致
count() == 1的断言在全跑时变成 2);模块级的可变全局(缓存字典、注册表、单例对象);手动setattr打的补丁没还原(pytest的monkeypatchfixture 会在测试结束自动还原,但你自己写mod.func = fake就不会);app.dependency_overrides或 Flask 的app.config没清理;环境变量被某个测试改了;logging 的 handler 被重复添加。定位方法是装pytest-randomly——它每次运行都打乱顺序,能把「隐藏的顺序依赖」变成「稳定复现的失败」,然后再二分定位是哪两个测试冲突。 - 误区:加个
time.sleep(2)等异步任务完成,简单又管用。 这是 flaky 的头号制造机,而且它同时带来两个问题。① 稳定性:固定的等待时长是在赌「操作一定能在 2 秒内完成」——本地开发机上够了,但 CI 的机器通常更慢、负载更高(尤其是并行跑多个 job 时),偶尔就会超过 2 秒然后失败;你把它改成 5 秒,过段时间又会遇到同样的问题。② 速度:如果套件里有 100 个这样的 sleep,每次跑测试就白等 200 秒——而绝大多数情况下操作在 50 毫秒内就完成了。正确做法是轮询等待条件成立:wait_until(lambda: job.is_done(), timeout=10, interval=0.05)——条件满足立刻返回(通常几十毫秒),同时给了 10 秒的宽松上限应对慢机器。两个细节:用time.monotonic()而不是time.time()计时(后者会被 NTP 校时影响,可能出现负的时间差);超时时把「最后一次检查的错误」也报出来,否则只知道超时不知道卡在哪。 - 误区:把 flaky 测试标记成
@pytest.mark.flaky(reruns=3)就算处理了。 这只是把问题藏起来,而且藏得很彻底。一旦加上重试,这个测试在报告里永远是绿的,没有人会再想起它——半年后你会发现代码库里有几十个reruns=3的标记,没人知道它们为什么在那儿、问题是否还存在,其中有些可能一直在掩盖真实的竞态缺陷。如果确实当下修不了,正确的做法有三条纪律:① 标记必须写清楚reason并带上 issue 编号和预计修复时间(@pytest.mark.flaky(reason="偶发超时,见 #1234,2026-09 前修复"))——没有 issue 号和期限的标记就是永久债务;② 重试成功也要在报告里可见并计入统计,让「这周有多少次重试」成为一个能看到的数字;③ 定期审查(每个迭代花固定时间清理 top N)。更好的方案是隔离而不是重试:把标记了 flaky 的测试放到独立的 CI job,允许它失败但结果可见——这样主 CI 保持了「红了就是真有问题」的可信度。 - 误区:测试在并发(
pytest -n auto)下偶尔失败,说明并发跑不安全,关掉并发就行。 要先分清是「测试的隔离问题」还是「被测代码的真实竞态」。前者是测试的锅:多个 worker 共用同一个数据库、同一个临时文件、同一个端口、同一个 Redis db——解法是按worker_id做资源隔离(每个 worker 一个数据库/一个 Redis db 号)、端口用 0 让系统分配、临时目录用tmp_path。但后者完全不同:如果被测代码本身有竞态条件(共享状态没加锁、非原子的读改写、连接池的线程安全问题),那么并发测试偶发失败其实是在正确地报告一个真实的生产缺陷——这时候关掉并发只是让你看不见它,而它在生产环境的高并发下一定会爆发。所以遇到「加了-n才挂」,第一步应该是判断失败的原因是「测试互相干扰」还是「被测逻辑本身不正确」,后者要去修产品代码而不是改测试。 - 追问:怎么系统地度量和治理 flaky? 三步。① 收集数据——让 CI 输出
--junitxml=report.xml并归档,或者用 CI 平台自带的测试报告功能;关键是要有每个测试用例的历史执行记录(哪次跑、什么结果、在哪个 commit 上)。② 定义指标——最实用的定义是「同一个 commit 下,某个测试既有过失败也有过成功」(比如同一 PR 的多次 CI 运行、或者开了重试的情况下),这就是 flaky;进一步可以算「flaky 率 = flaky 用例数 / 总用例数」和「每周因 flaky 导致的重跑次数」。③ 建立治理机制——做一个看板展示 top 10 flaky 测试及其失败频率;每个迭代分配固定时间(比如两小时)清理列表最上面的几个;新增@flaky标记时在 code review 里质疑(「为什么不能现在修?issue 建了吗?期限是什么时候?」)。核心逻辑是:没有度量就没有治理——如果 flaky 只存在于开发者的口口相传里,它永远不会被系统性地解决。 - 追问:
pytest-randomly会不会让测试结果不可复现? 正好相反,它是为了让问题可复现。它做两件事:① 每次运行时随机打乱测试顺序,从而暴露隐藏的顺序依赖;② 用一个种子初始化random、numpy.random等随机源,并且在输出的开头明确打印这个种子(Using --randomly-seed=1234567)。所以当某次运行失败时,你只要用pytest --randomly-seed=1234567就能完全复现那次的顺序和随机值——这比「顺序固定但偶尔挂」的情况好定位得多(后者你根本不知道该怎么复现)。关键是 CI 的日志里一定要保留这个种子(默认会打印,但要确保日志没被截断)。如果某次调试需要固定顺序,用-p no:randomly临时关掉即可。有个实践建议:新项目一开始就装上它——因为顺序依赖是逐渐累积的,早期发现的成本远低于套件长到几千个测试之后。 - 追问:
pytest-socket是什么?值得用吗? 非常值得,它能从根上消灭「外部依赖类 flaky」。它的作用是在测试期间禁用真实的网络连接:配置addopts = --disable-socket --allow-unix-socket之后,任何测试代码里发起真实 socket 连接的行为都会立刻抛出异常并明确报错(而不是慢慢超时或者偶尔失败)。这解决了一个很常见的隐患:你以为某个测试已经 mock 掉了所有外部调用,但实际上某条代码路径漏了——本地开发时因为能连上网所以测试通过,等到 CI 的网络环境受限、或者第三方服务抖动时才偶发失败。加上这个插件后,漏掉的 mock 会在第一次运行时就暴露出来。需要真实网络的测试(真正的集成测试)可以用@pytest.mark.enable_socket显式放行,或者放到单独的 CI job 里。--allow-unix-socket是为了不影响本地数据库连接(很多驱动通过 Unix socket 连本机的 PostgreSQL/MySQL)。
八、加强记忆
flaky test(代码没改但时好时坏)真正的危害不是浪费的那几分钟,而是摧毁团队对测试的信任——传染链是「某测试偶尔挂 → 大家形成『重跑就好』的条件反射 → 真实回归 bug 也被 retry 掉 → 更多人加 reruns 图省事 → CI 变成走过场」,关键节点在第二步到第三步之间。六类成因:① 时间(月末、跨年、闰年,CI 是 UTC 而本地是 UTC+8 导致跨天判断不同)→ freezegun 冻结或注入时钟;② 随机(random、set 的迭代顺序、PYTHONHASHSEED)→ 固定种子 + 断言集合用 sorted/set/Counter 而不是比 list;③ 顺序依赖(最常见也最隐蔽)——典型症状是**「单跑过、全跑挂」,来源有数据库残留、模块级可变全局、手动 setattr 没还原(monkeypatch fixture 会自动还原)、dependency_overrides 没清 → 装 pytest-randomly 让每次顺序都不同,是最有效的暴露手段;④ 并发(xdist 下抢同一份资源)→ 按 worker_id 隔离数据库和 Redis db 号,端口用 0 让系统分配;⑤ 外部依赖 → respx/responses 拦截,更彻底的是 pytest-socket 禁掉真实网络,任何忘记 mock 的调用会立刻报错;⑥ 资源环境(CI 慢导致超时、macOS 文件系统不区分大小写而 Linux 区分)。定位的核心思路是把「偶尔失败」变成「稳定复现」:--count=50 抓概率性问题(但抓不到顺序依赖)、pytest-randomly 抓顺序依赖(CI 里一定要打印种子,否则没法复现)、二分定位、用 autouse fixture 比较测试前后的全局状态主动检测泄漏。流程图:单跑稳定全跑挂 → 顺序依赖;单跑也偶尔挂 → 随机/时间/并发;只在 CI 挂 → 环境或超时;加了 -n 才挂 → 资源共享或真实竞态。time.sleep(n) 是 flaky 的头号制造机**——设短了慢机器上挂、设长了拖慢整个套件,要用轮询 wait_until(cond, timeout) 并且用 time.monotonic() 计时(不受 NTP 校时影响)。关于自动重试的正确姿势:默认不开全局 reruns、只给显式标记的测试重试、重试成功也要在报告里可见并计入统计——关键是让 flaky 可见而不是让它消失;更好的是分层 CI(稳定测试红了必须处理、flaky 测试独立 job 允许失败)。标记必须带 issue 号和期限,否则就是永久债务。最后一个重要提醒:flaky 可能在报真 bug——并发测试偶发失败可能是被测代码真的有竞态,把测试改稳定不等于把 bug 修了。