Free

Auto-Run Tests with Hooks: Claude Knows Immediately If Its Code Changes Break Anything

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.


How It Works

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


Basic Version: Pick Test Command by File Type

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

Advanced Version: Target the Specific Test File

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

Full Configuration

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"
          }
        ]
      }
    ]
  }
}

A Few Things to Keep in Mind

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.


The Result

With this in place, Claude's workflow becomes:

  1. Edits a source file
  2. Hook automatically runs the relevant tests
  3. Tests fail → Claude sees the error output and keeps editing until they pass
  4. Tests pass → silent, Claude moves on

No more manually saying "run the tests." Every change Claude makes has a test safety net.