Free

Automatyczne uruchamianie testów z hookami: Claude od razu wie, czy kod coś psuje

Użyj PostToolUse Hooks na narzędziach Write/Edit, by uruchamiać testy po każdej zmianie pliku — niepowodzenia trafiają z powrotem do Claude, który natychmiast je naprawia.


Claude Code edytuje kod szybko — ale sam nie uruchamia testów. Domyślnie, gdy tylko skończy pisać plik, uważa zadanie za zakończone. Bez wyraźnej instrukcji nie wie, czy zmiana coś zepsuła.

Hook PostToolUse rozwiązuje ten problem: za każdym razem, gdy Claude zapisuje plik, testy uruchamiają się automatycznie. Przy niepowodzeniu wynik trafia z powrotem do Claude, który natychmiast bierze się do naprawiania.


Jak to działa

Claude Code modyfikuje pliki za pomocą dwóch narzędzi: Write (tworzenie lub nadpisywanie) i Edit (częściowe zmiany). Podpięcie Hooka do fazy PostToolUse tych narzędzi uruchamia testy przy każdym zapisie pliku na dysk.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/run-tests.sh"
          }
        ]
      }
    ]
  }
}

Hook otrzymuje pełny JSON wywołania narzędzia przez stdin, w tym ścieżkę zmodyfikowanego pliku. Użycie tej ścieżki do wyboru testów jest kluczem do praktyczności podejścia.

Reguły kodu wyjścia (PostToolUse):
- exit 0: testy przeszły — Claude kontynuuje bez zakłóceń
- exit 2: testy nie powiodły się — zawartość stderr jest wstrzykiwana z powrotem do Claude, który widzi błąd i próbuje go naprawić


Wersja podstawowa: wybór polecenia testowego według typu pliku

Najprostsze podejście: sprawdzić rozszerzenie pliku i uruchomić odpowiedni zestaw testów.

#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')

[[ -z "$file" ]] && exit 0

# Nie uruchamiaj testów, gdy Claude edytuje sam plik testowy
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 "Testy nie powiodły się (zmodyfikowany plik: $file):" >&2
  echo "$result" >&2
  exit 2
fi

Wersja zaawansowana: precyzyjne targetowanie pliku testowego

Uruchamianie całego zestawu przy każdej zmianie jest zbyt wolne. Użyj ścieżki zmodyfikowanego pliku, żeby znaleźć tylko powiązane testy.

W projekcie Python src/auth/login.py zazwyczaj odpowiada 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 "Testy nie powiodły się:" >&2
  echo "$result" >&2
  exit 2
fi

Dla JavaScript/TypeScript, Jest obsługuje filtrowanie według wzorca nazwy pliku:

#!/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 "Testy nie powiodły się:" >&2
  echo "$result" >&2
  exit 2
fi

Pełna konfiguracja

Pokrycie zarówno Write, jak i Edit, z ochroną timeoutem, żeby zawieszone testy nie blokowały 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

# Pomiń same pliki testowe i pliki niebędące kodem
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 "⚠ Testy nie powiodły się (plik wyzwalający: $(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"
          }
        ]
      }
    ]
  }
}

Ważne uwagi

Nie uruchamiaj testów przy edycji plików testowych

Gdy Claude edytuje plik testowy, sam test jeszcze nie jest ukończony. Uruchomienie go w tym momencie jest przedwczesne. Ta linia filtrowania jest kluczowa:

echo "$file" | grep -qE '(test_|_test\.|\.test\.|\.spec\.)' && exit 0

Write i Edit używają różnych nazw pól w tool_input

Write używa file_path, Edit używa path. Obsłuż oba za pomocą fallbacku:

file=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')

Trzymaj timeouty pod kontrolą

Wolne zestawy testów w połączeniu z częstymi edycjami mogą zamrozić sesję. timeout 60 to rozsądna wartość domyślna; dostosuj do rzeczywistego czasu testów projektu.

Poziom projektu, nie globalny

Ten Hook należy do konfiguracji poziomu projektu (.claude/settings.json). Polecenia testów zbyt mocno różnią się między projektami. Dla wersji globalnej skrypt musi dynamicznie wykrywać typ projektu.


Efekt

Po tej konfiguracji rytm pracy Claude wygląda tak:

  1. Edytuje plik źródłowy
  2. Hook automatycznie uruchamia powiązane testy
  3. Testy nie powiodły się → Claude widzi dane wyjściowe błędów i kontynuuje edycję, aż przejdą
  4. Testy przeszły → cisza, Claude przechodzi do następnego kroku

Koniec z powtarzaniem "uruchom testy". Każda zmiana Claude ma sieć bezpieczeństwa z testów.