Free

Hooks로 테스트 자동 실행: Claude가 코드 수정 후 즉시 오류를 파악

Write/Edit 도구에 PostToolUse Hook을 달아 파일 변경 시마다 테스트를 자동 실행하고, 실패 결과를 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을 받는다. 그 안에 수정된 파일 경로가 포함되어 있다. 이 경로를 기반으로 어떤 테스트를 실행할지 결정하는 것이 핵심이다.

종료 코드 규칙 (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의 모든 변경에 테스트 안전망이 깔린다.