ローカルで Claude Code に「全テスト通っています」と報告された変更を、勢いに任せて push したら GitHub Actions のジョブが真っ赤になっていた。個人開発で 12 年ほどアプリを作ってきた中で、これは Claude Code 登場以降にいちばん増えた種類の事故だと感じています。
不思議なのは、Claude Code が嘘をついているわけではないことです。ターミナルの記録を遡ると、本人なりには pass: 142 のような出力を確かに読み取っています。落ちているのはたいてい、その手前にある「テストフレームワークから返ってきた終了コードを Bash がどう扱ったか」のレイヤーです。
Vitest / Jest / pytest / Go test を素材に、Claude Code がテスト結果を誤判定してしまう典型パターンを4つに整理し、それぞれに対する仕組みベースの対策を順に書いていきます。2014年から累計5,000万DL超のアプリを個人で運営してきた廣川政樹の現場ノウハウとして読んでいただければ嬉しいです。
起きていること — 「成功と報告された変更が CI で落ちる」3つの兆候
兆候自体はわかりやすいので、まず3つに分けておきます。
ひとつ目は、ローカルの Claude Code は ✅ all tests passed と書いてきたのに、push 直後の CI で同じテストが Fail します。コードは何も変えていないのに環境差で落ちる、というパターンです。
ふたつ目は、Claude Code が走らせたテストの出力をスクロールすると Tests: 1 failed, 141 passed と書いてあるのに、Claude Code 自身は「すべて成功」と要約しているケースです。これはモデルが見ているテキストと、シェルが返した終了コードがずれている状態です。
みっつ目は、Claude Code がテストを走らせている途中で「タイムアウトしました、リカバリーします」と一度報告し、その後で別のテストだけ実行して passed を確認し、最終的に「全テスト通過」と総括してしまうパターン。watch モード絡みで頻発します。
兆候は違いますが、根っこは同じです。Bash の終了コードがテストの真の状態を反映していないか、反映されているのに Claude Code が見ないように作られているかのどちらかです。
原因1: シェルの && チェーンと || true の癖
いちばんよく見かけるのが、Claude Code が組み立てる Bash コマンドが、暗黙のうちにエラーを握り潰す形になっているパターンです。
# Claude Code がよく書いてしまう例
npm install && npm run build && npm test || echo "test failed but continuing"人間が手で書いたなら「テスト落ちたら止めるよね」と即座に気付くのですが、Claude Code は CI の文脈で || echo や || true を学んでいるので、似たような変形をしれっと出してきます。最終的な終了コードは echo の 0 になり、Claude Code は「コマンドは成功扱いで返ってきた」と判定します。
切り分けは単純で、テストの直後に必ず終了コードを変数に取り出すように矯正します。
npm test
TEST_EXIT=$?
echo "TEST_EXIT=$TEST_EXIT"
[ "$TEST_EXIT" -eq 0 ] || exit "$TEST_EXIT"$? を明示的に拾うコードを 1 度書いておくと、Claude Code はそのプロジェクトでは同じスタイルを継承してくれることが多いです。CLAUDE.md に「テスト実行後は必ず TEST_EXIT=$? を確認する」と書いておくと、さらに安定します。
原因2: テストランナー側が exit 0 を返してしまう設定
シェルのほうを締め上げても、テストフレームワーク側が「失敗らしい失敗を返さない」モードで動いていることがあります。これも実は、本人たちにとっては正当な仕様です。
Jest と Vitest には --passWithNoTests というオプションがあり、テストファイルが 0 件のときに緑で抜けます。便利ですが、tests/__obsolete__ のような旧ディレクトリだけが残っていて、新しい場所のテストが拾われていない、という状況で素通りされると気付けません。
pytest にも --exitfirst や --maxfail の挙動を変える組み合わせがあり、特に pytest -o "norecursedirs=tests" のような上書きをやってしまうと「テストが 1 件もありません」が成功扱いで返ります。
Go の go test ./... は基本的に厳しめですが、-run フラグで何にもマッチしないパターンを指定するとやはり 0 件成功で返ります。Claude Code がリファクタリングのなかで -run 'TestNewFoo' のようにスコープを絞り、結果として 0 件のテストを走らせて「通った」と判定する場面に何度か遭遇しました。
対策としては、「ローカルで走るテストの最低件数」を明示的に検証する一行を CLAUDE.md に書いてしまうのが手っ取り早いです。
# vitest の例
npx vitest run --reporter=json | tee vitest-report.json
COUNT=$(jq '.numTotalTests' vitest-report.json)
[ "$COUNT" -ge 10 ] || { echo "テスト件数が少なすぎます ($COUNT)"; exit 1; }10 件という数字に意味はなく、プロジェクトに応じて変えれば構いません。大切なのは「成功なら必ずある件数以上が走っているはず」という前提を、機械が読める形で残しておくことです。
原因3: 並列・watch モードで「途中の成功」だけが Claude Code の目に入る
これは個人開発で React Native のアプリを書いていたときに 2 回はまりました。npx jest --watch を起動したまま Claude Code に作業を任せると、ファイルが変わるたびに Jest が自動的に再実行されます。Claude Code は出力の流れる速度が速いことに引きずられて、
144 passed, 1 failed ... 145 passed (filter changed)
の 後半の 145 passed だけ を成功の根拠として拾ってしまうことがあります。実際には、フィルター変更によってその 1 件が「次の watch サイクルでは実行されなかった」だけです。
回避策はシンプルで、Claude Code に渡すテスト実行コマンドは原則として watch モードを禁止し、ワンショットで終わるコマンドだけ書く約束にします。
# Watch モードは Claude Code 用ではない
npx jest --watch # ❌
# 必ずワンショットで終わるコマンドだけ
npx jest --ci --reporters=default --reporters=summary # ✅CI フラグを付けると Jest はインタラクティブ機能を抑制し、終了コードもまっすぐ返してくれます。--ci を付けるだけで watch 由来の事故はほぼ消えるので、おすすめです。
原因4: ローカルと CI の環境変数差で条件分岐が変わる
Claude Code がローカルで実行している npm test と、GitHub Actions の npm test が同じスクリプトを叩いていても、結果が違うことがあります。package.json の scripts.test がこんなふうになっているケースが多いです。
{
"scripts": {
"test": "if [ \"$CI\" = \"true\" ]; then vitest run --coverage; else vitest run; fi"
}
}CI のほうではカバレッジ計測が走り、vitest.config.ts の coverage.thresholds.lines で 80% を切ると Fail します。ローカルでは閾値チェックがないので、テスト本体は全部 pass でも CI だけ落ちる、という状況が生まれます。
これは Claude Code というよりプロジェクト設定の話ですが、Claude Code に「テストを走らせて」と頼むときに CI=true npm test で実行するように躾けておくと、ローカルでの実行が CI に近づきます。CLAUDE.md に書いておく価値のある 1 行です。
# 推奨: ローカルでも CI 相当で実行する
CI=true npm test防御の組み立て方 — フックと CLAUDE.md でズレを潰す
ここまでの 4 つはどれも、Claude Code 単体で完全に防ぐのは難しい問題です。けれど、3 段階の防御を組み合わせれば、CI で落ちる確率は実用上ゼロに近づきます。私の個人開発リポジトリでは、次の構成に落ち着きました。
第一段階は CLAUDE.md に書く約束ごと。テスト実行コマンドは必ず CI=true を付ける、watch モードは使わない、終了コードを TEST_EXIT=$? で拾う、という 3 つを明文化します。Claude Code は CLAUDE.md を強く参照するので、毎セッション言わなくても守ってくれるようになります。
第二段階は PostToolUse フック。Bash ツールの実行が終わったタイミングで、終了コードが 0 でないなら Claude Code に明示的に伝えるフックを置きます。
// .claude/hooks.jsonc
{
"PostToolUse": [
{
"matcher": "Bash",
"command": "test \"$CLAUDE_TOOL_EXIT_CODE\" -eq 0 || echo \"⚠️ Bash exit=$CLAUDE_TOOL_EXIT_CODE — テストは失敗です。要約に含めてください\""
}
]
}このフックを噛ませると、Claude Code は終了コードが 0 でないコマンドを「成功」と要約しづらくなります。要約の段階で「失敗です」という文字列が直前に注入されるので、無視するほうが難しい状態を作れます。
第三段階は CI 側の同じスクリプト。GitHub Actions のワークフローでも、ローカル CLAUDE.md と同じコマンドを CI=true npm test の形で実行します。CI 側でしか起きない事故は、CI 側でも常に検出できる状態を作っておくのが結局いちばん安く、これは Claude Code 時代になってから特に効きが良くなりました。
# .github/workflows/test.yml の該当部分
- name: Run tests with same command as Claude Code
env:
CI: "true"
run: |
npm test
TEST_EXIT=$?
[ "$TEST_EXIT" -eq 0 ] || exit "$TEST_EXIT"自分のリポでの一回のミスから学ぶ
個人で運営しているアプリのリポでは、Claude Code に「テスト全部通ったから push してください」と任せた直後に CI が真っ赤になり、ストアアップロード前のパイプラインがブロックされたことがあります。原因は、CLAUDE.md にスコープ縮小のための vitest --testPathPattern 'unit/' を書き残していたことでした。ブラックボックステスト群が実は全件スキップされていて、passed: 23 の表示は嘘ではない、けれど「全テスト」ではなかったのです。
このときの教訓を CLAUDE.md に短く書き残しています。
テストを走らせるときは
npx vitest run --reporter=json | jq .numTotalTestsを必ず叩いて、件数が想定下限を下回らないか確認すること。
たった一行ですが、それ以降同種の事故は起きていません。Claude Code はテキストの指示に驚くほど忠実に従ってくれるので、自分が一度ハマったポイントを「事実上の rule」として書き残しておくと、未来の自分(と Claude Code)が同じ穴に落ちにくくなります。
全体を振り返って — 「成功と報告された」を一段だけ疑う
Claude Code が「テスト全通過」と報告したとき、本当に疑うべきは Claude Code そのものではなく、シェルとテストランナーが返した終了コードです。|| true を排除し、--passWithNoTests のような便利オプションを慎重に扱い、watch モードはローカル人間専用にしておく。これだけで、push 直後に CI が真っ赤になる回数はかなり減ります。
次にテストを Claude Code に任せるときは、まず package.json の scripts.test を一度開いてみてください。|| true や --passWithNoTests が紛れ込んでいたら、それは未来のあなたが踏む地雷です。今のうちに外してしまうと、Claude Code との協働もぐっと安全になります。
同じ問題で時間を奪われた方の参考になれば幸いです。お読みいただきありがとうございました。