◉CLAUDE LABEN
●2.1.301 — vim の % モーション、VS Code パネルでの LaTeX 数式表示、使用量上限待ちのリトライ監視の上限が入りました(10/15)●2.1.299 — 200K トークン超で約54分放置した会話はアイドル圧縮。MCP の許可ルールは名指ししたサーバーだけに一致します●SONNET 4.5 — Claude API での提供終了は 11/30、残り45日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました●2.1.301 — vim の % モーション、VS Code パネルでの LaTeX 数式表示、使用量上限待ちのリトライ監視の上限が入りました(10/15)●2.1.299 — 200K トークン超で約54分放置した会話はアイドル圧縮。MCP の許可ルールは名指ししたサーバーだけに一致します●SONNET 4.5 — Claude API での提供終了は 11/30、残り45日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました
記事一覧/Claude Code
⟐ Claude Code/2026-05-20中級

Claude Code が『テスト全通過』と報告したのに CI で落ちる — 終了コードを正しく拾うための検証設計

ローカルでは『テスト全通過』と報告された変更が、push 後の GitHub Actions で真っ赤になる。原因の多くは Claude Code 側ではなく、シェルとテストランナーの終了コード解釈にあります。実例ベースで切り分け方を整理しました。

Claude Code265テスト3CI6GitHub Actions12Bash5トラブルシューティング51

ローカルで Claude Code に「全テスト通っています」と報告された変更を、勢いに任せて push したら GitHub Actions のジョブが真っ赤になっていました。個人開発で 12 年ほどアプリを作ってきた中で、これは Claude Code 登場以降にいちばん増えた種類の事故だと感じています。

不思議なのは、Claude Code が嘘をついているわけではないことです。ターミナルの記録を遡ると、本人なりには pass: 142 のような出力を確かに読み取っています。落ちているのはたいてい、その手前にある「テストフレームワークから返ってきた終了コードを Bash がどう扱ったか」のレイヤーです。

Vitest / Jest / pytest / Go test を素材に、Claude Code がテスト結果を誤判定してしまう典型パターンを4つに整理し、それぞれに対する仕組みベースの対策を順に書いていきます。加えて、対策を入れたつもりが無効化されてしまう罠を1つ挙げます。どれも自分の個人開発リポジトリで実際に踏んだものだけを扱います。

起きていること — 「成功と報告された変更が 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 が見ないように作られているかのどちらかです。

どの兆候がどの原因に対応するかを、先に一覧にしておきます。心当たりのある行から読み進めていただいて構いません。

症状まず手元で叩くもの疑う原因
ローカルは緑、CI だけ赤CI=true npm test原因4(環境変数で分岐が変わる)
出力に failed があるのに成功と要約されたnpm test; echo "exit=$?"原因1(チェーンが失敗を握り潰す)
テストが異様に速く終わる実行件数を出力させる原因2(0件でも成功で返る)
件数チェックを入れたのに素通りするecho "${PIPESTATUS[@]}"パイプが終了コードを差し替えている
「タイムアウト→リカバリ」の後に成功と総括された実行コマンドに --watch がないか確認原因3(watch モード)

原因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 --outputFile=vitest-report.json
COUNT=$(jq '.numTotalTests // 0' vitest-report.json)
[ "$COUNT" -ge 10 ] || { echo "テスト件数が少なすぎます ($COUNT)"; exit 1; }

10 件という数字に意味はなく、プロジェクトに応じて変えれば構いません。大切なのは「成功なら必ずある件数以上が走っているはず」という前提を、機械が読める形で残しておくことです。

ここで --outputFile を使い、| tee を避けているのには理由があります。次の節がそれです。

見落としやすい罠 — パイプが終了コードを差し替える

件数チェックを入れたのに、なぜか同じ事故が止まりません。そう言われて覗いたコードには、たいていパイプが1本挟まっています。

# 一見きちんと検証しているように見える
npx vitest run --reporter=json | jq '.numTotalTests'
echo $?   # → jq が成功する限り 0。vitest が Fail で 1 を返していても 0

パイプラインの終了コードは、既定では最後のコマンドのものです。jq も tee も、渡されたバイト列を処理し終えれば正常終了します。上流の vitest が返した 1 は、パイプの継ぎ目で静かに捨てられます。

手元で 5 秒あれば確かめられます。

$ (exit 1) | cat; echo $?
0
$ set -o pipefail; (exit 1) | cat; echo $?
1

厄介なのは、この形が「検証を強化したコード」の顔をしていることです。原因1の || true は目に入れば怪しいと分かります。けれど | tee report.json は、誰が見ても真面目な処理に見えます。私自身、件数チェックを足したこと自体に満足してしまい、その行が実は何も守っていない可能性を数日のあいだ疑いませんでした。ログを 1 行ずつ追い直して PIPESTATUS に辿り着いたときは、少し背筋が寒くなりました。

対策は2つです。ひとつはパイプをやめること。主要なテストランナーはたいていファイル出力オプションを持っています。

# vitest / jest
npx vitest run --reporter=json --outputFile=vitest-report.json
npx jest --ci --json --outputFile=jest-report.json

もうひとつは、どうしてもパイプが必要な場面で set -o pipefail か PIPESTATUS を使うことです。

set -o pipefail
npx vitest run --reporter=json | tee vitest-report.json
TEST_EXIT=$?
 
# pipefail を使わない場合は、各要素の終了コードを直接読む
npx vitest run --reporter=json | tee vitest-report.json
echo "vitest=${PIPESTATUS[0]} tee=${PIPESTATUS[1]}"

set -o pipefail はシェルのオプションであり、前の呼び出しで設定した状態が次に引き継がれるとは限りません。テスト実行と同じ1コマンドの先頭に置くか、package.json の scripts.test に bash -o pipefail -c '...' の形で埋め込んでしまうのが確実です。PIPESTATUS は bash 固有の配列なので、sh / dash で走る CI ステップでは使えない点にも注意してください。

原因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 ツールの実行が終わった直後にフックを走らせ、その出力を Claude Code の要約直前に割り込ませます。

ここで一度つまずいたのが、フックが受け取る情報の形でした。フックに渡ってくるのは環境変数ではなく、標準入力に流れてくる JSON です。tool_response には Bash の標準出力・標準エラーが入りますが、終了コードをそのまま読める保証はありません。そこで「結果を書き残す役」をテスト実行側に持たせ、フックはそれを読むだけの構成にしました。

# package.json の scripts.test から呼ぶ想定
set -o pipefail
npx vitest run --reporter=json --outputFile=vitest-report.json
TEST_EXIT=$?
COUNT=$(jq '.numTotalTests // 0' vitest-report.json)
mkdir -p .claude && echo "$TEST_EXIT $COUNT" > .claude/last-test-exit
[ "$TEST_EXIT" -eq 0 ] || exit "$TEST_EXIT"
#!/usr/bin/env bash
# .claude/scripts/assert-test-exit.sh
SENTINEL=".claude/last-test-exit"
[ -f "$SENTINEL" ] || exit 0
read -r TEST_EXIT COUNT < "$SENTINEL"
rm -f "$SENTINEL"
 
if [ "$TEST_EXIT" -ne 0 ]; then
  echo "テストは失敗しています (exit=$TEST_EXIT)。成功として要約しないでください。" >&2
  exit 2
fi
if [ "${COUNT:-0}" -lt 10 ]; then
  echo "実行されたテストは ${COUNT} 件で、想定下限を下回っています。" >&2
  exit 2
fi
// .claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": ".claude/scripts/assert-test-exit.sh" }
        ]
      }
    ]
  }
}

肝は最後の exit 2 です。PostToolUse フックが 2 で終了すると、標準エラーに書いた文字列が Claude Code 側へ差し戻されます。要約を書く直前に「失敗しています」という日本語が注入されるため、成功として書くほうが難しい状態になります。単に echo して 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 が真っ赤になり、App Store へのアップロード前のパイプラインがブロックされたことがあります。原因は、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 との協働もぐっと安全になります。

同じ問題で時間を奪われた方の参考になれば幸いです。お読みいただきありがとうございました。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⟐ Claude Code2026-10-13
「cd して git add して commit」の承認が毎回出る — 設定の抜けではなく、仕様だと分かるまでにたどった道
cd /path && git add && git commit のような複合コマンドで、許可ルールを入れても承認が繰り返し出る。公式の権限ドキュメントと GitHub の報告を突き合わせると、cd と git の組み合わせは設計上プロンプトが出ると分かりました。原因の切り分け順と、手数を減らす4つの直し方を並べます。
⟐ Claude Code2026-10-12
SPM に移したら、起動した瞬間にアプリが落ちるようになりました — dyld の一行を Claude Code と読み解いた記録
CocoaPods から Swift Package Manager へ移した直後、ビルドは通るのに起動した瞬間に落ちました。dyld の一行から原因を切り分け、直し方と確認コマンドまでをまとめます。
⟐ Claude Code2026-10-05
「コミットは私がします」と書いたのに git log に一行増えていた朝、指示を強めるのをやめました
Claude Code に「コミットしないで」と頼んだのにコミットされたとき、まず git reset --soft で戻し、permissions.deny と PreToolUse hook の二層で物理的に止める手順です。deny ルールが素通しする git -c の形と、hook の試し方を書き残します。