Skip to main content

Cursor: run tests automatically after every agent file edit

The working .cursor/hooks.json recipe to run tests after each Cursor agent edit. Which event to use, the runner dispatch, and why batching at the turn boundary beats firing per edit.

Pramod W. · July 1, 2026

If you came to Cursor from Claude Code, you are probably searching for a PostToolUse hook that runs your tests after every edit. Cursor does have a postToolUse event, but it is not the one you want for a test-on-edit recipe. The right event is afterFileEdit, and the difference matters enough that picking the wrong one is the most common way this recipe breaks.

I am Pramod. I own tailtest’s Cursor plugin. This is the recipe I wish had existed when I wired the first version: the exact .cursor/hooks.json, the runner dispatch, and the two design decisions that separate a hook you keep from one you disable within a day. If you want the semantics of the event itself (why Tab and Cmd-S never fire it), that is a separate deep-dive: the Cursor afterFileEdit hook. This post is the practical build.

postToolUse vs afterFileEdit: pick the file-write event

Cursor’s hook surface exposes a long list of lifecycle events: sessionStart, beforeShellExecution, beforeReadFile, postToolUse, afterFileEdit, stop, and more (Cursor Hooks reference). Two of them look like candidates for a test-after-edit recipe.

postToolUse fires after every agent tool call: shell commands, MCP calls, file reads, file writes. If you dispatch a test runner from postToolUse, you run tests after the agent lists a directory, after it greps, after it reads a file. That is a lot of wasted runner dispatches, and it is why people who reach for the Claude Code muscle memory find the hook noisy.

afterFileEdit fires only after the agent writes a file. That is the event you want. It carries the path that changed, so your dispatch has exactly the information it needs and nothing it does not.

The mental map, if you are translating from another agent:

The event payload

When afterFileEdit fires, Cursor pipes a JSON payload to your command on stdin. The fields that matter for this recipe:

{
  "file_path": "/abs/path/to/file.py",
  "edits": [
    { "old_string": "def parse(x):", "new_string": "def parse(x, strict=False):" }
  ],
  "workspace_roots": ["/abs/path/to/project"],
  "hook_event_name": "afterFileEdit"
}

The current payload includes an edits array with old_string and new_string per change (Cursor Hooks reference). For a test-on-edit recipe you do not need the diff at all. You need file_path to decide which runner to invoke and which tests are related. Read file_path, ignore the rest, and you have a stable contract that does not care how the payload grows.

Where the config lives

Cursor reads a hooks.json from several locations and merges them: project (<project-root>/.cursor/hooks.json), user (~/.cursor/hooks.json), and an enterprise path, with enterprise and team config taking priority over project and user (Cursor Hooks reference). For a per-repo test recipe, put it in the project file so it travels with the code and every contributor gets the same behavior:

{
  "version": 1,
  "hooks": {
    "afterFileEdit": [
      { "command": "./scripts/test_on_edit.sh", "timeout": 30 }
    ]
  }
}

The timeout field caps how long Cursor waits on the hook. Keep it small. A hook that blocks the agent for thirty seconds after every edit teaches the user to turn it off.

The runner dispatch

The script’s whole job is to map the edited file to the smallest set of tests that could break, run only those, and get out. Two facts make this cheap in practice:

  • pytest with the testmon plugin selects and runs only the tests affected by the changed code, by tracking which tests touch which lines and comparing against the change (pytest-testmon). The first run builds the dependency database; subsequent runs are scoped.
  • Jest’s --findRelatedTests takes file paths and runs the tests for those files plus their transitive dependents (Jest CLI options). It is the flag lint-staged setups use for the same reason: run the impacted tests, not the whole suite.

A minimal dispatch that handles the two common cases:

#!/usr/bin/env bash
# scripts/test_on_edit.sh
set -e
file_path="$(jq -r '.file_path' < /dev/stdin)"
case "$file_path" in
  */node_modules/*|*/.venv/*|*/dist/*) exit 0 ;;   # never test generated or vendored code
  *.py)          pytest --testmon -q "$(dirname "$file_path")" ;;
  *.ts|*.tsx|*.js|*.jsx) npx jest --findRelatedTests "$file_path" --passWithNoTests ;;
  *)             exit 0 ;;
esac

That is a real starting point. It runs the affected Python or JavaScript tests after each agent edit and stays silent on files that have no business triggering a run. If you have one language and one runner, it is enough.

The two decisions that make or break it

The recipe above works. These two choices are what make it survive past the first afternoon.

Filter aggressively before you dispatch. The first line of the case is not decoration. An agent that bumps a dependency rewrites a lockfile; an agent that runs a build touches dist/. Firing your runner on those is pure latency with no signal. In our own Cursor logs a measurable share of afterFileEdit events land on files nobody should test (lockfiles, generated code, vendored packages), and the filter pays for itself by skipping every one of them. Start the ignore list with vendored directories, lockfiles, and build output, and grow it the first time a run surprises you.

Batch at the turn boundary, do not run per edit. Agent turns in Cursor arrive in bursts: a service, its model, a migration, the controller that wires them, all in one turn. If you run the full test cycle on every afterFileEdit, you multiply latency by the size of the burst and the user watches the same suite start four times. The pattern that scales is to let afterFileEdit do almost nothing (append the changed path to a session state file and exit) and let the stop hook run the tests once, over the union of files edited in the turn. That is the exact design tailtest ships, and the reasoning generalizes past Cursor: the test cycle belongs on the agent’s event boundary, not inside the prompt. Hook-based testing explained covers why that boundary is the load-bearing idea.

If you only take one thing from this section: afterFileEdit is an accumulator, stop is the runner.

Gotchas worth knowing before you ship

A few edges that cost me time, covered in full in the deep-dive but worth flagging here:

  • workspace_roots can be empty when Cursor is in single-file mode. Fall back to the directory of file_path or the hook silently no-ops.
  • The hook can fire before the write is fsynced in some setups. If you read the file in afterFileEdit you can race the write. Defer any file read to the stop hook, where the write is durable.
  • Exit codes are not free-form. Exit 0 means success; exit 2 blocks the action; any other code is treated as a failure and, by default, the action still proceeds (Cursor Hooks reference). For a test-on-edit recipe you almost always want to exit 0 and surface the result as a note, not block the turn.

What tailtest adds on top

The recipe above is the skeleton. What tailtest layers on it is the part that turns “tests ran” into “here is the one that matters”:

  • One runner dispatch across Python, JavaScript, TypeScript, and more, instead of a hand-maintained case statement.
  • The per-edit accumulator plus turn-boundary batching, wired for you.
  • Failure classification that labels each failing test real_bug, test_bug, or environment, so the agent acts on the real bug and ignores the flake.
  • An adversarial pass that generates the boundary-input tests an agent’s own tests skip (empty string, zero, negative, off-by-one), which is where a lot of agent-written bugs actually live.

Tailtest is MIT licensed, ships no telemetry, and needs no SaaS account. The installer is uvx tailtest install --agent cursor, which writes the .cursor/hooks.json and a minimal .tailtest/config.yaml for you. The per-edit testing platform page covers the runtime.

Ready to wire it into your team’s Cursor setup? The Cursor solution page walks through the full integration and where it fits in review.

FAQ

Does Cursor have a PostToolUse hook like Claude Code?

Cursor exposes a postToolUse event, but it fires after every agent tool call, not just file writes. For a run-tests-after-edit recipe you want afterFileEdit, which fires only on agent file writes and carries the changed file_path on stdin.

How do I run tests automatically after a Cursor agent edits a file?

Add an afterFileEdit hook to .cursor/hooks.json pointing at a script that reads file_path from stdin and dispatches an affected-tests runner such as pytest --testmon or jest --findRelatedTests. Filter out vendored and generated files first, and for multi-edit turns accumulate the paths and run once from the stop hook.

Should the test hook run on afterFileEdit or stop?

Both, split by job. Use afterFileEdit to record which files changed and exit fast. Use stop to run the test cycle once over all files edited in the turn. Running the full suite on every afterFileEdit multiplies latency by the number of edits in the turn.

Why are my Cursor test hooks running too often?

You are almost certainly dispatching from postToolUse (which fires on every tool call) instead of afterFileEdit, or you are running tests per edit instead of batching at the stop hook. Switch the event and move the runner to the turn boundary.