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 есть страховочная сеть из тестов.