13 KiB
13 KiB
Testing Hooks
Comprehensive testing strategies for Claude Code hooks.
Testing Philosophy
Hooks run with full system access. Test them thoroughly before deploying.
Testing Levels
- Unit testing - Test functions in isolation
- Integration testing - Test with mock Claude Code events
- Manual testing - Test in real Claude Code sessions
- Regression testing - Verify hooks don't break existing workflows
Unit Testing Hook Functions
Bash Functions
Example: Testing file validation
# hook-functions.sh - extractable functions
validate_file_path() {
local path="$1"
if [ -z "$path" ] || [ "$path" == "null" ]; then
return 1
fi
if [[ ! "$path" =~ ^/ ]]; then
return 1
fi
if [ ! -f "$path" ]; then
return 1
fi
return 0
}
# Test script
#!/bin/bash
source ./hook-functions.sh
test_validate_file_path() {
# Test valid path
touch /tmp/test-file.txt
if validate_file_path "/tmp/test-file.txt"; then
echo "✅ Valid path test passed"
else
echo "❌ Valid path test failed"
return 1
fi
# Test invalid path
if ! validate_file_path ""; then
echo "✅ Empty path test passed"
else
echo "❌ Empty path test failed"
return 1
fi
# Test null path
if ! validate_file_path "null"; then
echo "✅ Null path test passed"
else
echo "❌ Null path test failed"
return 1
fi
# Test relative path
if ! validate_file_path "relative/path.txt"; then
echo "✅ Relative path test passed"
else
echo "❌ Relative path test failed"
return 1
fi
rm /tmp/test-file.txt
return 0
}
# Run test
test_validate_file_path
JavaScript Functions
Example: Testing prompt analysis
// skill-activator.js
function analyzePrompt(text, rules) {
const lowerText = text.toLowerCase();
const activated = [];
for (const [skillName, config] of Object.entries(rules)) {
if (config.promptTriggers?.keywords) {
for (const keyword of config.promptTriggers.keywords) {
if (lowerText.includes(keyword.toLowerCase())) {
activated.push({ skill: skillName, priority: config.priority || 'medium' });
break;
}
}
}
}
return activated;
}
// test.js
const assert = require('assert');
const testRules = {
'backend-dev': {
priority: 'high',
promptTriggers: {
keywords: ['backend', 'API', 'endpoint']
}
}
};
// Test keyword matching
function testKeywordMatching() {
const result = analyzePrompt('How do I create a backend endpoint?', testRules);
assert.equal(result.length, 1, 'Should find one skill');
assert.equal(result[0].skill, 'backend-dev', 'Should match backend-dev');
assert.equal(result[0].priority, 'high', 'Should have high priority');
console.log('✅ Keyword matching test passed');
}
// Test no match
function testNoMatch() {
const result = analyzePrompt('How do I write Python?', testRules);
assert.equal(result.length, 0, 'Should find no skills');
console.log('✅ No match test passed');
}
// Test case insensitivity
function testCaseInsensitive() {
const result = analyzePrompt('BACKEND endpoint', testRules);
assert.equal(result.length, 1, 'Should match regardless of case');
console.log('✅ Case insensitive test passed');
}
// Run tests
testKeywordMatching();
testNoMatch();
testCaseInsensitive();
Integration Testing with Mock Events
Creating Mock Events
PostToolUse event:
{
"event": "PostToolUse",
"tool": {
"name": "Edit",
"input": {
"file_path": "/Users/test/project/src/file.ts",
"old_string": "const x = 1;",
"new_string": "const x = 2;"
}
},
"result": {
"success": true
}
}
UserPromptSubmit event:
{
"event": "UserPromptSubmit",
"text": "How do I create a new API endpoint?",
"timestamp": "2025-01-15T10:30:00Z"
}
Stop event:
{
"event": "Stop",
"sessionId": "abc123",
"messageCount": 10
}
Testing Hook with Mock Events
#!/bin/bash
# test-hook.sh
# Create mock event
create_mock_edit_event() {
cat <<EOF
{
"event": "PostToolUse",
"tool": {
"name": "Edit",
"input": {
"file_path": "/tmp/test-file.ts"
}
}
}
EOF
}
# Test hook
test_edit_tracker() {
# Setup
export LOG_FILE="/tmp/test-edit-log.txt"
rm -f "$LOG_FILE"
# Run hook with mock event
create_mock_edit_event | bash hooks/post-tool-use/01-track-edits.sh
# Verify
if [ -f "$LOG_FILE" ]; then
if grep -q "test-file.ts" "$LOG_FILE"; then
echo "✅ Edit tracker test passed"
return 0
fi
fi
echo "❌ Edit tracker test failed"
return 1
}
test_edit_tracker
Testing JavaScript Hooks
// test-skill-activator.js
const { execSync } = require('child_process');
function testSkillActivator(prompt) {
const mockEvent = JSON.stringify({
text: prompt
});
const result = execSync(
'node hooks/user-prompt-submit/skill-activator.js',
{
input: mockEvent,
encoding: 'utf8',
env: {
...process.env,
SKILL_RULES: './test-skill-rules.json'
}
}
);
return JSON.parse(result);
}
// Test activation
function testBackendActivation() {
const result = testSkillActivator('How do I create a backend endpoint?');
if (result.additionalContext && result.additionalContext.includes('backend')) {
console.log('✅ Backend activation test passed');
} else {
console.log('❌ Backend activation test failed');
process.exit(1);
}
}
testBackendActivation();
Manual Testing in Claude Code
Testing Checklist
Before deployment:
- Hook executes without errors
- Hook completes within timeout (default 10s)
- Output is helpful and not overwhelming
- Non-blocking hooks don't prevent work
- Blocking hooks have clear error messages
- Hook handles missing files gracefully
- Hook handles malformed input gracefully
Manual Test Procedure
1. Enable debug mode:
# Add to top of hook
set -x
exec 2>>~/.claude/hooks/debug-$(date +%Y%m%d).log
2. Test with minimal prompt:
Create a simple test file
3. Observe hook execution:
# Watch debug log
tail -f ~/.claude/hooks/debug-*.log
4. Verify output:
- Check that hook completes
- Verify no errors in debug log
- Confirm expected behavior
5. Test edge cases:
- Empty file paths
- Non-existent files
- Files outside project
- Malformed input
- Missing dependencies
6. Test performance:
# Time hook execution
time bash hooks/stop/build-checker.sh
Regression Testing
Creating Test Suite
#!/bin/bash
# regression-test.sh
TEST_DIR="/tmp/hook-tests"
mkdir -p "$TEST_DIR"
# Setup test environment
setup() {
export LOG_FILE="$TEST_DIR/edit-log.txt"
export PROJECT_ROOT="$TEST_DIR/projects"
mkdir -p "$PROJECT_ROOT"
}
# Cleanup after tests
teardown() {
rm -rf "$TEST_DIR"
}
# Test 1: Edit tracker logs edits
test_edit_tracker_logs() {
echo '{"tool": {"name": "Edit", "input": {"file_path": "/test/file.ts"}}}' | \
bash hooks/post-tool-use/01-track-edits.sh
if grep -q "file.ts" "$LOG_FILE"; then
echo "✅ Test 1 passed"
return 0
fi
echo "❌ Test 1 failed"
return 1
}
# Test 2: Build checker finds errors
test_build_checker_finds_errors() {
# Create mock project with errors
mkdir -p "$PROJECT_ROOT/test-project"
echo 'const x: string = 123;' > "$PROJECT_ROOT/test-project/error.ts"
# Add to log
echo "2025-01-15 10:00:00 | test-project | error.ts" > "$LOG_FILE"
# Run build checker (should find errors)
output=$(bash hooks/stop/20-build-checker.sh)
if echo "$output" | grep -q "error"; then
echo "✅ Test 2 passed"
return 0
fi
echo "❌ Test 2 failed"
return 1
}
# Test 3: Formatter handles missing prettier
test_formatter_missing_prettier() {
# Create file without prettier config
mkdir -p "$PROJECT_ROOT/no-prettier"
echo 'const x=1' > "$PROJECT_ROOT/no-prettier/file.js"
echo "2025-01-15 10:00:00 | no-prettier | file.js" > "$LOG_FILE"
# Should complete without error
if bash hooks/stop/30-format-code.sh 2>&1; then
echo "✅ Test 3 passed"
return 0
fi
echo "❌ Test 3 failed"
return 1
}
# Run all tests
run_all_tests() {
setup
local failed=0
test_edit_tracker_logs || ((failed++))
test_build_checker_finds_errors || ((failed++))
test_formatter_missing_prettier || ((failed++))
teardown
if [ $failed -eq 0 ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ All tests passed!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━"
return 0
else
echo "━━━━━━━━━━━━━━━━━━━━━━━━"
echo "❌ $failed test(s) failed"
echo "━━━━━━━━━━━━━━━━━━━━━━━━"
return 1
fi
}
run_all_tests
Running Regression Suite
# Run before deploying changes
bash test/regression-test.sh
# Run on schedule (cron)
0 0 * * * cd ~/hooks && bash test/regression-test.sh
Performance Testing
Measuring Hook Performance
#!/bin/bash
# benchmark-hook.sh
ITERATIONS=10
HOOK_PATH="hooks/stop/build-checker.sh"
total_time=0
for i in $(seq 1 $ITERATIONS); do
start=$(date +%s%N)
bash "$HOOK_PATH" > /dev/null 2>&1
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 )) # Convert to ms
total_time=$(( total_time + elapsed ))
echo "Iteration $i: ${elapsed}ms"
done
average=$(( total_time / ITERATIONS ))
echo "━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Average: ${average}ms"
echo "Total: ${total_time}ms"
echo "━━━━━━━━━━━━━━━━━━━━━━━━"
if [ $average -gt 2000 ]; then
echo "⚠️ Hook is slow (>2s)"
exit 1
fi
echo "✅ Performance acceptable"
Performance Targets
- Non-blocking hooks: <2 seconds
- Blocking hooks: <5 seconds
- UserPromptSubmit: <1 second (critical path)
- PostToolUse: <500ms (runs frequently)
Continuous Testing
Pre-commit Hook for Hook Testing
#!/bin/bash
# .git/hooks/pre-commit
echo "Testing Claude Code hooks..."
# Run test suite
if bash test/regression-test.sh; then
echo "✅ Hook tests passed"
exit 0
else
echo "❌ Hook tests failed"
echo "Fix tests before committing"
exit 1
fi
CI/CD Integration
# .github/workflows/test-hooks.yml
name: Test Claude Code Hooks
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '18'
- name: Install dependencies
run: npm install
- name: Run hook tests
run: bash test/regression-test.sh
- name: Run performance tests
run: bash test/benchmark-hook.sh
Common Testing Mistakes
Mistake 1: Not Testing Error Paths
❌ Wrong:
# Only test success path
npx tsc --noEmit
echo "✅ Build passed"
✅ Right:
# Test both success and failure
if npx tsc --noEmit 2>&1; then
echo "✅ Build passed"
else
echo "❌ Build failed"
# Test that error handling works
fi
Mistake 2: Hardcoding Paths
❌ Wrong:
# Hardcoded path
cd /Users/myname/projects/myproject
npm run build
✅ Right:
# Dynamic path
project_root=$(find_project_root "$file_path")
if [ -n "$project_root" ]; then
cd "$project_root"
npm run build
fi
Mistake 3: Not Cleaning Up
❌ Wrong:
# Leaves test files behind
echo "test" > /tmp/test-file.txt
run_test
# Never cleans up
✅ Right:
# Always cleanup
trap 'rm -f /tmp/test-file.txt' EXIT
echo "test" > /tmp/test-file.txt
run_test
Mistake 4: Silent Failures
❌ Wrong:
# Errors disappear
npx tsc --noEmit 2>/dev/null
✅ Right:
# Capture errors
output=$(npx tsc --noEmit 2>&1)
if [ $? -ne 0 ]; then
echo "❌ TypeScript errors:"
echo "$output"
fi
Debugging Failed Tests
Enable Verbose Output
# Add debug flags
set -x # Print commands
set -e # Exit on error
set -u # Error on undefined variables
set -o pipefail # Catch pipe failures
Capture Test Output
# Run test with full output
bash -x test/regression-test.sh 2>&1 | tee test-output.log
# Review output
less test-output.log
Isolate Failing Test
# Run single test
source test/regression-test.sh
setup
test_build_checker_finds_errors
teardown
Remember
- Test before deploying - Hooks have full system access
- Test all paths - Success, failure, edge cases
- Test performance - Hooks shouldn't slow workflow
- Automate testing - Run tests on every change
- Clean up - Don't leave test artifacts
- Document tests - Future you will thank present you
Golden rule: If you wouldn't run it on production, don't deploy it as a hook.