この記事は、連載「読まない技術」の第 1 回です。各回はファイルやスクリプトを一つ置けば完結します。連載の全体像と各回の一覧は序論にあります。
今回は、テストの出力の話です。AI がテストを走らせるたびに、数万行の出力がそのコンテキストに流れ込みます。そのうち AI に要るのはどの行かを扱います。
まず一つだけ、確かめてみてください。AI コーディングエージェントとの直近のセッションで、テストを実行した箇所の出力を探します。何行ありますか。その出力を最後まで読んだ人はいますか。
読んだ人は、たぶんいません。ただし AI は別です。AI はツール出力を読み飛ばせません。届いた行は全部コンテキストに入り、次の応答の材料になり、課金の対象になります。直しながら 5 回走らせれば 5 回入ります。コンテキストが詰まるほど応答の品質は落ちるので、テストを回すほど、テストを直す能力が下がる構造です。
私の手元の本番プロジェクトで、テスト全量の出力は 26,147 行(2.1MB)ありました。typingtube という、YouTube の音楽動画でタイピング練習ができる Web サービスで、個人で運営しています。13,950 件のテストを回すと、この量になります。この記事で置く仕組みは 1 つ、それを 3 行にするラッパー(本来のコマンドを包んで代わりに実行するスクリプト)です。
理解したことは一文にできます。
AI はテスト出力を全部コンテキストに入れる。だから出力の側を、失敗と件数だけが届く形にする。
CC BY 4.0
仕組み: 出力の入口を 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
出てくるのはこれだけです。
--- 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 行です。残りの行は、走らせていない領域の一覧など、この連載の後の回で足していきます。
大事なのは、AI への指示を 1 行も足していないことです。「出力は要約して読むこと」と指示ファイルに書く手もありますが、それは序論で見たとおり、足すほど薄まる側の解です。指示ではなく入口を変えました。ルールは破れますが、入口はそこにしかありません。
ただし、置いただけでは AI がこの入口を通ってくれません。rails test のような標準コマンドは、AI が学習で骨身に染みている言葉です。あなたが置いた scripts/test.sh は、AI が今日初めて見る言葉です。「ラッパーを使うこと」と指示ファイルに書いても、テストを走らせようとした瞬間に出てくるのは、何億回と見てきた標準コマンドの側です。指示ファイルの 1 行は、AI の中で染み付いた習慣と優先順位を競うことになり、たいてい負けます。だから、標準コマンドの側を塞ぎます。
Claude Code なら、素の rails test を止めてラッパーへ案内する hook を 1 つ添えると、入口が本当に 1 本になります。hook はコマンド実行の直前に割り込むスクリプトで、.claude/settings.json にこう登録します。
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/use_test_wrapper.sh"
}]
}]
}
}
中身は「コマンドが素の rails test なら、ラッパーの使い方を表示して実行させずに終わる」だけです。
#!/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
AI は素のコマンドでは動けず、案内を読んだときにだけ先へ進めます。人間が気づいて「ラッパーを使って」と会話に割り込むのと違い、hook の案内は作業の流れの中に届きます。AI はその場で読み替えて、自分の優先順位を崩さずに進み直す——人間の指示を挟まないことも、仕組みの効き目の一部です。この形はシリーズで何度も出てきます。
CC BY 4.0 はここまで
読まなくなったもの
テストの生出力です。人間も AI も読みません。ただし捨ててはいません。全文は毎回 tmp/test_last.log に残り、要約の 1 行目がその場所を言います。原因調査で本当に全文が要るときだけ、そこを開きます。
CC BY 4.0
注意: 失敗以外の異常が見えなくなる
この仕組みの死角は、失敗という形をとらない異常が隠れることです。スキップの急増、警告、実行件数の減少——テストが丸ごとロードされなくなっても、残りが全部通れば「0 failures」です。
だから件数の行だけは要約から削りません。上の実行例の 13950 runs, ... 228 skips がそれです。前回 13,950 だった runs が 9,000 に落ちていたら、失敗が 0 でも何かが起きています。読まずに数字だけで異常を拾う話は、第 9 回(引き継ぎを読まない技術)でまとめます。
検証手順: 失敗が届くことを一度だけ確かめる
前提
- この回のラッパー(
scripts/test.sh)を置き、素のテストコマンドを止める hook を登録し終えていること - テストが 1 件以上あり、手元で走ること
所要時間: 5 分
手順
- あなたが、テストを 1 件わざと壊します。短い時間で走るテストファイルを 1 つ開き、期待値を 1 文字だけ変えます(
assert_equal "abc", ...を"abd"に)。⚠️ 後始末で元に戻すので、ファイル名と行番号を控えておいてください - AI に「テストを走らせて」と頼みます。あなたが直接ラッパーを叩くのではなく、AI に頼んでください——確かめたいのは、AI が入口を通ったときに何が届くかだからです
合格条件(返ってきた要約が、すべて満たすこと)
- 全文ログの場所が出ている(
tmp/test_last.logのような 1 行) - 壊したテストの名前と、期待値・実際値の差が出ている(
Expected: "abc"とActual: "abd"に当たる行。手順 1 で控えたファイル名が、ここに出ます) - 件数の行の
failuresが 1 以上になっている(13950 runs, 1 failuresのような行)
合格しなかったとき
0 failuresと出た —— 壊したテストが、そもそも走っていません。手順 1 で選んだファイルが実行の対象に入っているかを見直してから、手順 2 をやり直します- 失敗はしたが、テスト名や差が出ない —— 要約が拾う行が足りません。ラッパーの
grepの範囲を広げます
後始末
- 壊した 1 文字を元に戻し、もう一度 AI に走らせます。件数の行が
0 failuresに戻れば終わりです - ⚠️ 戻し忘れると、この後の作業がずっと失敗し続けます
確かめたのは「失敗したら必ず届く」の側だけです。これを見ずに運用を始めると、要約が静かな理由が「失敗がない」のか「失敗が届かないつなぎ方」なのか、区別が付かないまま使うことになります。
この記事は「出力をどう届けるか」だけを扱いました。テストをいつ・何本走らせるか(再実行、並列実行、重い E2E の選択実行)は別の問題系なので、この連載では扱いません。
CC BY 4.0 はここまで
次回は「メモリを読まない技術」。AI が書き溜めた知見メモを、私は読み返していません。それでも、メモが増えて困ったことはありません。
連載「読まない技術」
- ← 前回: 序論 AIの出力を、もうほとんど読んでいない
- → 次回: 第 2 回 メモリを読まない技術
- 全回の一覧: 序論 AIの出力を、もうほとんど読んでいない