免費

用 Hooks 自動跑測試:Claude 改完程式碼立刻知道有沒有出錯

用 PostToolUse Hook 掛載 Write/Edit 工具,Claude 每次改完檔案自動觸發測試,失敗結果直接推回讓它修。


Claude Code 改程式碼很快,但它不會主動跑測試。預設情況下,它寫完檔案就算完成任務——除非你明確叫它測試,否則它不知道改動有沒有出錯。

PostToolUse Hook 可以解決這個問題:Claude 每次寫完檔案,自動觸發測試,失敗的話把結果直接推回給 Claude,讓它立刻修。


核心思路

Claude Code 修改檔案用的是 Write(新建或覆蓋)和 Edit(局部修改)兩個工具。在這兩個工具的 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。用 // empty 兼容兩者:

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 繼續下一步

不需要你在中間反覆說「測試一下」,Claude 的每次改動都有測試兜底。