Free

รันเทสอัตโนมัติด้วย Hooks: Claude รู้ทันทีว่าโค้ดที่แก้ไขมีข้อผิดพลาดหรือไม่

ใช้ PostToolUse Hooks บนเครื่องมือ Write/Edit เพื่อรันเทสหลังทุกครั้งที่แก้ไขไฟล์ — ผลที่ล้มเหลวจะถูกส่งกลับให้ Claude แก้ไขทันที


Claude Code แก้ไขโค้ดได้เร็ว — แต่ไม่รันเทสเอง โดยค่าเริ่มต้น พอเขียนไฟล์เสร็จก็ถือว่างานเสร็จ ถ้าไม่บอกชัดๆ มันไม่รู้ว่าการเปลี่ยนแปลงนั้นทำให้อะไรพังหรือเปล่า

Hook แบบ PostToolUse แก้ปัญหานี้ได้: ทุกครั้งที่ Claude เขียนไฟล์ เทสจะรันอัตโนมัติ ถ้าล้มเหลว ผลลัพธ์จะถูกส่งกลับให้ Claude โดยตรงเพื่อแก้ไขทันที


หลักการทำงาน

Claude Code แก้ไขไฟล์ผ่านสองเครื่องมือ: Write (สร้างหรือเขียนทับ) และ Edit (แก้ไขบางส่วน) การติด Hook ไว้ที่ phase PostToolUse ของเครื่องมือเหล่านี้จะทริกเกอร์การรันเทสทุกครั้งที่ไฟล์ถูกบันทึกลงดิสก์

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

Hook รับ JSON ครบถ้วนของการเรียกใช้เครื่องมือผ่าน stdin รวมถึง path ของไฟล์ที่แก้ไข การใช้ path นั้นเพื่อตัดสินใจว่าจะรันเทสอะไรคือหัวใจของแนวทางนี้

กฎของ exit code (PostToolUse):
- exit 0: เทสผ่าน — Claude ดำเนินต่อโดยไม่มีการขัดจังหวะ
- exit 2: เทสล้มเหลว — เนื้อหา stderr จะถูกส่งกลับให้ Claude ซึ่งจะเห็นข้อผิดพลาดและพยายามแก้ไข


เวอร์ชันพื้นฐาน: เลือกคำสั่งเทสตามประเภทไฟล์

แนวทางที่ง่ายที่สุด: ตรวจสอบนามสกุลไฟล์แล้วรัน test suite ที่ตรงกัน

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

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

# ไม่ทริกเกอร์เทสเมื่อ Claude แก้ไขไฟล์เทสเอง
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 ทั้งหมดทุกครั้งช้าเกินไป ใช้ path ของไฟล์ที่แก้ไขเพื่อหาเฉพาะเทสที่เกี่ยวข้อง

ในโปรเจกต์ 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 รองรับการกรองตาม 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 "เทสล้มเหลว:" >&2
  echo "$result" >&2
  exit 2
fi

การตั้งค่าแบบสมบูรณ์

ครอบคลุมทั้ง Write และ Edit พร้อม timeout guard เพื่อป้องกันเทสที่ค้างจนบล็อก 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 ใช้ชื่อ field ต่างกันใน tool_input

Write ใช้ file_path, Edit ใช้ path จัดการทั้งสองด้วย fallback:

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

ควบคุม timeout ของเทส

Test suite ที่ช้าร่วมกับการแก้ไขบ่อยๆ อาจทำให้ session หยุดตาย timeout 60 เป็นค่าเริ่มต้นที่สมเหตุสมผล ปรับตามระยะเวลาเทสจริงของโปรเจกต์

ระดับโปรเจกต์ ไม่ใช่ global

Hook นี้ควรอยู่ในการตั้งค่าระดับโปรเจกต์ (.claude/settings.json) เพราะคำสั่งเทสแตกต่างกันมากระหว่างโปรเจกต์ ถ้าต้องการเวอร์ชัน global สคริปต์ต้องตรวจจับประเภทโปรเจกต์แบบ dynamic


ผลลัพธ์

หลังตั้งค่านี้ จังหวะการทำงานของ Claude จะกลายเป็น:

  1. แก้ไขไฟล์ source
  2. Hook รันเทสที่เกี่ยวข้องอัตโนมัติ
  3. เทสล้มเหลว → Claude เห็น output ข้อผิดพลาดและแก้ไขต่อจนกว่าจะผ่าน
  4. เทสผ่าน → เงียบ Claude ดำเนินขั้นตอนต่อไป

ไม่ต้องพูดซ้ำๆ ว่า "รันเทสด้วย" ทุกการเปลี่ยนแปลงของ Claude มีตาข่ายนิรภัยจากเทส