Free

Tests automatisch ausführen mit Hooks: Claude weiß sofort, ob sein Code etwas kaputt macht

PostToolUse Hooks auf Write/Edit-Tools setzen, um nach jeder Dateiänderung Tests auszulösen — Fehler werden direkt an Claude zurückgegeben, damit es sie sofort behebt.


Claude Code bearbeitet Code schnell — aber führt keine Tests aus. Standardmäßig gilt die Aufgabe als erledigt, sobald eine Datei geschrieben ist. Ohne explizite Anweisung weiß Claude nicht, ob die Änderung etwas kaputt gemacht hat.

Ein PostToolUse Hook löst das: Jedes Mal, wenn Claude eine Datei schreibt, laufen die Tests automatisch. Bei Fehlern wird das Ergebnis direkt an Claude zurückgegeben, das sofort mit der Korrektur beginnt.


Wie es funktioniert

Claude Code bearbeitet Dateien mit zwei Tools: Write (Erstellen oder Überschreiben) und Edit (Teiländerungen). Ein Hook in der PostToolUse-Phase dieser Tools startet bei jeder Dateiänderung automatisch einen Testlauf.

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

Der Hook empfängt den vollständigen JSON-Body des Tool-Aufrufs über stdin, inklusive des Pfads der geänderten Datei. Anhand dieses Pfads zu entscheiden, welche Tests laufen sollen, ist der Kern dieses Ansatzes.

Exit-Code-Regeln (PostToolUse):
- exit 0: Tests bestanden — Claude macht ohne Unterbrechung weiter
- exit 2: Tests fehlgeschlagen — der stderr-Inhalt wird an Claude zurückgegeben, das den Fehler sieht und versucht, ihn zu beheben


Basisversion: Testbefehl nach Dateityp auswählen

Der einfachste Ansatz: Dateiendung prüfen und das passende Test-Framework starten.

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

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

# Keine Tests auslösen, wenn Claude eine Testdatei bearbeitet
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 "Tests fehlgeschlagen (geänderte Datei: $file):" >&2
  echo "$result" >&2
  exit 2
fi

Erweiterte Version: Gezielt die richtige Testdatei ansteuern

Die gesamte Test-Suite bei jeder Änderung zu starten ist zu langsam. Nutze den Pfad der geänderten Datei, um nur die relevanten Tests zu finden.

In einem Python-Projekt entspricht src/auth/login.py typischerweise 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 "Tests fehlgeschlagen:" >&2
  echo "$result" >&2
  exit 2
fi

Für JavaScript/TypeScript unterstützt Jest das Filtern per Dateinamen-Pattern:

#!/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 "Tests fehlgeschlagen:" >&2
  echo "$result" >&2
  exit 2
fi

Vollständige Konfiguration

Write und Edit abdecken, mit Timeout-Schutz damit hängende Tests Claude nicht blockieren:

.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

# Testdateien selbst und Nicht-Code-Dateien überspringen
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 "⚠ Tests fehlgeschlagen (ausgelöst durch: $(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"
          }
        ]
      }
    ]
  }
}

Wichtige Hinweise

Keine Tests auslösen beim Bearbeiten von Testdateien

Wenn Claude eine Testdatei bearbeitet, ist der Test noch nicht fertig. Ihn jetzt zu starten ist verfrüht. Diese Filterzeile ist entscheidend:

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

Write und Edit nutzen unterschiedliche Feldnamen in tool_input

Write nutzt file_path, Edit nutzt path. Beide mit einem Fallback abdecken:

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

Timeouts im Griff behalten

Langsame Test-Suites kombiniert mit häufigen Änderungen können die Session einfrieren. timeout 60 ist ein sinnvoller Standard — an die tatsächliche Testdauer des Projekts anpassen.

Projektebene, nicht global

Dieser Hook gehört in die Projektkonfiguration (.claude/settings.json). Testbefehle variieren zu stark zwischen Projekten. Für eine globale Version muss das Skript den Projekttyp dynamisch erkennen.


Das Ergebnis

Mit dieser Konfiguration sieht Claudes Arbeitsrhythmus so aus:

  1. Quelldatei bearbeiten
  2. Hook führt automatisch die relevanten Tests aus
  3. Tests fehlgeschlagen → Claude sieht die Fehlerausgabe und bearbeitet weiter, bis sie bestehen
  4. Tests bestanden → Stille, Claude macht mit dem nächsten Schritt weiter

Kein wiederholtes „Führ mal die Tests aus". Jede Änderung von Claude hat ein Test-Sicherheitsnetz.