用 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 的工作節奏變成:
不需要你在中間反覆說「測試一下」,Claude 的每次改動都有測試兜底。