Use PostToolUse Hooks on Write/Edit tools to automatically run tests after every file change — failures are fed back to Claude so it fixes them on the spot.
Claude Code edits fast — but it doesn't run tests on its own. By default, once it writes a file, it considers the task done. Unless you explicitly ask it to test, it has no idea whether the change broke anything.
A PostToolUse Hook fixes this: every time Claude writes a file, tests run automatically. If they fail, the output gets pushed straight back to Claude, which immediately tries to fix the problem.
Claude Code modifies files using two tools: Write (create or overwrite) and Edit (targeted changes). Attaching a Hook to the PostToolUse event for these tools triggers a test run every time a file lands on disk.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/run-tests.sh"
}
]
}
]
}
}
The Hook receives the full tool call JSON via stdin, including the path of the modified file. Using that path to decide which tests to run is the key to making this practical.
Exit code rules (PostToolUse):
- exit 0: tests passed — Claude continues silently
- exit 2: tests failed — stderr content is injected back to Claude, which sees the error and attempts a fix
The simplest approach: check the file extension, run the matching test suite.
#!/bin/bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')
[[ -z "$file" ]] && exit 0
# Don't trigger tests when Claude edits a test file itself
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 "Tests failed (modified file: $file):" >&2
echo "$result" >&2
exit 2
fi
Running the full suite on every change is slow. Use the modified file path to locate only the relevant tests.
For a Python project, src/auth/login.py typically maps to 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 "Tests failed:" >&2
echo "$result" >&2
exit 2
fi
For JavaScript/TypeScript, Jest supports matching by filename 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 "Tests failed:" >&2
echo "$result" >&2
exit 2
fi
Cover both Write and Edit, with a timeout guard to prevent hung tests from blocking 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
# Skip test files themselves and non-code files
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 "⚠ Tests failed (triggered by: $(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"
}
]
}
]
}
}
Don't trigger tests when editing test files
When Claude edits a test file, the test itself isn't finished yet — running tests at that moment is premature. This filter line is essential:
echo "$file" | grep -qE '(test_|_test\.|\.test\.|\.spec\.)' && exit 0
Write and Edit use different field names in tool_input
Write uses file_path, Edit uses path. Handle both with a fallback:
file=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')
Keep test timeouts tight
Slow test suites combined with frequent edits will freeze the session. timeout 60 is a reasonable default — adjust based on your project's actual test duration.
Project-level, not global
This Hook belongs in project-level config (.claude/settings.json). Test commands vary too much between projects. If you want a global version, the script needs to detect the project type dynamically.
With this in place, Claude's workflow becomes:
No more manually saying "run the tests." Every change Claude makes has a test safety net.