この記事は、連載「読まない技術」の第 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文字だけ変えます(
assert_equal "abc", ...を"abd"に)。後始末で元に戻すので、ファイル名と行番号を控えておいてください - 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 が書き溜めたメモリを、は読み返していません。 それでも、メモリが増えて困ったことはありません。
連載「読まない技術」
- ← 前回: 序論 AIの出力を、もうほとんど読んでいない
- → 次回: 第 2 回 メモリを読まない技術
- 全回の一覧: 序論 AIの出力を、もうほとんど読んでいない