Claude-skill-registry hooks-test
Test Claude Code hooks in isolation and via integration. Use when developing, debugging, or validating hook behavior.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/hooks-test" ~/.claude/skills/majiayu000-claude-skill-registry-hooks-test && rm -rf "$T"
skills/data/hooks-test/SKILL.mdHooks Test
Test hooks at three levels: unit tests (Python), direct invocation, and headless Claude.
Reference: See reference.md for complete payload schemas.
When to Use
- Developing new hooks
- Debugging hook failures
- Validating hook behavior before deployment
- Regression testing after hook changes
Level 1: Unit Tests (Python)
Test hook logic in isolation using the test harness with pytest.
Test Harness
Use templates/test-harness.py:
from test_harness import create_payload, run_hook, assert_blocked, assert_allowed def test_blocks_dangerous_commands(): payload = create_payload("PreToolUse", tool_name="Bash", tool_input={"command": "rm -rf /"}) result = run_hook("./my-hook.py", payload) assert_blocked(result, "dangerous") def test_allows_safe_commands(): payload = create_payload("PreToolUse", tool_name="Bash", tool_input={"command": "echo hello"}) result = run_hook("./my-hook.py", payload) assert_allowed(result) def test_modifies_input(): payload = create_payload("PreToolUse", tool_name="Write", tool_input={"file_path": "/tmp/test.txt"}) result = run_hook("./my-hook.py", payload) assert_modified_input(result, "file_path", "/safe/path/test.txt")
Run with pytest:
pytest test_my_hook.py -v
Assertions
| Function | Checks |
|---|---|
| Exit code 2, stderr contains msg |
| Exit code 0, no block decision |
| Exit 0, updatedInput has field |
| Exit 0, context includes text |
Level 2: Direct Invocation (Shell)
Test hooks by calling them directly with JSON payloads.
Basic Pattern
echo '{"hook_event_name": "PreToolUse", "tool_name": "Bash", ...}' | ./hook.py echo "Exit: $?"
With Payload File
cat > /tmp/payload.json << 'EOF' { "session_id": "test-123", "transcript_path": "/tmp/test.jsonl", "cwd": "/path/to/project", "permission_mode": "default", "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "rm -rf /"}, "tool_use_id": "toolu_01ABC" } EOF cat /tmp/payload.json | ./my-hook.py
Exit Code Reference
| Exit Code | Meaning | Check |
|---|---|---|
| 0 | Success/allow | stdout contains valid JSON or context |
| 2 | Block action | stderr contains reason for Claude |
| Other | Non-blocking error | stderr contains user message |
Level 3: Headless Claude (End-to-End)
Test hooks end-to-end by invoking Claude in headless mode. Claude executes normally, triggers hooks through its behavior, and you verify the outcome.
Headless Claude Flags
claude -p "prompt here" --debug
| Flag | Purpose |
|---|---|
/ | Headless mode - prints response and exits |
| Shows hook execution details in stderr |
| Limit which tools Claude can use |
| Control permission behavior |
Test Patterns
Test PreToolUse blocks dangerous commands:
# Prompt that would trigger dangerous Bash command output=$(claude -p "delete everything in /tmp" --debug 2>&1) # Check hook blocked it (look for your hook's block message) if echo "$output" | grep -q "blocked"; then echo "PASS: Hook blocked dangerous command" fi
Test PostToolUse reacts to failures:
# Prompt that triggers a command expected to fail output=$(claude -p "run: exit 1" --debug 2>&1) # Check hook's reaction appears in output echo "$output" | grep -q "Hook command completed"
Test UserPromptSubmit adds context:
# Any prompt triggers UserPromptSubmit output=$(claude -p "hello" --debug 2>&1) # Verify hook ran echo "$output" | grep "UserPromptSubmit"
Test Stop hook continues execution:
# Prompt that completes, triggering Stop hook output=$(claude -p "what is 2+2" --debug 2>&1) # Check if hook caused continuation echo "$output" | grep "Stop"
Debug Output Format
[DEBUG] Executing hooks for PreToolUse:Bash [DEBUG] Found 1 hook matchers in settings [DEBUG] Matched 1 hooks for query "Bash" [DEBUG] Executing hook command: ./my-hook.py with timeout 60000ms [DEBUG] Hook command completed with status 0: <stdout content>
Automated Test Script
#!/bin/bash # integration-test.sh - Run from project root with hook configured set -e echo "Test 1: PreToolUse blocks rm -rf" if claude -p "run: rm -rf /" --debug 2>&1 | grep -qi "block"; then echo " PASS" else echo " FAIL" exit 1 fi echo "Test 2: Safe commands allowed" if claude -p "run: echo hello" --debug 2>&1 | grep -q "hello"; then echo " PASS" else echo " FAIL" exit 1 fi echo "All tests passed"
Isolate Test Configuration
Use
.claude/settings.local.json for test hooks (not committed):
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [".claude/hooks/test-blocker.py"] } ] } }
Payload Examples by Event
PreToolUse
payload = create_payload("PreToolUse", tool_name="Write", tool_input={"file_path": "/etc/passwd", "content": "..."})
Test: block dangerous paths, allow safe ops, modify input via
updatedInput
PostToolUse
payload = create_payload("PostToolUse", tool_name="Bash", tool_input={"command": "npm test"}, tool_response={"stdout": "FAILED", "exit_code": 1})
Test: react to failures, add context, log operations
UserPromptSubmit
payload = create_payload("UserPromptSubmit", prompt="delete all files")
Test: block prohibited prompts, add context, transform input
Stop/SubagentStop
payload = create_payload("Stop", stop_hook_active=False)
Test: continue on conditions, verify TDD evidence, prevent loops when
stop_hook_active=True
Debugging Tips
- Hook not running? Check
menu, verify matcher syntax/hooks - JSON parse error? Validate hook outputs valid JSON on exit 0
- Timeout? Default is 60s, increase with
"timeout": 120000 - Wrong exit code? Use
to block,sys.exit(2)
to allowsys.exit(0) - Stderr not showing? Only displayed in verbose mode (
)ctrl+o
Success Criteria
- Level 1: Unit tests pass with pytest
- Level 2: Direct invocation returns expected exit codes
- Level 3: Headless Claude triggers hook and behaves correctly
- Exit codes match behavior (0=allow, 2=block)
- Blocking responses include reason in stderr
- JSON output is valid and complete
Integration
Related:
- Docs:
for CLI flagsclaude --help - Command:
to manage hooks/hooks - Config:
,.claude/settings.json.claude/settings.local.json