Встановіть 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 виглядає так:
Більше не потрібно повторювати «запусти тести». Кожна зміна Claude має страхувальну сітку з тестів.