この記事は、連載「読まない技術」の第 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 つ開き、期待値を 1 文字だけ変えます(assert_equal "abc", ..."abd" に)。⚠️ 後始末で元に戻すので、ファイル名と行番号を控えておいてください
  2. AI に「テストを走らせて」と頼みます。あなたが直接ラッパーを叩くのではなく、AI に頼んでください——確かめたいのは、AI が入口を通ったときに何が届くかだからです

合格条件(返ってきた要約が、すべて満たすこと)

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

合格しなかったとき

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

後始末

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

確かめたのは「失敗したら必ず届く」の側だけです。これを見ずに運用を始めると、要約が静かな理由が「失敗がない」のか「失敗が届かないつなぎ方」なのか、区別が付かないまま使うことになります。


この記事は「出力をどう届けるか」だけを扱いました。テストをいつ・何本走らせるか(再実行、並列実行、重い E2E の選択実行)は別の問題系なので、この連載では扱いません。

CC BY 4.0 はここまで

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

連載「読まない技術」