Make Claude Code run the tests before it says done
On this page
  1. How a Stop hook works
  2. The script
  3. The settings
  4. What happened when we ran it
  5. Make it fit your project
  6. Or use /goal for one session
  7. Where Joxo fits
  8. Frequently asked questions
  9. Can I force Claude Code to run tests before finishing?
  10. Why does my Stop hook loop forever?
  11. Does the Stop hook run when I press Esc?
  12. Try it with your team

Blog / claude code

Make Claude Code run the tests before it says done

A Stop hook that runs your tests and sends failures back to Claude, so it can't finish on red. The script, the settings, and what we saw when we ran it.

"Done! All changes are complete." Then you run the tests and three fail. Coding agents report success because, from where they sit, the work looks finished. Asking nicely in CLAUDE.md ("always run the tests") helps until the session gets long. A Stop hook makes it a rule instead of a request: when Claude tries to finish, your script runs the tests, and if they fail, Claude gets the output and has to keep going.

Below are the script, the settings, what happened when we ran it, and the one guard you must not leave out.

How a Stop hook works#

Claude Code runs Stop hooks each time the main agent finishes responding. Your script gets a small JSON object on stdin and answers with its exit code:

Exit codeWhat Claude Code does
0Lets Claude stop as normal
2Blocks the stop and shows Claude what your script printed to stderr, so it carries on
anything elseLogs an error and lets Claude stop

The hook doesn't run when you interrupt Claude yourself, so Esc still works.

The script#

Save this as .claude/hooks/tests-must-pass.sh in your project and make it executable with chmod +x. Swap python3 -m unittest for your own test command (npm test, pytest -q, go test ./...). It needs jq.

bash
#!/bin/bash
# Stop hook: Claude can't finish while the tests fail.
input=$(cat)
# Already continuing because of this hook? Let it stop, so it never loops forever.
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
if ! out=$(python3 -m unittest 2>&1); then
  echo "Tests fail. Fix them before you say you're done:" >&2
  echo "$out" | tail -20 >&2
  exit 2
fi
exit 0

Two details matter:

  • stop_hook_active is true when Claude is already continuing because a Stop hook blocked it. Without that check, a test Claude can't fix would block it again and again. Claude Code caps this at eight blocks in a row, but you don't want to spend eight turns finding out.
  • tail -20 keeps the message short. Claude needs the failing test and the error, not 3,000 lines of output.

The settings#

Add the hook to .claude/settings.json in the project, so everyone who works in it gets the same rule:

json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-must-pass.sh" }
        ]
      }
    ]
  }
}

$CLAUDE_PROJECT_DIR points at the project root, so the hook works from any subfolder. Commit both files.

What happened when we ran it#

We set up a tiny project with one function that sums quantities when it should sum price times quantity, and one test that catches it. Then we gave Claude Code (version 2.1.312) small jobs that had nothing to do with the bug.

"Add a one-line docstring to total." Claude added the docstring and tried to finish. The hook ran the tests, they failed, and Claude got the output. It fixed the function to sum(i["price"] * i["qty"] for i in items), the tests passed, and it finished with a note: the fix went beyond the docstring it was asked for, and anything that relied on the old result would now get a price total. Exactly the report you want.

"Rename total to order_total in price.py only. Nothing else." Now the rule and the request conflict: the rename breaks the test's import, and fixing that means touching a file it was told not to touch. Claude did the rename, the hook blocked it, and instead of quietly editing the test it stopped (the stop_hook_active guard let it) and said plainly that the tests were failing, why, and which of two changes we wanted.

Both outcomes beat "Done!". The agent either leaves the tests green or tells you they are red and why.

Make it fit your project#

  • Fast tests only. The hook runs every time Claude finishes a turn, including when it only answers a question. Point it at a quick check (one package, the unit tests, a type check) and leave the slow suite for later.
  • Skip it when nothing changed. To avoid running tests after a plain answer, start the script with git diff --quiet && git diff --cached --quiet && exit 0: no changes in the work tree or the index, no test run. Leave it out if your agent commits as it goes, since committed work leaves no diff.
  • Different checks, same idea. Swap the test command for a linter, tsc --noEmit, or a build. Anything that exits non-zero on failure works.

Or use /goal for one session#

If you only want this for the job in front of you, Claude Code has /goal. You give it a condition, for example /goal all tests in tests/ pass and the build succeeds, and after every turn a separate model checks whether the condition holds; if not, Claude starts another turn. It clears itself once the condition is met. A Stop hook runs your real test command and applies to every session in the project; /goal is a quick per-session version judged by a model.

Where Joxo fits#

A check like this matters most when the agent works while you are away. With Joxo, when an agent stops to ask you something (like which of two fixes you want), the question reaches your phone and desktop, and you answer from there. Setup is one line pasted into your agent. For more on hooks that don't fire, see Claude Code hooks not working.

Frequently asked questions#

Can I force Claude Code to run tests before finishing?#

Yes. Add a Stop hook that runs your tests and exits with code 2 when they fail. Claude Code blocks the stop and gives Claude the output, so it keeps working until the tests pass or it explains why it can't.

Why does my Stop hook loop forever?#

It blocks again even when Claude is already continuing because of it. Read stop_hook_active from the hook's input and exit 0 when it is true. Claude Code also ends the turn after eight blocks in a row.

Does the Stop hook run when I press Esc?#

No. Stop hooks don't run when you interrupt Claude yourself, so you can always stop it.