Guide / Claude Code

Run tests automatically with Claude Code hooks

Short answer

To make Claude Code run your tests automatically, add a Stop hook to .claude/settings.json that runs the test suite and exits with code 2 when it fails. Claude Code then refuses to end the turn and hands the failure output back to Claude. A PreToolUse hook on the Edit and Write tools can also block Claude from rewriting existing tests.

Why use a hook instead of asking Claude to run tests

You can tell Claude to run the tests in CLAUDE.md, and it usually will. But “usually” is the problem: the agent decides whether verification happens, and the agent is the one that benefits from skipping it. Hooks move that decision into the harness. They run as ordinary shell commands at fixed points in Claude Code’s lifecycle, whether or not the model remembers to.

Two hook events matter for testing:

Claude Code hook events used in this guide
EventWhen it runsExit code 2 means
StopWhen Claude is about to finish its responseClaude is not allowed to stop; stderr is sent to Claude as the reason
PreToolUseBefore a tool call such as Edit or Write executesThe tool call is blocked; stderr is sent to Claude as the denial reason

Any other non-zero exit code is treated as a hook error rather than a block, so the scripts below use exactly 2. The full reference is in Anthropic’s hooks documentation.

Step 1: register the hooks

Add this to .claude/settings.json in your repository so it applies to everyone who uses Claude Code on the project. Merge it with any hooks you already have.

.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests.mjs\"",
            "timeout": 300
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-tests.mjs\""
          }
        ]
      }
    ]
  }
}

Stop does not take a matcher; it fires every time. The PreToolUse matcher Edit|Write limits the second hook to file edits. timeout is in seconds.

Step 2: run the tests before Claude can finish

Save this as .claude/hooks/run-tests.mjs. Replace npm test --silent with whatever runs your suite.

.claude/hooks/run-tests.mjs
// Claude Code Stop hook: refuse to end the turn while tests fail.
import { spawnSync } from "node:child_process";

let input = "";
for await (const chunk of process.stdin) input += chunk;
const { stop_hook_active } = JSON.parse(input || "{}");

const result = spawnSync("npm test --silent", { encoding: "utf8", shell: true });
if (result.status === 0) process.exit(0);

// Claude already had one retry because of this hook. Let the turn end
// instead of looping, and leave the failure for a human.
if (stop_hook_active) process.exit(0);

const tail = `${result.stdout}\n${result.stderr}`.trim().split("\n").slice(-40).join("\n");
console.error(
  "Tests are failing. Fix the implementation. Do not edit, skip, or delete tests.\n\n" + tail,
);
process.exit(2);

The stop_hook_active check is the loop guard. Claude Code sets it to true when Claude is already continuing because a Stop hook blocked it. This version gives Claude one retry and then lets the turn end. Remove the check if you would rather rely on Claude Code’s built-in cap of 8 consecutive continuations.

Step 3: stop Claude from rewriting tests

A Stop hook alone has a loophole: the fastest way to make failing tests pass is to change the tests. Save this as .claude/hooks/protect-tests.mjs:

.claude/hooks/protect-tests.mjs
// Claude Code PreToolUse hook: block edits to existing test files.
import { existsSync } from "node:fs";

let input = "";
for await (const chunk of process.stdin) input += chunk;
const file = String(JSON.parse(input).tool_input?.file_path ?? "").replaceAll("\\", "/");

const isTest =
  /\.(test|spec)\.[cm]?[jt]sx?$/.test(file) || /\/(__tests__|tests?)\//.test(file);

// New test files are fine. Rewriting existing ones is not.
if (!isTest || !existsSync(file)) process.exit(0);

console.error(
  `Blocked: ${file} is an existing test. Change the implementation so it passes. ` +
    "If the test itself is wrong, stop and explain why to the user.",
);
process.exit(2);

Claude can still add new test files, but any edit to an existing one is refused with an explanation. For why this matters, see why AI agents change tests to make them pass.

Limits of this setup

  • Running the full suite every turn is slow. On a large project, run only the tests related to the changed files, or the check becomes the thing everyone disables.
  • Passing tests are only as good as the tests. If the existing tests do not check a behavior, the hook cannot protect it. Mutation testing finds those gaps.
  • The hook protects test files, not intent. Claude can still change a business rule in production code if no test covers it. Only a record of approved behavior outside the code catches that.
  • Hooks run on the developer’s machine. Keep the same checks in CI so a change made without Claude Code, or with hooks disabled, is still verified.

The MaruCheck version: verify against approved behavior

MaruCheck ships a ready-made Stop hook that addresses those limits. Instead of running the whole suite, it runs maru verify --diff, which selects only the tests linked to requirements the change touches, checks the diff against your reviewed Quality Contracts for behavior drift, and blocks the turn with the exact failing requirement:

Terminal
npm install --save-dev --save-exact marucheck@0.5.0
npx --no-install maru init
npx --no-install maru hook install

maru hook install merges one entry into .claude/settings.json and leaves your other hooks alone. When the gate blocks, Claude is told explicitly that editing or re-approving the contract is not an allowed fix. The agent gate documentation shows the full output.

Frequently asked questions

How do I make Claude Code run tests before it finishes?
Add a Stop hook to .claude/settings.json whose command runs your test suite. If the tests fail, the hook exits with code 2 and writes the failure to stderr. Claude Code then refuses to end the turn and passes that output back to Claude, which keeps working until the tests pass.
What is a Claude Code Stop hook?
A Stop hook runs when Claude Code is about to finish responding. Exiting with code 2 blocks the stop and feeds stderr back to the model as the reason. Claude Code also passes a stop_hook_active flag on stdin so a hook can avoid blocking the same turn forever.
Can a Claude Code hook stop Claude from editing test files?
Use a PreToolUse hook that matches the Edit and Write tools, reads tool_input.file_path from the JSON on stdin, and exits with code 2 when the path is an existing test file. Claude Code blocks that tool call and shows Claude the reason, so it has to fix the implementation instead.

Last updated