作者通过实际案例揭示单元测试通过但集成后系统崩溃的根本原因:测试只验证了函数内部逻辑,无法覆盖调用者行为与跨模块交互。
崩溃是诚实的。它会打断你、打印堆栈跟踪,并告诉你该往哪里看。你会修复它,因为它很坚持。
这篇文章讲的是另一种情况。代码能运行、能返回、能满足你写的每一个断言,但对使用你软件的人而言什么也没交付。

这种事我至少在两个项目里交付过二十四次:一个在微软商店里的 Windows 系统监控器,和一个正在开发中的策略游戏。都是独立完成,都是公开的,所以提交历史无法篡改,记录就是一切。
全部内容可以用两段代码片段说清楚。
def test_grants_corpus():
company = Company()
node = ResearchNode("curated_corpora")
company.try_acquire_data_source(node.grant) # <- 测试调用了它
assert "curated_corpora" in company.data_sources
而这是生产路径,在另一个文件里,写成时间相隔数周:
def on_research_complete(self, node):
self.company.research_points -= node.cost
self.company.cash -= node.price
self.ui.toast(f"{node.name} complete")
# 这里从来没有人调用过 try_acquire_data_source
函数能工作。它一直能工作。测试证明了它能工作。产品里根本没人调用过它。
测试证明的是:一个函数在被调用时,做了你预期的事。它无法证明有任何东西调用了它。几乎下面每一个案例都是这句话的变体。
SF-01. 从未被调用的函数
真实案例,2026 年 8 月 15 日。游戏里的一个研究节点打印出了它应该授予的确切内容:一组精选语料库、一个混合专家架构。玩家支付了研究点数、现金,以及游戏日历中的四个月。他们在任何战役中从未收到过任何东西。一场战役从头到尾都锁定在初始的数据源和架构上,一路走到最后一天。
526 个测试全部通过。
每个测试都直接调用了 TryAcquireDataSource 和 TryAdoptArchitecture。UI 层里没有任何东西调用过它们。
#!/usr/bin/env bash
# caller_audit.sh - 领域方法中在接口层没有调用者的方法
grep -rhoE "public [A-Za-z<>]+ (Try[A-Z]\w+|Set[A-Z]\w+|Add[A-Z]\w+)" Simulation/ \
| awk '{print $NF}' | sort -u \
| while read -r m; do
n=$(grep -rl "\b$m\b" UI/ 2>/dev/null | wc -l)
[ "$n" -eq 0 ] && echo "UNREACHABLE $m"
done
在我的仓库上运行结果:
UNREACHABLE TryAcquireDataSource
UNREACHABLE TryAdoptArchitecture
UNREACHABLE CancelTraining
UNREACHABLE CancelArchitectureProgramme
...
46 个公开变更器,21 个无调用者,5 个本应是玩家操作的
修复方法,是一种测试形态,不是补丁
// 差:证明了函数能工作。无法证明可达性。
company.TryAcquireDataSource(node.Grant);
Assert.Contains("curated_corpora", company.DataSources);
// 好:根本禁止直接调用授予函数。
// 用日循环完成节点的方式来完成它,然后读取公司之后拥有了什么。
var before = company.DataSources.Count;
company.CompleteResearchNode(node); // 真实的完成路径
Assert.AreEqual(before + 1, company.DataSources.Count);
Assert.Contains("curated_corpora", company.DataSources);
同一个类,在其他项目里。用 Welford 算法运行的热基线经过了充分测试历时数月;聊天助手从未导入过这个引擎。优化器守护进程上存在一个 set_turbo() 方法,调用者数量为零,所以整个它所针对的耦合从它写下的那天起就是死的。十六个助手意图以置信度 1.00 匹配用户表述,但背后没有对应处理器。
SF-02. 从不存在的键
真实案例,2026 年 7 月 9 日。事件日志里每一行都渲染为 Unknown event,持续了数月。数据库里存了类型、指标、值、基线,还有关述文字。UI 读取的是 evt["message"]。这个键在代码库的任何一个层里从来就没有存在过。
# ui/events_page.py
label = evt.get("message", "Unknown event") # 任何地方都没写过这个键
# hck_stats_engine/events.py - 行实际包含的内容
{"ts": ..., "type": "spike", "metric": "cpu",
"value": 87.0, "baseline": 32.0,
"description": "CPU spike: 87% (usual 32%, +55)"}
我的产品给用户展示过的信息量最大的标签就是"Unknown"这个词,真正的答案就在相邻一个字典键的距离上。
# key_diff.py - UI 读取的内容 vs 存储产生的内容
import re, sqlite3, pathlib
db = sqlite3.connect("data/logs/hck_stats.db")
cols = {r[1] for r in db.execute("PRAGMA table_info(events)")}
used = set()
for f in pathlib.Path("ui").rglob("*.py"):
used |= set(re.findall(r'evt\[[\'"](\w+)[\'"]\]', f.read_text(encoding="utf-8")))
used |= set(re.findall(r'evt\.get\([\'"](\w+)[\'"]', f.read_text(encoding="utf-8")))
print("读取了但从未存储过:", sorted(used - cols))
为什么测试是绿的:fixture 是手写的,而手写的 fixture 包含了你自己认为存在的键。这种信念才是被测试的东西。要用从真实存储中取出的行结构来写测试。
值得一说的一种变体。一个学习引擎报告它在工作,但实际上什么都没学到。它学习了一个指标,查询条件是 WHERE cpu_temp > 0。在 Windows 上读取 CPU 温度需要大多数人不运行的一种传感器服务,所以这列始终为零,过滤条件排除了所有行。说实话:我前一周刚加的一条正确性规则——永远不要从一个估算温度中学习——才是把这列清零的原因。一个好的决策制造了一次静默故障。
SF-03. 从未生效的导入
真实案例,2026 年 7 月 16 日。硬件扫描器从未工作过,任何机器上都没有,从写下的那天起就没有。
def _scan_wmi(self):
try:
import wmi # 从未安装,从未打包
c = wmi.WMI()
...
except Exception:
pass # 每台机器,每次,永远
这个项目历史上第一次成功的硬件身份写入,发生在我把它重新连接到那个本来就工作的扫描器的那一天。

# import_audit.py - 导入了但未在 requirements 中声明的模块
import ast, pathlib, sys
declared = {l.split("==")[0].strip().lower()
for l in open("requirements.txt") if l.strip()}
std = set(sys.stdlib_module_names)
for f in pathlib.Path(".").rglob("*.py"):
tree = ast.parse(f.read_text(encoding="utf-8"))
for n in ast.walk(tree):
if isinstance(n, ast.Import):
for a in n.names:
root = a.name.split(".")[0]
if root.lower() not in std | declared:
print(f"{f}:{n.lineno} 未声明: {root}")
还有一个变体,这个要我自己来承担。我三次发布说 psutil.sensors_temperatures() 在 Windows 上返回空字典。它并不会。这个属性根本不存在:
>>> import psutil
>>> hasattr(psutil, "sensors_temperatures")
False # Windows, psutil 7.2.1
每一次调用都触发了一个被 try 块吞掉的 AttributeError。我之所以检查,只是因为我在写一篇关于重复记忆中的说法文章。
对于可选的平台 API,记录你走了哪个分支:
if hasattr(psutil, "sensors_temperatures"):
temps, src = psutil.sensors_temperatures(), "sensor"
else:
temps, src = _estimate_from_load(), "est"
# src 与值一起传递。缺失的传感器必须在每一个消费者那里
# 明显不同于正常读数。
SF-04. 谎言般的成功消息
真实案例,2026 年 7 月 18 日。风扇曲线编辑器有一个"应用"按钮。它闪烁"应用成功",但什么都没有持久化。每次重启都丢弃用户的曲线,静默地,持续了两个版本。
def _on_apply(self):
self.curve = self._draft_curve # 仅在内存中
self._flash("Applied successfully") # 一个字符串,不是证据
没有人反馈这个问题。用户无法区分"它保存了"和"它说自己保存了"。他们设好了曲线,看到确认,重启了一周后发现是默认值,然后就以为自己操作错了。
grep -rniE "(applied|saved|success|complete)" ui/ --include=*.py -l \
| xargs -r grep -LiE "(open\(|json\.dump|\.write\(|commit\(|save\()" \
| sed 's/^/CLAIMS SUCCESS, NEVER WRITES: /'
# 差
assert page._on_apply() is True
# 好 - 产品就是副作用,所以要读回来
page._on_apply()
with open(SETTINGS) as f:
assert json.load(f)["curve"] == page._draft_curve
同类问题。ShowWindow(SW_HIDE) 调用成功但控制台保持打开,因为在 Windows Terminal 下 GetConsoleWindow() 返回的是一个隐藏的代理窗口。一个商店快捷方式指向了一个发布清单中不存在的 Application Id,所以它什么都打不开。没有人会报告一个坏掉的快捷方式。他们只是不再用了。
SF-05. 静默的 catch
真实案例,2026 年 6 月 24 日。
try:
ctx = build_llm_context(lang, window) # `window` 属于另一个函数
...
except:
pass
NameError,连续四个月,每次调用都出现。没有崩溃,没有日志,没有警告。整个备用路径从第一天起就死掉了。我发现它,是因为某些回答比应有的样子略差——这不是一个调试方法。
bare except 还会吞掉 SystemExit 和 KeyboardInterrupt,所以在所有问题之上,它还是一个关闭 hazard。仓库范围内统计发现了 53 个。
grep -rn "except:" --include=*.py . | sed 's/^/BARE EXCEPT /'
grep -rn -A2 "except Exception" --include=*.py . \
| grep -B1 -E "^\s*(pass|continue)\s*$" \
| sed 's/^/SWALLOWED /'
修复它的那条规则
只吞读,不吞写。
# fine: a default is a reasonable answer to "I do not know"
try:
temp = read_sensor()
except SensorUnavailable:
temp = None
# never: the user believes this happened
try:
save_settings(cfg)
except Exception as e:
log.error("settings not saved: %s", e)
raise
受伤最深的那一次。重构把一个 6533 行的模块拆成了七个,并删除了一个模块级单例。调用方将其导入包装在一个 broad except 中,所以 HAS_AI_LAYER 悄无声息地变成了 False,整个产品的 AI 层自行关闭了。一切正常运行。测试全绿。一个当天写的 guard test——针对一个我未曾预料到的盲点——是我唯一知道的证据。
SF-06. 隐藏 bug 的运行环境
真实案例,2026 年 6 月 17 日。
# insights.py
def summarise(self, label: Optional[str] = None) -> str: # Optional 从未导入
...
在我的机器上这运行完全正常。我在 Python 3.14 上开发,PEP 649 延迟了注解求值,所以缺失的导入从未触发。在我正式支持的 3.9 到 3.13 版本上,它在导入时就会抛出异常,整个模块悄无声息地关闭了自己。
我自己的运行时一直在向我隐藏这个 bug。
for v in 3.9 3.10 3.11 3.12 3.13 3.14; do
echo "=== python $v ==="
py -$v -m unittest discover tests -q 2>&1 | tail -3
done
Store 案例,2026 年 7 月 4 日。Microsoft 批准应用进入 C:\Program Files\WindowsApps,这是只读的。应用把自己的数据库、偏好设置和学习基准写到了可执行文件旁边,所以每个 Store 安装上的所有写操作都静默失败了。认证不检查这个。用户也不会报告,因为看起来什么都没出错。应用Simply永久性失忆。
def _app_dir():
exe = os.path.dirname(sys.executable)
# A name check is a guess. A write probe is a fact.
try:
probe = os.path.join(exe, ".w")
with open(probe, "w") as f:
f.write("1")
os.remove(probe)
return exe
except OSError:
return os.path.join(os.environ["LOCALAPPDATA"], "PC_Workman_HCK")
不要通过名称检测环境。要探测它。
同一个类,不同引擎:Object.Destroy 在 play 模式外是一个 no-op,所以一个在运行时正确释放内存的纹理缓存在编辑器中什么都不释放。
if (Application.isPlaying) Object.Destroy(tex);
else Object.DestroyImmediate(tex);
SF-07. 同一个真相的两个来源
真实案例,2026 年 8 月 19 日。模型创建者引用 11 天完成一次训练运行。实际运行在 1 天内就结束了。两个数字都出自我自己写的代码。
// projection, shown to the player before they commit money
days = petaflopDays / (throughput * PrecisionMultiplier(precision));
// daily tick, what actually advances the run
progress += baseRate * founderSkill * teamUtilisation;
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the projection
// has never heard of either of these,
// and never multiplies by precision
两个公式产生同一个量,持续了数周,没有任何东西在比较它们。
grep -rhoE "^\s*def (_?[a-z_]+)\(" --include=*.py . \
| sed -E 's/.*def (_?[a-z_]+)\(/\1/' | sort | uniq -c \
| awk '$1 > 1 {print "DUPLICATE DEF x"$1" "$2}'
这在六份副本中找到了 _base_dir,五份中找到了 _is_admin。版本字符串存在于八个文件中,其中三个已经是错的:主窗口标题显示 1.8.1,而单实例检查搜索的是 1.8.2 标题,所以在两个版本中二次启动应用都会停止聚焦运行中的副本,没有任何测试覆盖。
如果同一个量在两个地方被计算,那不是重复。那是一颗带有日期的定时炸弹。修复方法绝不是保持副本同步。而是让两边都调用同一个函数,这样它们就不会漂移,外加一个 ratchet test:
def test_version_has_exactly_one_source(self):
hits = [p for p in Path(".").rglob("*.py")
if re.search(r'["\']\d+\.\d+\.\d+["\']', p.read_text())
and p.name != "app_version.py"]
self.assertEqual([], hits, f"hardcoded version literal in {hits}")
SF-08. 只存在于界面中的规则
参数上限在 TryStartTraining 内部强制执行。但 Project(),即创建者屏幕每次滑块移动时调用的函数,既不检查上限也不检查精度门。创建者愉快地为运行定价,然后 START 按钮拒绝,但没有在玩家触碰过的任何东西上附加任何警告。
// the test that matters: it never opens a screen
var blueprint = new Blueprint { Parameters = ceiling * 10 };
var result = company.TryStartTraining(blueprint);
Assert.IsFalse(result.Accepted,
"parameter ceiling is enforced in the UI only");
操作顺序是同一个类的不同外衣:
var blueprint = CurrentBlueprint(); // reads the raw slider
var projection = Project(blueprint);
RefreshParameterCeiling(); // clamps the slider, too late
把手弹回,屏幕上每个数字都保持了玩家拖动到的值。在读取之后运行的验证不是验证。
存在于一个屏幕中的限制是一个用好字体呈现的建议。一旦该操作存在第二个入口点,它就失效了:另一个屏幕、一个加载的存档、一个编辑过的配置、一个 API 调用。
作为发布前检查一次性运行所有这些
以上没有哪一条值得作为你记得去做的事情来保存。警惕性在几千行之后就无法维持,而且它恰恰在你疲惫的时候失效——而这正是这类 bug 发布的时机。
#!/usr/bin/env bash
# preflight.sh - before tagging. Exit code is advisory, not a gate.
set -u
fail=0
echo "== SF-01 unreachable domain methods =="; ./tools/caller_audit.sh
echo "== SF-02 keys read but never stored =="; python tools/key_diff.py
echo "== SF-03 undeclared imports =="; python tools/import_audit.py || fail=1
echo "== SF-04 success with no write =="
grep -rniE "(applied|saved|success)" ui/ --include=*.py -l \
| xargs -r grep -LiE "(open\(|json\.dump|\.write\(|commit\()" || true
echo "== SF-05 bare excepts =="
grep -rn "except:" --include=*.py . && fail=1
echo "== SF-06 runtime matrix =="
for v in 3.9 3.10 3.11 3.12 3.13 3.14; do
py -$v -m unittest discover tests -q >/dev/null 2>&1 || { echo "FAILS ON $v"; fail=1; }
done
echo "== SF-07 duplicate definitions =="; ./tools/duplicate_defs.sh
exit $fail
读第 2 行的注释。这不会阻塞发布。它打印一份人类阅读的列表,因为它发现的问题中有一半是合理的:没有用户面向调用方的内部管道、一个确实应该存在两次的 helper、一个真正只读操作上的成功消息。
一个总是狼来的检查在一周内就会被禁用,然后它什么都保护不了了。只有一小部分是硬失败:没有 bare except、没有死导入、没有硬编码版本字面量、没有单源 helper 的第二份副本。这些是 ratchet。其他一切都是报告。
AI 辅助实际改变了什么
我和一个助手一起构建,公开地,已经一年多了。诚实的描述比当前争论的双方都更窄。
它并没有降低单个函数的质量。上述大多数案例都是普通错误,有普通的起因,其中几个早于任何助手出现。
改变的是标准必须在何处被执行。一个助手非常擅长产出一个满足描述的函数。它没有视图去看从人类的手到这个函数是否存在一条路径,因为那条路径活在另一个文件里、另一层中、通常在另一段对话中。Writing 变快了。验证任何东西是否到达结果没有变快。上述每一个类都活在这两个速度之间的缝隙中。
我最在意的那次事件。这个项目保持了一条规则,反对 AI 生成感的 prose,并有一个测试对每个助手响应触发,任何 em dash 都会失败。7 月 17 日,一次自动化清理在 42 个文件中移除了 312 个 em dash。它还重写了 guard 测试内部的 em dash 字面量——那个测试本该防御它们——把这个测试变成了"这个回答是否包含连字符"。
# before: 被它本应防御的清理吃掉了
self.assertNotIn("—", response)
# after: 纯 ASCII,后续任何文本处理都无法匹配它
EM_DASH = chr(0x2014)
self.assertNotIn(EM_DASH, response)
然后用一个阴性对照来验证:往一个真实响应里注入一个破折号,看测试失败。如果你从未故意看过你的护栏失效,你不会知道它居然会失效。
这个工具完全按我说的做了,按它自己的标准快速而正确地执行了,却把唯一能阻止它解决我交给它的问题的东西给禁用了。
这些失败从内部是看不见的。没有任何崩溃,没有任何日志,你恰好在看的那块屏幕上也没有任何异常。每一个都需要有人去问一个没人想到要问的问题,而独自一人时,能问出的问题恰恰正是你已经知道要问的那一套。
审阅者免费提供了这一点,不是因为他们更聪明,而是因为他们不带你的预设而来。读你测试文件的审阅者从未被告知"这里直接调用函数是没问题的"。他们只是看到一个测试不像用户会做的事,然后指出来了。
没有审阅者,你就需要一个机械替代品。grep 不会疲劳,不会假设,也不会客气地跳过上周读过的那个文件。它在各方面都比人差,除了在唯一重要的一点上:它不是你,它不共享你的盲区。
第二个替代品是真实用户,而且他们好多少这一点令人不安。三名测试人员在我认为已经完成的版本上各花了五个小时,发现了六个稳定性问题、十七个以满置信度匹配却返回空结果的意图,以及一个在每条消息中调用系统快照十九次的函数。这些在我的测试套件里一条都没有。他们第一个晚上就全发现了。
一个人,两个项目。我没有数据能说明这是否适用于一个团队,而团队有代码审查,这会消除其中一些问题,同时产生我从未遇到过的一些新问题。
我也没法干净地把"AI 辅助导致的失败"和"就算没有 AI 我也会发版出去而导致的失败"分开。任何声称自己能做到这一点的人,不管哪个方向,都在推销东西。我能提供的是日志所显示的,有日期,在公开仓库里。
测试套件从六月 21 个测试增长到八月 331 个,但增长不是重点。重点是每一个曾经无声无息地发版出去的失败现在都有一个棘轮测试——如果这个类返回了,构建立即失败。这不能让软件变得正确。它只是让某一类错误无法再次发版出去。

Marcin Firmuga,22 岁,波兰拉多姆。PC Workman(一个 Windows 系统监视器,带完全本地化的助手)开发十四个月:331 个测试,521 个流程定义,330 条离线硬件兼容性库,110 个波兰语和英语意图。MIT 许可,Sigstore 签名,每次提交都跑 CodeQL,七月起在 Microsoft Store 上架。同期还有 Scaling Laws,一款关于经营前沿 AI 实验室的策略游戏,跨越 65 个文件、697 个测试和 35 个存档迁移。
另有 25 份英语和波兰语的诊断指南,免费且无需注册,连续二十一周每周三篇 build-in-public 文章,没有缓冲也没有待发稿件队列。
这一切都不是从一张办公桌前开始的。始于荷兰的一个仓库——那时我是个开叉车的订单拣货员,然后在波兰开车,然后在一家修理店焊接塑料,现在开出租车,每天十到十二小时。支撑这一切的是同一个系统,十四个月未变:白天工作,晚上编程。
以上所有数字均来自发布当天从源代码中读出的数据,不是凭记忆。这种习惯存在是因为我曾经凭记忆犯过错,在公开场合,关于同一个 API 犯过三次。这件事被作为独立条目记录在catalogue里。
我在找我的第一份软件工作。Python、桌面应用、无云依赖的本地 AI、Windows 内部机制、发布工程、双语技术写作。接受远程、混合或克拉科夫、华沙、弗罗茨瓦夫现场办公。
完整目录、八个类别及其各自的覆盖扫描:pcworkman.dev/guides/silent-failure-catalogue
一站式汇总:linktr.ee/marcin_firmuga
我每周写一份公开日志,包括那些做得不好的周。
如需进一步操作,你可以考虑屏蔽此人或举报滥用行为