对LLM相关flaky测试用隔离替代删除:通过CI配置隔离而非pytest.skip,确保测试持续运行并记录数据,同时解除对合并的阻塞。
第三次因为一个无关的 PR 被那个测试搞红之后,大约十分钟,你就会感受到删除它的压力。删除它是一种真正的损失:那个测试编码了某个人对系统的理解。隔离是另一种选择,而且它不仅仅是一个 skip 装饰器——它是一个第二职责、一个负责人和一个日期。
@pytest.mark.skip 和 test.skip 将测试完全从执行中移除。这解决了阻塞合并的问题,但创造了一个更糟糕的问题:测试停止产生数据。你现在完全不知道它是一次运行中失败五十次还是两次,不知道它是因为你的 prompt 变更还是因为提供商更换了模型才开始失败,也不知道它所保护的东西是三周前就完全坏掉了。被跳过的测试在第一个 sprint 之后就和一个被删除的测试无法区分了,这就是为什么 skip 列表只会增长而从不缩减。
隔离让测试在每次提交时都继续运行,并保留其结果记录。它移除的只有一件特定的事:测试使检查门禁合并失败的能力。这是一个接线层面的决策,而非代码层面的决策,将其保持在 CI 配置中而非测试文件中,正是使其可逆的原因。
让测试继续运行还有第二个原因,而这正是证明额外 CI 时间成本合理的原因。一个被隔离的测试是唯一指向它所覆盖内容的仪器。如果底层功能在测试被隔离期间完全损坏,隔离任务会从间歇性变红变成永久性变红,而这种转变是可检测的——但前提是任务实际执行了。跳过在功能运行完美和已被删除时产生相同的输出,这就是为什么 skip 列表不是隔离的更轻形式,而是一种不同且更糟糕的东西。
在整个仓库中使用单一的标记名称,并进行注册,这样拼写错误就会成为一个错误而非静默匹配失败的选择器。在 pytest 中,未注册的标记默认会发出警告,在 --strict-markers 下会报错;将注册放在你的配置中:
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"quarantined(owner, since, reason): known-flaky; runs, but does not gate a merge",
]
然后标记测试,并将元数据携带在标记中而非注释中,因为注释是无法被查询的:
import pytest
@pytest.mark.quarantined(
owner="search-team",
since="2026-07-14",
reason="tool_choice=auto occasionally returns no tool call on long inputs",
)
def test_router_emits_a_tool_call(client):
result = client.route("refund my order 4471")
assert result.tool_calls, "expected at least one tool call"
assert result.tool_calls[0].name == "lookup_order"
注意断言的内容:是工具名称和调用计数,而非一句话。这是刻意为之的,这也是对属性而非精确文本进行断言的主题。值得被隔离的测试应该已经在对结构性的东西进行断言了;如果它在对文本进行断言,隔离只是在治标不治本。
整个机制是两次调用配合互补的选择器。第一次门禁合并且从不看到被隔离的测试。第二次运行被隔离的测试,记录结果,并允许失败。
用反选排除被隔离的测试来运行门禁任务:pytest -m "not quarantined" --junitxml=reports/gate.xml。这是你的分支保护规则所指向的任务。
用重试和独立的报告单独运行隔离任务:pytest -m quarantined --reruns 3 --junitxml=reports/quarantine.xml。--reruns 标志来自 pytest-rerunfailures 插件,它也接受 --reruns-delay 和 --only-rerun 来限制对命名异常的重试。
在 CI 配置中将第二个任务标记为非阻塞,而非在 shell 中吞掉退出码。GitHub Actions 在 step 上有 continue-on-error: true;GitLab CI 在 job 上有 allow_failure: true。用 || true 管道可以工作,但会丢弃退出码,而你需要这个退出码来给仪表盘使用。
将两个 JUnit XML 文件都上传为构建产物。它们是 flake 仪表盘的输入,没有理由稍后再去收集。
Vitest 没有标记系统,所以约定是一个名称前缀或目录,通过 --exclude 和 --include 匹配。两者都接受 glob 模式,都可以在 vitest.config.ts 中或命令行上设置。最新版本也将每个测试的选项作为对象参数暴露——test(name, options, fn)——这正是 retry 存在于每个测试而非整个项目的地方。
// quarantine by path: tests under __quarantine__ are excluded from the gate
// package.json
{
"scripts": {
"test:gate": "vitest run --exclude '**/__quarantine__/**'",
"test:quarantine": "vitest run --dir src --include '**/__quarantine__/**' --reporter=junit --outputFile=reports/quarantine.xml"
}
}
在这里,路径约定优于名称约定,因为它在重命名后仍然有效,并且在目录列表中可见。审查仓库的某个人可以看到隔离区有多大,而无需运行任何东西,而这正是你需要的压力。
保持被隔离的文件与它的邻居导入相同的 fixtures 和 helpers,而非fork一份副本。被隔离的测试最常见的死亡方式是它调用的代码被重构了,隔离任务开始因导入失败而失败,而没有人注意到,因为那个任务是允许失败的。通过在隔离任务中断言收集到的预期测试数量来防止这种情况:pytest 在没有收集到任何测试时以退出码 5 退出,将这个特定代码视为硬失败只需一行代码,就能捕获静默腐烂的情况。
隔离的失败模式是它变成了一个有着更好品牌的墓地。三个规则阻止这种情况发生,而且所有三个规则都可以通过脚本而非良好意愿来执行:
每个被隔离的测试都有一个具名负责人。不是个人——是团队。人会离开;标记比人更长寿。
每个被隔离的测试都有一个自何时起的日期,如果任何标记比你约定的窗口更旧,任务就会使构建失败。失败的目的是强制做出决定:修复它、重写断言,或者用一条说明原因的提交消息故意删除它。
隔离有一个规模上限。最多只能容纳十五个被隔离测试的仓库会强制进行分类;没有上限的仓库会在远比这更糟的地方找到平衡点。按不稳定性评分对你要修复的测试排名,而非按文件顺序。
隔离绝对不能吸收的一件事:每次运行都失败的测试。这不是不稳定性,这是一个穿着标记的回归,而且这是这里最昂贵的错误,因为隔离任务允许失败,没有人去读它。在标记任何东西之前,运行足够多次以确定它确实有时会通过——从不稳定测试中辨别真实回归的过程就是检查,它大约需要两分钟。
插件标志名称和 CI 任务级键是本文中最可能移动的部分。在复制上面的调用之前,请检查 pytest-rerunfailures 和 Vitest CLI 文档以获取当前的拼写。
A Flakiness Score for Ranking Which Prompt Tests to Fix First
Telling a Flaky Test Apart From a Real Regression