免费

用 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"

# 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 的工作节奏变成:

  1. 修改代码文件
  2. Hook 自动跑对应测试
  3. 如果测试失败 → Claude 看到报错,继续修改,直到测试通过
  4. 如果测试通过 → 静默,Claude 继续下一步

不需要你在中间反复说「测试一下」,Claude 的每次改动都有测试兜底。