Free

Hooks でテストを自動実行:Claude がコードを変更したら即座に結果がわかる

Write/Edit ツールに PostToolUse Hook を設定し、ファイル変更のたびにテストを自動実行。失敗結果はそのまま Claude に返され、その場で修正される。


Claude Code はコード編集が速い。でも、テストは自分では実行しない。デフォルトでは、ファイルを書き終えた時点でタスク完了と判断する——明示的に指示しない限り、変更が何かを壊したかどうかわからない。

PostToolUse Hook でこれを解決できる。Claude がファイルを書くたびにテストが自動で走り、失敗したらその結果が Claude に即座にフィードバックされ、その場で修正に取り組む。


仕組み

Claude Code がファイルを変更するとき、使うツールは Write(作成・上書き)と Edit(部分変更)の2つだ。これらに PostToolUse Hook を設定することで、ファイルが書き込まれるたびにテストを自動実行できる。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/run-tests.sh"
          }
        ]
      }
    ]
  }
}

Hook は stdin を通じてツール呼び出しの完全な JSON を受け取る。その中に変更されたファイルのパスが含まれている。このパスを使ってどのテストを実行するか判断するのが、このタイプの Hook のポイントだ。

終了コードのルール(PostToolUse):
- exit 0:テスト通過——Claude はそのまま続ける
- exit 2:テスト失敗——stderr の内容が Claude に返され、エラーを見て修正を試みる


基本版:ファイルタイプでテストコマンドを選ぶ

最もシンプルなアプローチ:拡張子を見て対応するテストを実行する。

#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')

[[ -z "$file" ]] && exit 0

# テストファイル自体の変更ではテストを起動しない
echo "$file" | grep -qE '(test|spec)\.' && exit 0

case "$file" in
  *.py)
    cd "$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
    result=$(python -m pytest --tb=short -q 2>&1)
    ;;
  *.ts|*.js|*.tsx|*.jsx)
    cd "$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
    result=$(npm test -- --passWithNoTests 2>&1)
    ;;
  *.go)
    dir=$(dirname "$file")
    result=$(go test "./$dir/..." 2>&1)
    ;;
  *.rb)
    cd "$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
    result=$(bundle exec rspec --format progress 2>&1)
    ;;
  *)
    exit 0
    ;;
esac

exit_code=$?

if [[ $exit_code -ne 0 ]]; then
  echo "テスト失敗(変更ファイル:$file):" >&2
  echo "$result" >&2
  exit 2
fi

発展版:対象のテストファイルを特定する

毎回フルテストを実行するのは遅い。変更されたファイルのパスから関連するテストだけを特定して実行しよう。

Python プロジェクトなら、src/auth/login.py に対応するテストは通常 tests/auth/test_login.py になる:

#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')

[[ -z "$file" || "$file" != *.py ]] && exit 0
echo "$file" | grep -qE '(test_|_test\.py)' && exit 0

root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
cd "$root"

basename=$(basename "$file" .py)
test_file=$(find tests -name "test_${basename}.py" 2>/dev/null | head -1)

if [[ -n "$test_file" ]]; then
  result=$(python -m pytest "$test_file" --tb=short -q 2>&1)
else
  result=$(python -m pytest --tb=short -q 2>&1)
fi

if [[ $? -ne 0 ]]; then
  echo "テスト失敗:" >&2
  echo "$result" >&2
  exit 2
fi

JavaScript/TypeScript では、Jest のファイル名パターンマッチが使える:

#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')

[[ -z "$file" ]] && exit 0
echo "$file" | grep -qE '\.(test|spec)\.' && exit 0
echo "$file" | grep -qE '\.(ts|tsx|js|jsx)$' || exit 0

root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
cd "$root"

basename=$(basename "$file" | sed 's/\.[^.]*$//')
result=$(npx jest --testPathPattern="$basename" --passWithNoTests 2>&1)

if [[ $? -ne 0 ]]; then
  echo "テスト失敗:" >&2
  echo "$result" >&2
  exit 2
fi

完全な設定

Write と Edit の両方をカバーし、テストがハングしても Claude がブロックされないようタイムアウトを追加する:

.claude/hooks/run-tests.sh

#!/bin/bash
set -euo pipefail

input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')

[[ -z "$file" ]] && exit 0

# テストファイル自体と非コードファイルをスキップ
echo "$file" | grep -qE '(test_|_test\.|\.test\.|\.spec\.)' && exit 0
echo "$file" | grep -qE '\.(py|ts|js|tsx|jsx|go|rb)$' || exit 0

root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
cd "$root"

run_test() {
  timeout 60 "$@" 2>&1
}

case "$file" in
  *.py)
    result=$(run_test python -m pytest --tb=short -q)
    ;;
  *.ts|*.tsx|*.js|*.jsx)
    basename=$(basename "$file" | sed 's/\.[^.]*$//')
    result=$(run_test npx jest --testPathPattern="$basename" --passWithNoTests)
    ;;
  *.go)
    dir=$(dirname "$file")
    result=$(run_test go test "./$dir/...")
    ;;
  *.rb)
    result=$(run_test bundle exec rspec --format progress)
    ;;
  *)
    exit 0
    ;;
esac

if [[ $? -ne 0 ]]; then
  echo "⚠ テスト失敗(トリガーファイル:$(basename "$file"))" >&2
  echo "" >&2
  echo "$result" >&2
  exit 2
fi

.claude/settings.json

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/run-tests.sh"
          }
        ]
      }
    ]
  }
}

注意点

テストファイルの編集時はテストを走らせない

Claude がテストファイルを編集中、テスト自体はまだ完成していない。このタイミングでテストを実行するのは早計だ。このフィルタ行が重要:

echo "$file" | grep -qE '(test_|_test\.|\.test\.|\.spec\.)' && exit 0

Write と Edit では tool_input のフィールド名が異なる

Write は file_path、Edit は path を使う。フォールバックで両方に対応する:

file=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')

タイムアウトを設定する

遅いテストスイートと Claude の頻繁な編集が重なるとセッション全体が止まる。timeout 60 が現実的なデフォルト値だが、プロジェクトの実際のテスト時間に合わせて調整しよう。

プロジェクト単位で設定する

このタイプの Hook はプロジェクト固有の設定(.claude/settings.json)として配置するのが適切だ。テストコマンドはプロジェクトによって大きく異なる。グローバル版を使うなら、スクリプト内でプロジェクトタイプを動的に判定する必要がある。


効果

この設定を入れると、Claude の作業リズムがこうなる:

  1. ソースファイルを編集
  2. Hook が対応するテストを自動実行
  3. テスト失敗 → Claude がエラー出力を確認し、通過するまで修正を続ける
  4. テスト通過 → 静かに次のステップへ

「テストしてみて」と言い続ける必要がなくなる。Claude のすべての変更にテストの安全網が張られる。