Test hooks inline
captain-hook ships the test next to the hook. You attach a tests dict to any deterministic primitive, mapping an Input to the outcome you expect, then run them all with one command. No separate test file, no fixtures to wire up.
Write a hook with inline tests
Create .claude/hooks/no_stash.py in your project. The tests dict maps each Input to the result you expect, one of Block, Warn, or Allow.
# .claude/hooks/no_stash.py
from captain_hook import block_command, Input, Block, Allow
block_command(
["git", "stash"],
reason="Use jj instead of git stash",
tests={
Input(command="git stash"): Block(pattern="jj"),
Input(command="git status"): Allow(),
},
)The first case says “a git stash command must block, and the block message must mention jj.” The second says “a git status command must pass through.” Input carries one field per event payload; see Input fields for the full list.
Run the tests
From your project root, run the test command. With no --hooks flag it covers your project’s own hooks in .claude/hooks; pass --hooks DIR to test only that directory. The wheel builtins are tested by captain-hook itself, and a plugin’s pack is tested from its own repo with pack test, so neither is swept in here. Tests execute under a throwaway $HOME, so hooks that read machine state behave identically on every machine.
uvx capt-hook testEach test prints one line, then a summary:
PASS no_stash:hook_9fd2dfd7:Input(command='git stash')
PASS no_stash:hook_9fd2dfd7:Input(command='git status')
2 tests: 2 passed, 0 failed, 0 errors, 0 skipped
The id on each line is the hook’s registered name, then a colon and the repr of the Input that produced the case; the repr prints only the fields you set. The registered name is the hook file’s stem, the primitive kind, and eight hex characters of the message’s SHA-256 — no_stash:hook_9fd2dfd7 here — so it stays put when you reorder hooks or add new ones to the file. The command exits 0 when every test passes and 1 when any test fails or errors.
Read a failure
Break the first expectation to see what a failure looks like. Change the pattern so it demands a word the message does not contain:
Input(command="git stash"): Block(pattern="mercurial"),Run uvx capt-hook test again:
FAIL no_stash:hook_9fd2dfd7:Input(command='git stash'): [no_stash:hook_9fd2dfd7] Block message 'BLOCKED: Use jj instead of git stash.' doesn't match 'mercurial'
PASS no_stash:hook_9fd2dfd7:Input(command='git status')
2 tests: 1 passed, 1 failed, 0 errors, 0 skipped
The FAIL line names the test, quotes the rendered message, and shows the pattern that missed. The hook still blocked, so the outcome was right, but mercurial does not appear in BLOCKED: Use jj instead of git stash. Restore the pattern to jj and the run goes green.
What Block(pattern=...) matches
block_command renders its block message as BLOCKED: {reason}. plus the hint when you pass one. The pattern you give Block is a regex, and the runner searches it against that rendered message with re.search. So Block(pattern="jj") passes here because jj appears in Use jj instead of git stash. Pick a word that survives rendering, or omit the pattern to assert only that the hook blocked at all:
Input(command="git stash"): Block(), # any block message passesWarn(pattern=...) works the same way against a warning message. Allow() takes no pattern; it asserts the hook returned nothing or an explicit allow. Two expectations sharpen that for PermissionRequest hooks, where “returned nothing” and “answered the dialog” are different outcomes. Allow(explicit=True) requires an actual allow result, so None fails, and Ask() requires no result at all, meaning the dialog shows. The full result-type contract lives in the Primitives reference.
Input fields
Input models the event payload, and which fields you set depends on the event the hook listens to. A block_command hook fires on PreToolUse for a Bash command, so you set command. An Edit hook needs file and content, a Read guard reads offset and limit, and a Stop gate needs a transcript fixture. The Primitives reference holds the complete field table, from command through transcript and tasks, alongside which primitives carry inline tests.
offset and limit populate a Read call’s typed fields, so a hook that reads evt.as_input(ReadCall).limit or matches a CustomInputTypeCondition[ReadCall] sees them:
Input(tool="Read", file="big.py", limit=5000): Warn(pattern="narrow"),Test size-based guards with a real file
Pass a FileFixture to file when the hook stats the file or reads its size. Input materializes the fixture to a real temp file before the hook runs, so a guard that calls evt.file.path.stat().st_size sees a file of the size you asked for:
from captain_hook import FileFixture, Input, Warn, Allow, nudge, Event, CustomCondition, BaseHookEvent
from dataclasses import dataclass
@dataclass(frozen=True)
class LargeFile(CustomCondition):
max_bytes: int = 1_000_000
def check(self, evt: BaseHookEvent) -> bool:
return evt.file is not None and evt.file.path.stat().st_size > self.max_bytes
nudge(
"Large file — read a section, not the whole thing.",
only_if=[LargeFile(max_bytes=1024)],
events=Event.PreToolUse,
tests={
Input(tool="Read", file=FileFixture(size=2048)): Warn(),
Input(tool="Read", file=FileFixture(size=128)): Allow(),
},
)FileFixture takes size to fill a file with that many bytes, content for exact text, or name to control the file name an extension- or path-based condition matches. A plain string file="app.py" stays a virtual path with no bytes behind it, so reach for FileFixture only when the hook needs the file to exist.
Two more knobs cover command guards that stat their operands. A {file} token in Input.command is replaced with the materialized fixture’s absolute path, so the command names a real file. home=True (requires name) materializes the fixture under a private temp home and swaps $HOME to it for just that test, so a literal ~ in the command exercises an os.path.expanduser-based guard deterministically:
tests={
Input(command="cat {file}", file=FileFixture(size=2048, name="big.md")): Warn(),
Input(command="cat ~/notes.md", file=FileFixture(home=True, name="notes.md", size=128)): Allow(),
}When a hook depends on session history, build the transcript fixture with T, which emits user and assistant lines in the exact shape Claude Code writes:
from captain_hook import T
tests={
Input(transcript=[
T.user("run the suite"),
T.assistant(T.tool("Bash", command="uv run pytest")),
]): Allow(),
}T.tool_turn("Bash", result="ModuleNotFoundError", is_error=True, command="uv run pytest") returns a paired call-and-result line for failure scenarios, and raw JSONL dicts mix in freely for shapes T doesn’t cover. The transcript conditions read the fixture exactly as they would a live session; Query the transcript shows how to capture a real one.
Run the same tests in CI
uvx capt-hook test is the whole CI step. Add it to a GitHub Actions workflow and the job fails when any hook test fails:
# .github/workflows/hooks.yml
name: hooks
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v8.2.0
- name: Test hooks
run: uvx capt-hook test --json--json prints one JSON record per test instead of the human summary. Each record is a single line you can pipe into jq or read in another tool:
{"id": "no_stash:hook_9fd2dfd7:Input(command='git stash')", "status": "pass", "expected": "block", "reason": ""}
{"id": "no_stash:hook_9fd2dfd7:Input(command='git status')", "status": "pass", "expected": "allow", "reason": ""}The fields are the test id, its status of pass, fail, error, or skip, the expected outcome kind, and a reason string that carries the failure detail when a test breaks. The exit code is the same as the plain run, so the CI step goes red on any fail or error without you parsing a thing. Parse the records when you want a report; rely on the exit code when you want a gate.
Style rules test the same way
A styleguide() hook carries its tests dict like any other primitive, with one caveat: each Input runs through the whole styleguide, every rule at once, so keep test inputs minimal — a snippet that trips exactly the rule under test. Writing the rules themselves (matchers, StyleRule, StyleDiffRule) is covered in Writing hooks.
Why some tests report SKIP
Only legacy string-key tests report SKIP — they replay a recorded session and skip when no fixture exists. LLM-backed primitives never skip: the runner stubs the model, so their tests exercise wiring, not judgment — LLM hooks owns that split and the Input(llm={...}) overrides.
See also
Primitives & Verdicts lists the full Input field set and the result-type contract. Write an LLM hook covers splitting deterministic narrowing from model judgment, and Query the transcript tours the query API transcript conditions read at runtime.