Free

Автоматичний запуск тестів з хуками: Claude одразу дізнається, чи зламано код

Встановіть PostToolUse хуки на інструменти Write/Edit, щоб запускати тести після кожної зміни файлу — помилки повертаються Claude для негайного виправлення.


Claude Code редагує код швидко — але тести не запускає. За замовчуванням, щойно файл написано, завдання вважається виконаним. Без явної команди він не знає, чи зламало зміна щось.

Хук PostToolUse вирішує цю проблему: щоразу, коли Claude пише файл, тести запускаються автоматично. При невдачі результат одразу передається Claude, який негайно береться за виправлення.


Як це працює

Claude Code змінює файли за допомогою двох інструментів: Write (створити або перезаписати) та Edit (часткові зміни). Хук у фазі PostToolUse цих інструментів запускає тести при кожному збереженні файлу.

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

Хук отримує повний JSON виклику інструменту через stdin, включаючи шлях зміненого файлу. Використання цього шляху для вибору тестів — ключ до практичності підходу.

Правила коду виходу (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

Просунута версія: точковий запуск потрібного тесту

Запускати всю suite при кожній зміні — надто повільно. Використовуємо шлях зміненого файлу, щоб знайти лише потрібні тести.

У 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. Обробляємо обидва за допомогою fallback:

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

Контролюйте таймаут тестів

Повільні suite у поєднанні з частими правками можуть заморозити сесію. timeout 60 — розумне значення за замовчуванням; коригуйте під реальну тривалість тестів проєкту.

Рівень проєкту, не глобальний

Цей хук належить до конфігурації рівня проєкту (.claude/settings.json). Команди тестів надто сильно різняться між проєктами. Для глобальної версії скрипт повинен динамічно визначати тип проєкту.


Результат

Після налаштування робочий ритм Claude виглядає так:

  1. Редагує вихідний файл
  2. Хук автоматично запускає відповідні тести
  3. Тести провалились → Claude бачить вивід помилок і продовжує редагування, поки вони не пройдуть
  4. Тести пройшли → тиша, Claude переходить до наступного кроку

Більше не потрібно повторювати «запусти тести». Кожна зміна Claude має страхувальну сітку з тестів.