用 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"
# Jest 支持传文件名模式(不需要路径完全匹配)
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() {
# 限制超时 60 秒,避免测试挂死
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 的每次改动都有测试兜底。