我修正了一個小小的時區錯誤,執行了測試套件,看著全部 236 項測試都亮起綠燈。出於習慣而非懷疑,我在標記發行版之前先推送至 CI。CI 亮起紅燈——接著變得更紅。那個小小的修正底下堆疊了另外兩個錯誤,而它們在我剛看著測試通過的那台機器上完全看不出來。
這是一個我維護的工具,名叫 claude-code-notify:當 Claude Code 達到使用限制時,它會通知我,限制重置時也會再通知一次。你不需要了解這個工具。錯誤是一個普通的時區問題,而它讓我付出代價,學到關於測試的一課——關於完全綠色的本地測試套件如何可能對你撒謊。
原始錯誤出現在計算限制「何時」重置的那個部分。
純屬巧合才正確
當 Claude Code 告訴你限制將在何時重置,它會將時間以純文字寫入訊息,括號內註明時區:resets 5:20am (Asia/Hong_Kong)。讀取該時間的函式 parse_reset() 取出小時和分鐘,並以主機機器的本地時間進行所有運算。它從未查看括號內的時區。
我的主機機器是一台遠端伺服器,固定在香港時間。因此 Claude Code 嵌入的時區與我的程式碼默默假設的時區相同——兩邊都是 Asia/Hong_Kong——答案每次都正確。不是因為程式碼正確,而是因為兩個我從未想過要比較的時區碰巧相同。若在倫敦的筆電上執行相同的工具,或是遇到 Claude Code 回報其他時區的重置,「你的限制將在……」的通知就會差好幾個小時,既沒有例外也沒有日誌行說明——只是一個自信卻錯誤的時間。
修正很小:從文字中擷取時區名稱,並透過 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)
進入全螢幕模式 離開全螢幕模式
兩項測試都用 resets 9pm (Asia/Hong_Kong) 來檢查 parse_reset()。兩者都用一個普通的 datetime 來建構「目前時間」——以主機本地時間讀取——然後將剖析器的答案轉換回來,同樣以主機本地時間驗證。同樣的環境時區出現在斷言的兩邊。
在舊的、有錯誤的 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'
進入全螢幕模式 離開全螢幕模式
zoneinfo 直到 Python 3.9 才進入標準函式庫。這個工具支援 3.8——pyproject.toml 宣告 requires-python = ">=3.8"——而 CI 矩陣刻意在 3.8 上執行。正式環境程式碼已經考量到這點:ZoneInfo 的 import 放在 try/except ImportError 之後,當失敗時,程式碼會退回到主機本地時間,也就是 Bug 1 修正所依賴的同一個退路。它會彎曲。
測試不會彎曲。那四個測試時區解析的測試——我剛才錨定的兩個,加上我隨修正新增的兩個——每個都直接執行 from zoneinfo import ZoneInfo。在 3.8 上,這不是優雅的退路;這是在測試主體甚至還沒開始執行前就發生的硬 ModuleNotFoundError。被測試的程式碼會降級,而用來涵蓋它的測試卻斷掉了。
修正方式是每一處都加一行——pytest.importorskip("zoneinfo")——當模組不存在時,會乾淨地跳過測試。其背後的原則比前面兩個錯誤都更平淡,卻比兩者都更重要:測試必須以與它涵蓋的程式碼相同的方式降級。如果正式環境容忍缺少可選相依性,那麼硬性要求該相依性的測試就不是在測試正式環境。它是在測試程式碼從未做出的更嚴格承諾。
有意義的紅燈,以及沒有意義的紅燈
那次執行中還有一個工作是紅燈,而它是個誘餌。macos-latest 上的 Python 3.8 顯示為失敗,就緊鄰 Ubuntu 上真正的 3.8 失敗——近到看起來像是同一個錯誤。它不是。它的日誌寫著 The operation was canceled,發生在 setup-python 步驟,在任何測試執行之前。當矩陣的一條分支失敗時,GitHub Actions 預設會取消其餘分支,而我的工作流程從未關閉這個功能——因此真正的 Ubuntu 失敗把整個 macOS 欄位一起拖下水,3.8、3.11 和 3.12 全部被取消,沒有一個是真正的錯誤。把那個紅燈當成第四個問題去追查,你會浪費一下午去除錯一個取消。分辨真正的失敗與 fail-fast 雜訊是另一個步驟;光看顏色是不行的。
把三個真正的錯誤排在一起,它們有共通點。Bug 1 需要一台時區與回報時區不同的主機。Bug 2 也是如此。Bug 3 需要一台沒有 zoneinfo 的 Python。我的開發機器在香港,使用 Python 3.12 且內建 zoneinfo——這是三個錯誤都無法發生的唯一配置。我不是運氣不好而忽略了它們。我用來建置的機器,在結構上,正是三個錯誤都隱形的機器。我所信任的每一次綠色執行都是真實的,同時也是毫無價值的:程式碼與執行它的電腦一致。這就是綠色測試套件所能證明的全部。程式碼是否「正確」是另一回事,而這兩者之間的差距,正是機器的時區、Python 版本,以及已安裝模組躲藏之處。
真正重要的修正不是那三個修補中的任何一個。而是在標記發行版之前先執行矩陣,而不是把單一一台機器上的「236 passed」當作「可發行」。三個修補中的任何一個都只要十分鐘;我一直跳過的那個步驟,是廉價、感覺多餘的那一步——讓一台不是你的機器在你宣稱完成之前先試跑程式碼。
這個工具是 claude-code-notify——採用 MIT 授權,位於 github.com/Jeromefromcn/claude-code-notify,現在已包含正確的重置時區。如果你曾因為 Claude Code 的使用限制而浪費一下午,也許值得一看。而如果你寧可讀錯誤而不是文章,它們是歷史中的三個 commit——每個錯誤一個。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.