# Test hooks inline

captain-hook ships the test next to the hook. You attach a `tests` dict to any deterministic primitive, mapping an [Input](../../reference/testing.md#captain_hook.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](../../reference/testing.md#captain_hook.Input) to the result you expect, one of [Block](../../reference/testing.md#captain_hook.Block), [Warn](../../reference/testing.md#captain_hook.Warn), or [Allow](../../reference/testing.md#captain_hook.Allow).

``` python
# .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](../../reference/testing.md#captain_hook.Input) carries one field per event payload; see [Input fields](#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.

``` bash
uvx capt-hook test
```

Each 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](../../reference/testing.md#captain_hook.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:

``` python
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](../../reference/primitives.md#captain_hook.block_command) renders its block message as `BLOCKED: {reason}.` plus the `hint` when you pass one. The `pattern` you give [Block](../../reference/testing.md#captain_hook.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:

``` python
Input(command="git stash"): Block(),   # any block message passes
```

`Warn(pattern=...)` works the same way against a warning message. [Allow()](../../reference/testing.md#captain_hook.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()](../../reference/testing.md#captain_hook.Ask) requires no result at all, meaning the dialog shows. The full result-type contract lives in the [Primitives reference](../../docs/reference/primitives.md).


# Input fields

[Input](../../reference/testing.md#captain_hook.Input) models the event payload, and which fields you set depends on the event the hook listens to. A [block_command](../../reference/primitives.md#captain_hook.block_command) hook fires on `PreToolUse` for a Bash command, so you set [command](../../reference/files-commands.md#captain_hook.Call.command). An [Edit](../../reference/prompt-contexts.md#captain_hook.Edit) hook needs `file` and `content`, a `Read` guard reads `offset` and `limit`, and a `Stop` gate needs a `transcript` fixture. The [Primitives reference](../../docs/reference/primitives.md) holds the complete field table, from [command](../../reference/files-commands.md#captain_hook.Call.command) through `transcript` and [tasks](../../reference/events-results.md#captain_hook.BaseHookEvent.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:

``` python
Input(tool="Read", file="big.py", limit=5000): Warn(pattern="narrow"),
```


## Test size-based guards with a real file

Pass a [FileFixture](../../reference/testing.md#captain_hook.FileFixture) to `file` when the hook stats the file or reads its size. [Input](../../reference/testing.md#captain_hook.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:

``` python
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](../../reference/testing.md#captain_hook.FileFixture) takes `size` to fill a file with that many bytes, `content` for exact text, or [name](../../reference/files-commands.md#captain_hook.Call.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](../../reference/testing.md#captain_hook.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](../../reference/files-commands.md#captain_hook.Call.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:

``` python
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](../../reference/testing.md#captain_hook.T), which emits user and assistant lines in the exact shape Claude Code writes:

``` python
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](../../reference/testing.md#captain_hook.T) doesn't cover. The [transcript conditions](../../docs/reference/conditions.md) read the fixture exactly as they would a live session; [Query the transcript](../../docs/guide/llm-hooks.md#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:

``` yaml
# .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:

``` json
{"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](../../reference/tool-calls.md#captain_hook.OtherCall.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](../../reference/tool-calls.md#captain_hook.OtherCall.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()](../../reference/primitives.md#captain_hook.style.styleguide) hook carries its `tests` dict like any other primitive, with one caveat: each [Input](../../reference/testing.md#captain_hook.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](../../reference/primitives.md#captain_hook.style.StyleRule), [StyleDiffRule](../../reference/primitives.md#captain_hook.style.StyleDiffRule)) is covered in [Writing hooks](../../docs/guide/primitives.md).


# 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](../../docs/guide/llm-hooks.md) owns that split and the `Input(llm={...})` overrides.


# See also

[Primitives & Verdicts](../../docs/reference/primitives.md) lists the full [Input](../../reference/testing.md#captain_hook.Input) field set and the result-type contract. [Write an LLM hook](../../docs/guide/llm-hooks.md) covers splitting deterministic narrowing from model judgment, and [Query the transcript](../../docs/guide/llm-hooks.md#query-the-transcript) tours the query API transcript conditions read at runtime.
