我修复了一个小小的时区 bug,运行了测试套件,然后眼睁睁看着全部 236 个测试变成绿色。出于习惯而非怀疑,我在标记发布之前先推送到 CI。CI 变红了——然后变得更红。那个小小的修复下面还隐藏着两个 bug,而在刚刚看到测试通过的那台机器上,它们完全看不出来。
背景是我维护的一个工具,名叫 claude-code-notify:当 Claude Code 达到使用上限时,它会 ping 我一次;当上限重置时,再 ping 一次。你不需要了解这个工具。bug 是一个普通的时区问题,而它让我付出的代价是一堂关于测试的课——完全绿色的本地测试套件也可能对你撒谎。
最初的 bug 出现在计算「上限何时重置」的部分。
只是巧合才正确
当 Claude Code 告诉你上限何时重置时,它会把时间以纯文本形式写入消息,并在括号中标明时区:resets 5:20am (Asia/Hong_Kong)。读取这个时间的函数 parse_reset() 提取出小时和分钟,然后在宿主机器的本地时间中完成所有运算。它从未查看括号里的时区。
我的宿主机器是一台固定在香港时间的远程服务器。所以 Claude Code 嵌入的时区和我代码默认的时区恰好相同——两边都是 Asia/Hong_Kong——因此答案每次都正确。不是因为代码正确,而是因为两个我从未想过要比较的时区恰好相同。如果把同一个工具跑在一台伦敦的笔记本上,或者遇到 Claude Code 报告另一个时区的重置时间,那么「你的上限将在……」的 ping 就会偏离好几个小时,既没有异常,也没有日志行来解释——只给出一个自信却错误的时间。
修复很简单:从文本中捕获时区名称,通过 Python 标准库的 zoneinfo 解析它;只有当时区缺失或无法解析时,才回退到宿主本地时间——这正是原来代码一直做的事,现在被降级为一个有意的最后手段。测试通过了。这正是我本该起疑却没有起疑的时刻,因为本地没有任何可疑之处。
不可能失败的测试
ubuntu-latest 在 UTC 下运行。第一次 CI 跑完返回 4 failed, 232 passed,其中两个失败来自我根本没碰过的测试:
tests/test_usagelimit.py::test_parse_reset_returns_next_local_occurrence FAILED
tests/test_usagelimit.py::test_parse_reset_rolls_to_tomorrow_when_past FAILED
E assert (13, 0) == (21, 0)
Enter fullscreen mode Exit fullscreen mode
两个测试都用 resets 9pm (Asia/Hong_Kong) 来检验 parse_reset()。它们都用一个普通的 datetime 构造「当前时间」——按宿主本地时间读取——然后把解析器的答案也按宿主本地时间转换回来验证。断言两边用的都是同一个环境时区。
在旧的、有 bug 的 parse_reset()(同样在宿主本地时间下工作)下,这种对称性以一种安静的方式致命:输入宿主本地时间、在宿主本地时间中计算、用宿主本地时间读回,时区直接从方程里抵消了。两边在地球上任何机器上都匹配。测试看起来像在验证时区处理,其实只是在验证一个数等于它自己。它不可能失败——在我的机器上不可能,在任何人的机器上也不可能。
Bug 1 的修复打破了这种对称性,这也是问题最终暴露的唯一原因。parse_reset() 现在把香港时间晚上 9 点解析成一个真实的瞬间,无论宿主时钟怎么说,这个瞬间都一样。在我的香港机器上,把这个瞬间读回宿主本地时间仍然显示 21:00——仍然是绿色的。而在 UTC 运行器上,同样的瞬间读回来是 13:00,因为香港晚上 9 点就是 UTC 的下午 1 点。(13, 0) == (21, 0):一个八小时的差距,在代码跑在我的时钟之外的机器上时立刻显现。
这个教训值得记住。一个从与代码读取相同环境推导出期望值的测试是自洽的,而在绿色的仪表盘上,自洽与正确难以区分。它通过了,所以你信任它,但它其实什么也没检查。修复方法是将期望值锚定到外部的、明确的东西上——输入时间和断言都固定到 zoneinfo.ZoneInfo("Asia/Hong_Kong"),而不是「这台机器叫什么本地时间」。我在 TZ=UTC、TZ=America/Los_Angeles 和 TZ=Pacific/Kiritimati 下重新运行了测试,现在它断言的是关于世界的事实,而不是关于我机器的事实。
测试的失败方式与代码不同
第二次推送。又红了,而且原因不同:
tests/test_usagelimit.py::test_parse_reset_uses_reported_timezone_not_host FAILED
E ModuleNotFoundError: No module named 'zoneinfo'
Enter fullscreen mode Exit fullscreen mode
zoneinfo 直到 Python 3.9 才进入标准库。这个工具支持 3.8——pyproject.toml 声明 requires-python = ">=3.8"——CI 矩阵特意包含 3.8。生产代码已经处理了这一点:ZoneInfo 的导入放在 try/except ImportError 后面,导入失败时回退到宿主本地时间,这正是 Bug 1 修复所依赖的回退逻辑。它会优雅降级。
测试却没有降级。那四个检验时区解析的测试——我刚刚锚定的两个,加上我随修复一起新增的两个——都直接写了一句 from zoneinfo import ZoneInfo。在 3.8 上,这不是优雅的回退,而是在测试体还没运行前就直接抛出硬 ModuleNotFoundError。被测代码降级了,而本该覆盖它的测试却直接断了。
修复方法是每处加一行——pytest.importorskip("zoneinfo")——这样当模块不存在时测试会干净地跳过。其背后的原则比前面两个 bug 都平淡,却比两者都更重要:测试必须以与它覆盖的代码相同的方式降级。如果生产环境容忍某个可选依赖缺失,那么硬性要求该依赖的测试就不是在测试生产环境,而是在测试代码从未承诺过的更严格的保证。
有意义的红和没有意义的红
那次运行中还有一个任务变红了,但这是个诱饵。macos-latest 上的 Python 3.8 显示失败,紧挨着 Ubuntu 上真正的 3.8 失败——近到看起来像是同一个 bug。其实不是。它的日志写着 The operation was canceled,发生在 setup-python 步骤,在任何测试运行之前。当矩阵的一条腿失败时,GitHub Actions 默认会取消其余部分,而我的 workflow 从未关闭这个行为——所以真正的 Ubuntu 失败把整个 macOS 列都拖下了水,3.8、3.11 和 3.12 全部被取消,没有一个是真正的 bug。把那个红当作第四个问题去追,你会浪费一下午去调试一个取消操作。把真正的失败和 fail-fast 噪音区分开来是另一个步骤;单凭颜色做不到。
把这三个真正的 bug 排在一起,它们有共同点。Bug 1 需要一台时区与报告时区不同的宿主。Bug 2 也是。Bug 3 需要一台没有 zoneinfo 的 Python。我的开发机器在香港,跑 Python 3.12,内置 zoneinfo——这正是三个 bug 都无法发生的唯一配置。我不是运气不好才忽略它们。我构建代码的机器,在结构上恰恰是让这三个 bug 全部隐形的那台机器。我曾信任的每一次绿色运行都是真实的,同时也是毫无价值的:代码与运行它的计算机一致。这就是绿色测试套件所能证明的全部。代码是否正确是另一个命题,而这两个命题之间的空白,正是机器的时区、Python 版本和已安装模块藏身之处。
真正重要的修复不是那三个补丁中的任何一个,而是「在标记发布前先跑矩阵」,而不是把「236 个测试通过」解读成「可以发布了」。任何一个补丁都只需十分钟;我一直跳过的步骤其实是最廉价、最显得多余的那一步——让一台不是你的机器在你宣布完成前先试试代码。
这个工具叫 claude-code-notify——采用 MIT 许可,地址 github.com/Jeromefromcn/claude-code-notify,现在已包含正确的重置时区处理。如果你曾经因为 Claude Code 的使用上限浪费过一下午,它也许值得一看。如果你更想看 bug 而不是这篇文字,它们就在提交历史里——每个 bug 一个提交。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.