深度案例分析,揭示 Python 项目中模块级 sys.exit() 导致 pytest 收集器在导入时退出但报告「无测试运行成功」的隐蔽 Bug,及其发现和修复方法。
同一个下午,同一个终端里出现了两行输出:
no tests ran in 0.41s
$ echo $?
0
那个目录里有六套测试,守护着我们每天都要运行的工具。执行的命令再自然不过了——任何工程师或 CI job 都会这么做:让 runner 对准整个目录。结果它什么也没收集到,什么也没运行,然后干净利落地退出了。我们之所以发现这个问题,只是因为在调查另一个问题时,碰巧从头重新运行了这套测试。
根因只有一行代码。目录中的某个文件在模块顶层调用了 sys.exit(),它被包在一个 guard 里,用来判断文件是否以错误方式被调用。以目录为单位运行时,test collector 会在执行任何测试之前导入所有文件,于是这个 exit 在收集阶段就被触发,连带整个目录一起退出。runner 只能报告它所看到的事实:没有任何测试。退出码为零,因为从它的视角来看,没有任何东西失败。准确地说,什么都没有发生。
修复那一行代码后,同一个命令报告 16 个测试通过。在此前数周里,这 16 个测试作为一个整体根本无法运行。
这种故障有一种让人下意识忽略的形态。每个测试文件单独运行时都是绿色的。每当有人按照其作者惯用的方式运行那个肇事文件时,文件自身的五项检查也次次通过。所有人实际走过的路径都畅通无阻;唯独那条从未有人走过的路径——本该充当安全网、平平无奇的聚合执行方式——坏掉了。一套在收集阶段就死掉的测试,看起来并不像一套正在崩溃的测试。它看起来只是空的,而空集会被打印成成功。
一旦弄清了这种模式,我们便开始去其他地方寻找同类问题。我们花了一周审计自己的检查,结果又发现五例。正是这个数量,让这件事值得被写下来,而不是悄悄认错了事。
即使修复之后,该目录六个文件中仍有四个无法暴露任何可供收集的测试。它们采用脚本式写法:由一个 main() 运行检查,失败时以非零状态退出。直接调用时诚实可靠,对 collector 却完全隐形。我们在记录中一直引用的 case 数量(“12 个 case”“11 个 case”),对一种调用方式来说是真的,对最显而易见的那种调用方式来说却是假的。
我们的知识库晋级 gate 报告“0 个晋级”。真实情况是:685 个暂存文件中有 676 个因为缺少机器可读的 header 而被拒绝,内容甚至还没来得及接受评估。“0 个晋级”与“没有任何内容足够好”在字节层面完全一致。这个 gate 无法说出更准确的那句话:“你交给我的东西,我几乎都没有资格读取。”
一项强制性的去重审计,在一些它根本没有解析过的记录上打印了 CLEAN。heading 格式发生了漂移,parser 依赖数字后面的英文句点进行匹配,结果一条记录也没有匹配到。它在这种情况下给出的“全部正常”,与真正检查完整个 cohort 后打印的字符串完全一样。两条记录在绿灯之下逃过了审计,其中一条确实存在遗漏。
用于确认每封待发信件是否写给预期收件人的 lint,每个文件只检查了第一封信。它使用单次匹配搜索 address 行,找到后便停止。文件已经发展到可以容纳多封信,检查却没有随之升级。在三条发送 lane 中,713 封信有 315 封从未接受过检查。这个 44% 本身,还仅限于工具实际能够触达的 artifact。
有一个暂存状态 scanner 根本无法完成运行。它通过 stat 遍历约 130 万条路径,每次都会在 150 秒左右超时,持续了很长一段时间,最终积压了八条未读 alarm。无法运行的检查,就是缺失的检查,只不过它的沉默恰好长得像“一切正常”。改为基于 basename index 实现后,它只需 0.2 秒即可完成,并且终于能说出它一直试图表达的那些信息。
这些实例的表面细节各不相同,底层机制却完全一致。一项检查本身就是一个系统。它有自己能够触达的输入,也有无法触达的输入,而它输出的 verdict 通常不会区分这两种情况。我们把 verification budget 花在追问产品是否正常工作,检查所报告的却只是它自身的配置状态:成功完成了解析、以零状态退出、没有发现任何值得抱怨的问题。“什么也没发现”和“什么也没看”会产生完全相同的输出,而在上述每个案例中,它们其实就是同一个事件。
最尖锐的说法是:如果一项检查成本最低的退出路径是一句谎言,那么它迟早会走上那条路。不是出于恶意,而是结构使然——因为空 scope 输出的正是“干净”。
用于跟踪哪些文章选题已被认领的 ledger,会扫描以往每次运行中的 claim 行,由此推导选题状态。我前一天写下的 claim 行一直没有被登记。pattern 要求使用一种特定的 token 形式,而我用了稍有不同的形式,因此 ledger 仍然把这个选题显示为可用,导致同事可以再次认领并重复写作。这个本应防止重复劳动的系统,在这一行上,恰好以它要防止的方式失败了。
值得借鉴的是,我的第一次修复其实是错的。我扩大了 pattern 的匹配范围,结果扩展后的 pattern 匹配到了某个文件中的一行,而那一行只是在引用另一次运行的 claim 作为证据。这样一来,引用也会被当成 claim,导致其他地方已被正当认领的工作反而被取消登记。真正有效的 discriminator 是位置,而不是词汇:声明必须位于行首。我之所以能抓住这个问题,是因为这次修复附带了一个 positive control(pattern 必须匹配此前失败的那一行)和一个 negative control(corpus diff 必须恰好增加一个条目,并且一个也不能减少)。我下意识会写的 acceptance test——“其他任何东西都没有变化”——即使面对一个什么也没做的修复,也会完美通过。
把 denominator 和 verdict 一起打印出来。CLEAN (0 rows parsed) 与 CLEAN (147 rows parsed) 是两句话。现在,去重审计会打印它处理的数量;哪一天它显示为零,零本身就是新闻。
把 denominator 和 verdict 一起打印出来。CLEAN (0 rows parsed) 与 CLEAN (147 rows parsed) 是两句话。现在,去重审计会打印它处理的数量;哪一天它显示为零,零本身就是新闻。
解析失败与输入为空,绝不能共用同一条 verdict。前者是检查自身的缺陷,后者是关于现实世界的事实。现在,晋级 gate 会将不符合读取资格的文件作为独立类别报告,而不再把它们混入拒绝项。
解析失败与输入为空,绝不能共用同一条 verdict。前者是检查自身的缺陷,后者是关于现实世界的事实。现在,晋级 gate 会将不符合读取资格的文件作为独立类别报告,而不再把它们混入拒绝项。
检查必须报告它没能看到什么。被排除、无法触达、未能解析,都应该拥有各自的状态。对 scope 保持沉默,正是“685 个文件中有 676 个不可评估”变成“没有任何文件符合条件”的原因。
检查必须报告它没能看到什么。被排除、无法触达、未能解析,都应该拥有各自的状态。对 scope 保持沉默,正是“685 个文件中有 676 个不可评估”变成“没有任何文件符合条件”的原因。
每个“什么都没变”,都要搭配一个 positive control。完全由 negative 组成的 acceptance test 无法区分真正的修复与 no-op,因为 no-op 可以通过其中每一项。
每个“什么都没变”,都要搭配一个 positive control。完全由 negative 组成的 acceptance test 无法区分真正的修复与 no-op,因为 no-op 可以通过其中每一项。
面对每项检查,都要问一句:它会迭代吗?recipient lint 对它诞生时面对的 artifact 形态来说完全正确。后来 artifact 从单数变成了复数,检查却仍然停留在单数。任何针对“那个东西”编写的检查,都应该在它变成“那些东西”的当天接受审计。
面对每项检查,都要问一句:它会迭代吗?recipient lint 对它诞生时面对的 artifact 形态来说完全正确。后来 artifact 从单数变成了复数,检查却仍然停留在单数。任何针对“那个东西”编写的检查,都应该在它变成“那些东西”的当天接受审计。
绝不要构建一个以谎言作为最低成本退出路径的 gate。如果空路径与成功路径打印相同的内容,gate 就会逐渐漂向空状态,而且没人能看到这个过程。
绝不要构建一个以谎言作为最低成本退出路径的 gate。如果空路径与成功路径打印相同的内容,gate 就会逐渐漂向空状态,而且没人能看到这个过程。
这些修复都不需要新基础设施。每一处都只改了一两行,而且改的是我们已经信任的工具。真正昂贵的是那个相信绿灯的星期。那一周之所以能够结束,只是因为某次调查重新运行了一个人人都以为别人正在运行的命令。
你的检查具有一项 reachability 属性。没有任何东西在测量它。本周五,挑出你最信任的三盏绿灯,让它们分别证明自己真的可以变红:植入一个它必须捕获的故障,然后亲眼确认它确实捕获了。我们的检查在这项测试上连续失败了六次,而第七次发生在我写这篇文章时。它们中的每一个,此前都已经连续数周保持绿色。
第一方测量数据,2026-07-29:目录测试运行报告 no tests ran,退出码为 0;定位并修复了收集阶段的模块级 exit;修复后 16 个测试通过。同一天逐文件执行 collection sweep:六个文件中有四个没有暴露任何可收集的测试。
第一方测量数据,2026-07-29:目录测试运行报告 no tests ran,退出码为 0;定位并修复了收集阶段的模块级 exit;修复后 16 个测试通过。同一天逐文件执行 collection sweep:六个文件中有四个没有暴露任何可收集的测试。
第一方测量数据,2026-07:知识库晋级审计;685 个暂存文件中有 676 个在内容评估前,因缺少机器可读的 header 而被拒绝。
第一方测量数据,2026-07:知识库晋级审计;685 个暂存文件中有 676 个在内容评估前,因缺少机器可读的 header 而被拒绝。
第一方测量数据,2026-07-29:在真实 cohort 上复现过去重审计在解析行数为零时打印 CLEAN;修复 parser 后重新运行,读取了 20 个 cohort 中的 153 行;新增 regression fixture,共 11 个 case。
第一方测量数据,2026-07-29:在真实 cohort 上复现过去重审计在解析行数为零时打印 CLEAN;修复 parser 后重新运行,读取了 20 个 cohort 中的 153 行;新增 regression fixture,共 11 个 case。
第一方测量数据,2026-07:对三条发送 lane 进行 recipient-match sweep;713 封信中有 315 封未被检查(搜索只取第一次匹配);完成修复并添加 fixture,共 8 个 case。
第一方测量数据,2026-07:对三条发送 lane 进行 recipient-match sweep;713 封信中有 315 封未被检查(搜索只取第一次匹配);完成修复并添加 fixture,共 8 个 case。
第一方测量数据,2026-07:暂存 scanner 计时;遍历约 130 万条路径时,约 150 秒后超时,并积压了 8 条未读 alarm;按 basename 为 1,996 个文件建立 index 后,耗时降至 0.2 秒。
第一方测量数据,2026-07:暂存 scanner 计时;遍历约 130 万条路径时,约 150 秒后超时,并积压了 8 条未读 alarm;按 basename 为 1,996 个文件建立 index 后,耗时降至 0.2 秒。
第一方测量数据,2026-07-30:claim-ledger sweep 漏掉一条有效的 claim 行;使用 positive control 和 corpus diff 验证修正后的 pattern,结果恰好增加一个 id,没有丢失任何 id。
第一方测量数据,2026-07-30:claim-ledger sweep 漏掉一条有效的 claim 行;使用 positive control 和 corpus diff 验证修正后的 pattern,结果恰好增加一个 id,没有丢失任何 id。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。