この記事は、連載「読まない技術」の第 1 回です。うまくいかない症状と、その対処を一つずつ並べています。各回はファイルやスクリプトを一つ置けば完結します。その仕組みがなぜ要るのかは、置いたあとに解説を読めば分かります。連載の全体像と各回の一覧は序論にあります。

今回は、テスト結果の話です。AI がテストを走らせるたびに、テスト結果が数万行出ます。AIは、届いた出力を選べません。届いた行は全部コンテキストに入るので、序論で見た「足すほど薄まる」が、ここでも起きます。序論で増えていたのは指示ファイルに書いたルールでしたが、ここで増えるのはツールの出力です。

AIコーディングエージェントを使っているなら、直近のセッションで、AIがテストを走らせた箇所のテスト結果を探してみてください。そのテスト結果は何行ありますか。それを最後まで読んだ人はいますか。

読んだ人は、たぶんいません。ただしAIは別です。AIに届いた行は、次の応答の材料になり、課金の対象にもなります。AIがテストを直しながら5回走らせれば、コンテキストに入るテスト結果も5回ぶんです。コンテキストの行が増えるほど応答の品質は落ちるので、テストを走らせるほど、AIがテストを直す能力は下がります。

私の手元の本番プロジェクトでは、AIが13,950件のテストを全部走らせると、テスト結果は26,147行(2.1MB)になりました。typingtube という、YouTube の音楽動画でタイピング練習ができる Web サービスで、個人で運営しています。この26,147行を3行に絞るのが、ラッパー(本来のコマンドを包んで代わりに実行するスクリプト)です。

AIにテストを直させると、AIは直してはテストを走らせ、また直します。そのあいだに、AIがテストを直す能力は、回を重ねるほど下がっていきます。この症状に、あなたなら次のどちらで対処しますか。

  • 指示ファイルに「出力は要約して読むこと」と書く
  • テスト実行の入口をラッパーに差し替えて、失敗と件数だけを出す

答えは、ラッパーに差し替えるほうです。この症状は、テストを走らせた回数とともに重くなります。走らせるたびに数万行のテスト結果がAIに届き、届いた分はコンテキストに積み上がっていくからです。AIは、届いた出力を選べません。指示ファイルに「要約して読むこと」と書いても、AIがその指示に従えるのは、出力が届いたあとです。コンテキストに入った量は、そこからは減りません。しかも、あなたがこの指示を書けば、指示ファイルのルールが1つ増えるので、序論で見た「足すほど薄まる」が起きます。だから、失敗と件数だけに絞るのは、出す側のラッパーの役目です。

理解したことは一文にできます。

AIは届いた出力を選べない。だから出す側で、失敗と件数だけに絞る。

CC BY 4.0

なぜ、テストを走らせるほど、テストを直す能力が下がるのか

応答のたびに、セッションの履歴は丸ごとAIへ送り直されます。AIを呼び出すAPIは、前のやり取りを覚えていない作り(ステートレス)で、呼び出すたびに会話の履歴を全部送ります。テスト結果も、その履歴の一部です。テストを5回走らせたあとは、5回ぶんのテスト結果が、応答のたびにコンテキストに載ります。

ただし、AIに届くのはテスト結果の一部です。Claude Codeはコマンドの出力に上限を置いていて、上限を超えた分は切られます。テストが1件でも失敗して終わると、26,147行のうちAIが受け取るのは、先頭と末尾の抜粋です。真ん中にある失敗は、AIに届く抜粋から外れます。

長い入力から必要な行を探させる測定では、関係のない行が1つ混じるだけで、必要な行を見つける精度が落ちました。AIがいま直している1件の失敗から見ると、テスト結果は、コンテキストの中で最も関係の薄い部分です。

テストを直す能力については、Claude Codeのベストプラクティスが書いています。コンテキストが埋まっていくにつれてLLMの性能は落ち、埋まりかけると、AIは前の指示を「忘れ」はじめたり、誤りを増やしたりすることがある、という説明です。

仕組み: テスト実行の入口を1本に変える

私は、テスト実行の入口を、ラッパー1本に差し替えました。次のスクリプトは、手元のコードを短くしたものです(例はRails / Minitestですが、grep する行を自分のテストランナーの出力に合わせて書き換えれば、ほかのテストランナーでも使うことができます)。

#!/bin/bash
# scripts/test.sh — 全出力をログに残し、会話には要約だけ返す
log=tmp/test_last.log
mkdir -p tmp
bin/rails test "$@" >"$log" 2>&1
status=$?

echo "--- summary (full log: ${log}) ---"
grep -E "^Finished in " "$log" | tail -1
grep -E "^[0-9]+ runs, " "$log" | tail -1   # 件数の行。⚠️ 削らない(後述)

failures=$(grep -cE "^(Failure|Error):" "$log" || true)
if [ "${failures:-0}" -gt 0 ]; then
  echo "--- ${failures} failure(s)/error(s) (first excerpt) ---"
  grep -A 3 -E "^(Failure|Error):" "$log" | head -40
fi
exit $status

AIのコンテキストに入るのは、これだけです。

--- summary (full log: tmp/test_last.log) ---
Finished in 277.851362s, 50.2067 runs/s, 318.7712 assertions/s.
13950 runs, 88571 assertions, 0 failures, 0 errors, 228 skips

私の手元のラッパーの出力は29行あり、上のスクリプトは、そのうち中心になる3行を出します。残りの行は、走らせていない領域の一覧などです。全文は毎回 tmp/test_last.log に残り、ラッパーの出力の1行目にその場所が出ます。ログを残すのは、AIがあとで全文を読み直すことができるようにするためです。

「出力は要約して読むこと」と指示ファイルに書く手もあります。ただ、それは指示ファイルにルールを1つ足す解で、序論で見た「足すほど薄まる」が起きます。私は指示ファイルをそのままにして、テスト実行の入口だけを変えました。ルールはAIが破ることもありますが、テスト実行の入口は、そこにしかありません。

ただし、ラッパーを置いただけでは、AIはそれまでどおり素の rails test を打ちました。rails test のような標準コマンドは、AIが既に知っている言葉で、置いた scripts/test.sh は、AIが今日初めて見る言葉です。「ラッパーを使うこと」と指示ファイルに書いても、AIは rails test を打ちました。指示ファイルに書いた1行では、AIに染み付いた習慣は書き換わりません。

Claude Codeの公式のドキュメントも、同じ区別をしています。指示ファイルに書くのは「AI が推測できない Bash コマンド」で、書かないのは「AI が既に知っている標準的な言語の慣習」です。scripts/test.sh は書く側、rails test は書かない側に入ります。指示ファイルに書く意味があるのは、AIが知らない側だけです。だから私が止めるのは、標準コマンドのほうです。ただし、逃げ道を一つ開けておきます。

Claude Code なら、素の rails test を止めてラッパーへ案内するhookを1つ置くと、テスト実行の入口が本当に1本になります。hookは、AIのコマンド実行の直前に割り込むスクリプトです。.claude/settings.json には、次のように登録します。

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/use_test_wrapper.sh"
      }]
    }]
  }
}

hookのスクリプトは、次のとおりです。

#!/bin/bash
# .claude/hooks/use_test_wrapper.sh
command=$(cat | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))')
case "$command" in
  *scripts/test.sh*|*SKIP_TEST_GUARD=*) exit 0 ;;   # ラッパー経由と、明示の逃げ道は通す
esac
if printf '%s' "$command" | grep -qE '(^|[;&|[:space:]])(bin/)?rails[[:space:]]+test([[:space:]]|$)'; then
  echo "Run tests with scripts/test.sh <same args> (full output stays in tmp/test_last.log)" >&2
  exit 2   # exit 2 = 実行させず、stderr の案内を AI に読ませる
fi
exit 0

hookの案内で大事なのは、文面より、案内が届く時点です。この案内は、AIがコマンドを打ったその場に、実行の拒否として返ります。止められたコマンドは走らないので、26,147行の出力も生まれません。人間がセッションの途中で「ラッパーを使って」と打ち込んでも、AIが素の rails test を走らせている最中なら、AIがその一言を読むのは、実行中のコマンドが終わってからです。そのときには、素の rails test はもう走り終わっていて、その出力はコンテキストに入っています。

CC BY 4.0 はここまで

読まなくなったもの

テスト結果の全文です。人間もAIも、読むのはラッパーの出力だけです。全文はログに残してあるので、原因の調査で全文が要るときだけ、ログを開きます。

CC BY 4.0

注意: 失敗以外の異常がラッパーの出力から消える

この仕組みの死角は、失敗という形をとらない異常がラッパーの出力から消えることです。スキップの急増、警告、実行件数の減少が、その異常です。テストが丸ごとロードされなくなっても、残りのテストが全部通れば、ラッパーの出力には「0 failures」と出ます。

私の手元では、テストの実行そのものが立ち上がらなかったことがあります。1件も走っていないのに、走らせた記録だけが残りました。このとき失敗は0件で、ラッパーの出力は全部通ったときと同じ形でした。

だから、件数の行だけは、ラッパーの出力に必ず残します。上の実行例の 13950 runs, 88571 assertions, 0 failures, 0 errors, 228 skips がそれです。省略しすぎるのも良くありません。件数の行には、数字と項目名を必ず並べて出します。

検証手順: 失敗が届くことを一度だけ確かめる

前提

この回のラッパー(scripts/test.sh)を置き、素の rails test を止めるhookを登録し終えておきます。テストは1件以上あり、手元で走るものにします。

所要時間: 5分

手順

  1. あなたが、テストを1件わざと壊します。短い時間で走るテストファイルを1つ開き、期待値を1文字だけ変えます(assert_equal "abc", ... を "abd" に)。後始末で元に戻すので、ファイル名と行番号を控えておいてください
  2. AIに「テストを走らせて」と頼みます。ラッパーを実行するのは、AIに任せてください。確かめたいのは、AIが入口を通ったときに何が届くかだからです

合格条件(返ってきたラッパーの出力が、すべて満たすこと)

合格の条件は 3 つです。1 つ目に、ログの場所(tmp/test_last.log のような1行)が出ていることを確かめます。2 つ目は、壊したテストの名前と、期待値・実際値の差(Expected: "abc" と Actual: "abd" に当たる行)が出ていることです。手順 1 で控えたファイル名が、ここに出ます。3 つ目は、件数の行の failures が1以上になっていること(13950 runs, 1 failures のような行)です。

合格しなかったとき

  • 0 failures と出た —— 壊したテストが、実行の対象から外れています。手順 1 で選んだファイルを実行の対象に入れてから、手順 2 をやり直します
  • 失敗はしたが、テスト名や差が出ない —— ラッパーが拾う行が足りません。ラッパーの grep の範囲を広げます

後始末

壊した1文字を元に戻し、もう一度AIにテストを走らせます。件数の行が 0 failures に戻れば終わりです。戻し忘れると、このあと走らせるテストが、ずっと失敗し続けます。

この手順で確かめたのは、「失敗したら必ず届く」ことだけです。これを確かめておけば、ラッパーの出力に失敗が出ていないとき、その理由が「失敗がない」のか「失敗が届かないつなぎ方」なのかを、区別することができます。


CC BY 4.0 はここまで

次回は「メモリを読まない技術」。AI が書き溜めたメモリを、私は読み返していません。 それでも、メモリが増えて困ったことはありません。

連載「読まない技術」