Back to catalogue
ValidationStop
Run pytest at end of response
Won't hand back until pytest is green
When the agent finishes, runs the full pytest suite via uv. If tests fail, Claude re-enters and receives the failure output as context to fix the regressions.
What does the Run pytest at end of response hook do?
Run pytest at end of response is a Claude Code Stop hook. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Won't hand back until pytest is green.
As a Stop hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.
Use cases
- Regression safety net on every agent turn
- TDD loop: write code until tests go green
- Catch side-effects of refactors before control returns to the user
Tags
#validation#pytest#uv#python#tests#ci
settings.json fragment
{
"hooks": {
"Stop": [
{
"hooks": [
{
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/pytest.mjs",
"type": "command"
}
]
}
]
}
}Script · .claude/hooks/pytest.mjs
#!/usr/bin/env node
// @hookstack stop-pytest
import { spawnSync } from "node:child_process";
// @hookstack stop-pytest
// Exécute pytest à la fin d'une session Python (Stop)
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { changedFiles } from "./lib/changed-files.mjs";
const PYTHON_MARKERS = [
"pyproject.toml",
"setup.py",
"pytest.ini",
"setup.cfg",
];
const PY = /\.py$/;
const PY_CFG = /(^|\/)(pyproject\.toml|pytest\.ini|setup\.cfg|setup\.py)$/;
export function run({
exists = existsSync,
spawn = spawnSync,
cwd = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
changed = changedFiles({
cwd: process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
}),
} = {}) {
// CI (GitHub Action générée par le CLI) : lancer toute la suite, le scope
// « fichiers modifiés » n'a pas de sens sur un arbre git propre.
if (process.env.HOOKSTACK_FULL_CHECK === "1") changed = null;
const isPython = PYTHON_MARKERS.some((f) => exists(`${cwd}/${f}`));
if (!isPython) return null;
// Aucun .py (ni config pytest) modifié → inutile de relancer toute la suite.
if (changed && !changed.some((f) => PY.test(f) || PY_CFG.test(f)))
return null;
const hasXdist =
spawn("uv", ["run", "python", "-c", "import xdist"], {
encoding: "utf8",
timeout: 10_000,
cwd,
stdio: "ignore",
}).status === 0;
const pytestArgs = hasXdist
? ["run", "pytest", "-n", "auto", "--tb=short", "-q"]
: ["run", "pytest", "--tb=short", "-q"];
const result = spawn("uv", pytestArgs, {
encoding: "utf8",
timeout: 300_000,
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
const out = (result.stdout ?? "") + (result.stderr ?? "");
const status = result.status ?? 1;
const message =
status !== 0
? `[pytest] ÉCHEC (exit ${status})\n${out.slice(-2000)}\n`
: `[pytest] ✓ Tests passés\n${out.split("\n").filter(Boolean).slice(-5).join("\n")}\n`;
return { status, message };
}
/* v8 ignore next 6 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const result = run();
if (result) {
process.stderr.write(result.message);
if (result.status !== 0) process.exit(2);
}
}