Dùng PostToolUse Hooks trên công cụ Write/Edit để chạy test sau mỗi lần thay đổi file — kết quả thất bại được đẩy ngay về Claude để sửa.
Claude Code sửa code nhanh — nhưng không tự chạy test. Mặc định, sau khi viết xong file là coi như xong việc. Nếu không nói rõ, nó không biết thay đổi vừa rồi có phá vỡ gì không.
Hook PostToolUse giải quyết điều này: mỗi lần Claude viết file, test tự động chạy. Nếu thất bại, kết quả được đẩy thẳng về cho Claude để sửa ngay lập tức.
Claude Code sửa đổi file bằng hai công cụ: Write (tạo mới hoặc ghi đè) và Edit (chỉnh sửa một phần). Gắn Hook vào phase PostToolUse của hai công cụ này sẽ kích hoạt chạy test mỗi khi file được lưu xuống đĩa.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/run-tests.sh"
}
]
}
]
}
}
Hook nhận toàn bộ JSON của lần gọi công cụ qua stdin, bao gồm đường dẫn file vừa sửa. Dùng đường dẫn đó để quyết định chạy test nào là mấu chốt.
Quy tắc exit code (PostToolUse):
- exit 0: test qua — Claude tiếp tục bình thường
- exit 2: test thất bại — nội dung stderr được đưa lại cho Claude, nó thấy lỗi và thử sửa
Đơn giản nhất: kiểm tra extension file rồi chạy test suite tương ứng.
#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')
[[ -z "$file" ]] && exit 0
# Không kích hoạt test khi Claude sửa chính file test
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 "Test thất bại (file đã sửa: $file):" >&2
echo "$result" >&2
exit 2
fi
Chạy toàn bộ suite mỗi lần quá chậm. Dùng đường dẫn file vừa sửa để tìm đúng test liên quan.
Với project Python, src/auth/login.py thường tương ứng với 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 "Test thất bại:" >&2
echo "$result" >&2
exit 2
fi
Với JavaScript/TypeScript, Jest hỗ trợ lọc theo pattern tên file:
#!/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 "Test thất bại:" >&2
echo "$result" >&2
exit 2
fi
Bao phủ cả Write lẫn Edit, thêm timeout để tránh test bị treo làm kẹt 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
# Bỏ qua chính file test và file không phải code
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 "⚠ Test thất bại (file: $(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"
}
]
}
]
}
}
Không kích hoạt test khi sửa file test
Khi Claude đang sửa file test, bản thân test đó chưa hoàn chỉnh. Chạy nó lúc này là quá sớm. Dòng lọc này rất quan trọng:
echo "$file" | grep -qE '(test_|_test\.|\.test\.|\.spec\.)' && exit 0
Write và Edit dùng tên field khác nhau trong tool_input
Write dùng file_path, Edit dùng path. Xử lý cả hai bằng fallback:
file=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')
Kiểm soát timeout test
Test suite chậm kết hợp với việc Claude sửa liên tục có thể làm đóng băng phiên làm việc. timeout 60 là mặc định hợp lý; điều chỉnh theo thời gian test thực tế của project.
Cấp project, không phải global
Hook này nên đặt trong cấu hình cấp project (.claude/settings.json). Lệnh test quá khác nhau giữa các project. Nếu muốn dùng bản global, script cần tự phát hiện loại project.
Với cấu hình này, nhịp làm việc của Claude trở thành:
Không cần nhắc đi nhắc lại "chạy test đi". Mọi thay đổi của Claude đều có lưới an toàn từ test.