タイムゾーンの小さなバグを修正し、テストスイートを実行したところ、236のテストすべてがグリーンになりました。習慣からというより不安から、タグ付け前にCIへプッシュしました。CIは赤くなり — さらに赤くなりました。その小さな修正の下にはさらに2つのバグが重なっており、それらは私がテストを見ていたマシンでは一切見えていませんでした。

舞台は私がメンテナンスしているツール claude-code-notify です。これは Claude Code が利用制限に達したときと、制限がリセットされたときに通知を送ります。ツールの詳細は不要です。バグはごく普通のタイムゾーンバグで、私が学んだのは「テスト」についての教訓 — 完全にグリーンのローカルスイートがいかに顔を真っ赤に嘘をつくかということです。

元々のバグは、制限がいつリセットされるかを計算する部分にありました。

偶然正しかっただけ

Claude Code が制限のリセット時刻を伝えるとき、メッセージにプレーンテキストでタイムゾーンを括弧付きで書き込みます: resets 5:20am (Asia/Hong_Kong)。その時刻を読み取る関数 parse_reset() は時と分を取り出し、ホストマシンのローカルタイムで計算していました。括弧内のタイムゾーンは一度も見ていません。

私のホストマシンは香港時間に固定されたリモートサーバーです。つまり、Claude Code が埋め込んだタイムゾーンと、私のコードが暗黙的に想定したタイムゾーンは同じ — 両方とも Asia/Hong_Kong — だったため、毎回正しい答えが出ていました。正しかったのはコードが正しかったからではなく、比較しようとも思わなかった2つのタイムゾーンがたまたま同一だったからです。同じツールをロンドンのノートPCで実行したり、Claude Code が別のタイムゾーンでリセットを報告したりすると、「制限のリセットは…」という通知が何時間もズレて届き、例外もログ行も残らず、ただ自信たっぷりの誤った時刻が表示されます。

修正は小さかったです: テキストからタイムゾーン名を取り出し、Python の標準ライブラリ zoneinfo で解決するようにし、タイムゾーンが欠落または解決不能な場合のみホストのローカルタイムにフォールバックします — 以前は常にそうしていた動作を、意図的な最後の手段に格下げした形です。テストはパスしました。ここで疑念を抱くべきでしたが、抱きませんでした。ローカルでは疑う余地がなかったからです。

失敗できないテスト

ubuntu-latest は UTC で実行されます。最初の CI 実行は 4 failed, 232 passed を返し、そのうち2つは私が触れていないテストの失敗でした:

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

どちらも parse_reset()resets 9pm (Asia/Hong_Kong) に対して検証していました。どちらも「現在時刻」をプレーンな datetime — ホストローカルとして読み取ったもの — から構築し、パーサーの答えを検証する際もホストローカルに変換していました。同じ環境のタイムゾーンがアサーションの両側に存在していました。

古いバグのある parse_reset() もホストローカルタイムで動作していたため、その対称性は静かに致命的でした: ホストローカルを入力し、ホストローカルで計算し、ホストローカルで読み返す — タイムゾーンが方程式からすっと抜け落ちる。地球上のどのマシンでも両辺は一致しました。テストはタイムゾーン処理を検証しているように見えました。実際には「数値が自分自身に等しいこと」を検証していたのです。失敗することはあり得ませんでした — 私のマシンでも、誰のマシンでも。

Bug 1 の修正がその対称性を壊したことが、すべてが表面化した唯一の理由です。parse_reset() は今や 9pm Hong Kong を実在の瞬間として解決し、ホストクロックが何と言おうと同一の瞬間になります。私の香港マシンでは、その瞬間をホストローカルとして読み返すと依然として 21:00 — 依然としてグリーンです。UTC ランナーでは、同一の瞬間が 13:00 として読み返されます。香港の 9pm UTC の 1pm だからです。(13, 0) == (21, 0): 私のクロックが存在しない場所でコードが実行された瞬間に現れる8時間のずれです。

これを残しておくべきです。期待値をコードが読み取るのと同じ環境から導出するテストは自己整合的であり、グリーンのダッシュボード上では自己整合性と正しさは区別できません。パスするので信頼し、何も検証していないのです。修復は期待値を外部の明示的なものに固定すること — 入力時刻とアサーションの両方を「このマシンがローカルと呼ぶもの」ではなく zoneinfo.ZoneInfo("Asia/Hong_Kong") に固定します。私は TZ=UTCTZ=America/Los_AngelesTZ=Pacific/Kiritimati の下で再実行し、テストがマシンに関する事実ではなく世界に関する事実を主張するようにしました。

コードが失敗するのと同じ方法でテストは失敗しなかった

2回目のプッシュ。再び赤、今度は違った形で:

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.tomlrequires-python = ">=3.8" を宣言 — し、CI マトリクスは意図的に 3.8 を実行します。本番コードはすでにそれを考慮していました: ZoneInfo のインポートは try/except ImportError の後ろにあり、失敗した場合はホストローカルタイムにフォールバックします。これは Bug 1 の修正が依拠するのと同じフォールバックです。柔軟に振る舞います。

テストは柔軟ではありませんでした。タイムゾーン解決を検証する4つのテスト — 私が固定した2つと、修正とともに追加した2つ — はどれも素の from zoneinfo import ZoneInfo を行っていました。3.8 ではそれは優雅なフォールバックではなく、テスト本体が実行される前に発生するハードな ModuleNotFoundError です。テスト対象のコードは劣化し、それをカバーするはずのテストが代わりに壊れたのです。

修正は1行ずつ — pytest.importorskip("zoneinfo") — で、モジュールが存在しない場合はテストをクリーンにスキップします。その背後にある原則は、前の2つのバグよりも地味ですが、両方よりも重要です: テストはカバーするコードと同じ方法で劣化しなければなりません。本番がオプショナルな依存関係の欠如を許容するなら、その依存関係をハードに要求するテストは本番をテストしていません。コードが決して約束しなかった、より厳格な約束をテストしているのです。

意味のある赤と、意味のない赤

その実行でもう1つのジョブが赤く、それはおとりでした。macos-latest on Python 3.8 は失敗と表示され、本物の Ubuntu 上の 3.8 失敗のすぐ隣にあり — 同じバグのように見えるほど近かったのです。違いました。ログには The operation was canceled とあり、単一のテストも実行される前に setup-python ステップで発生していました。マトリクスの1つのレッグが失敗すると、GitHub Actions はデフォルトで残りをキャンセルし、私のワークフローはそれをオフにしていません — したがって、本物の Ubuntu の失敗が macOS 列全体を、3.8、3.11、3.12 すべてをキャンセルし、実際のバグは1つもありませんでした。その赤を4つ目の問題として追いかけると、キャンセルのデバッグに午後を無駄にします。本物の失敗とフェイルファストノイズを区別することは、それ自体が1つのステップです。色だけでは判断できません。

3つの本物のバグを並べると韻を踏みます。Bug 1 は、報告されたタイムゾーンと異なるタイムゾーンを持つホストを必要とします。Bug 2 も同様です。Bug 3 は zoneinfo のない Python を必要とします。私の開発マシンは香港にあり、Python 3.12 で zoneinfo が組み込まれています — 3つのバグのどれも発生し得ない唯一の構成です。私は運悪く見逃したわけではありません。私が構築したマシンは、構造的に、3つすべてが不可視になるまさにそのマシンでした。信頼していたすべてのグリーンランは、真実でありながら同時に無価値でした: コードはそれを実行したコンピュータと一致したのです。それがグリーンスイートが証明することのすべてです。コードが正しいかどうかは別問題であり、この2つの主張の間の空間こそが、マシンのタイムゾーン、Python バージョン、インストール済みモジュールが隠れる場所なのです。

実際に重要だった修正は、3つのパッチのどれでもありませんでした。タグ付け前にマトリクスを実行することでした。1台のボックスで「236 passed」と読むのを「出荷可能」と見なす代わりに。3つのパッチのどれも10分程度の作業でした。スキップしていたステップは、安価で冗長に感じられるものでした — 自分のマシンではないマシンに、完了と呼ぶ前にコードを試させることです。

ツールは claude-code-notify — MIT ライセンスで、github.com/Jeromefromcn/claude-code-notify にあり、正しいリセットタイムゾーンが含まれています。Claude Code の利用制限が午後を奪ったことがあるなら、価値があるかもしれません。そして、バグの経緯ではなくバグ自体を読みたい場合は、履歴に3つのコミットがあります — バグごとに1つずつ。