Testing¶
Inline tests¶
Every primitive supports a tests parameter — a dict mapping Input to
expected outcomes (Block, Warn, or Allow):
from captain_hook import block_command, Input, Block, Allow
block_command(
["git", "stash"],
reason="Use jj instead",
tests={
Input(command="git stash"): Block("jj"),
Input(command="git status"): Allow(),
},
)
Run all inline tests (from your project root, --hooks defaults to .claude/hooks):
Input fields¶
Input models the event payload. Which fields you set depends on the event:
| Field | Event type | Example |
|---|---|---|
command |
Bash/Execute tool | Input(command="git stash") |
file |
Edit/Write file path | Input(file="src/main.py") |
content |
Write content / Edit new | Input(file="x.py", content="print('hi')") |
old |
Edit old content | Input(file="x.py", old="foo", content="bar") |
tool |
Override tool name | Input(tool="Grep") |
prompt |
UserPromptSubmit | Input(prompt="Fix the bug") |
agent_type |
Subagent type | Input(agent_type="cleanup") |
transcript |
Session transcript | Input(transcript=Path("fixture.jsonl")) |
Expected outcomes¶
Block(pattern?)— expects the hook to block. Optional regex pattern matches the block message.Warn(pattern?)— expects the hook to warn. Optional regex pattern matches the warning.Allow()— expects the hook to allow (returnNoneor action"allow").
mock_event¶
Create mock events for unit tests:
from captain_hook import mock_event, mock_tool_event, mock_stop_event
# Generic mock — infers event type from tool name
evt = mock_event(tool="Bash", command="ls -la")
# Specific factories
evt = mock_tool_event("Edit", file_path="src/main.py", old_string="foo", new_string="bar")
evt = mock_stop_event()
evt = mock_subagent_stop_event(agent_type="cleanup")
evt = mock_user_prompt_event(prompt="Fix the tests")
dispatch_test¶
Round-trip test through the full dispatch pipeline:
from captain_hook import dispatch_test, HookApp, Event, hook, Command, Input
app = HookApp()
with app:
hook(Event.PreToolUse, message="blocked", block=True, only_if=[Command(r"rm")])
result = dispatch_test(app, Input(command="rm -rf /"))
assert result is not None
assert result["permissionDecision"] == "deny"
assert_result¶
Validate dispatch results against expected outcomes:
from captain_hook import assert_result, Block, Warn, Allow
# Passes — result is a block with matching message
assert_result({"permissionDecision": "deny", "permissionDecisionReason": "blocked"}, Block("blocked"))
# Passes — result is None (allowed)
assert_result(None, Allow())
# Raises AssertionError — expected block but got allow
assert_result(None, Block("should have blocked"))
pytest integration¶
Write standard pytest tests using the mock helpers:
import pytest
from captain_hook import HookApp, Event, hook, mock_event, Command
def test_rm_blocked():
app = HookApp()
with app:
hook(Event.PreToolUse, message="not allowed", block=True,
only_if=[Command(r"rm\s+-rf")])
evt = mock_event(tool="Bash", command="rm -rf /")
from captain_hook import dispatch
results = dispatch(app, evt)
assert any(r.action.value == "block" for r in results)
def test_ls_allowed():
app = HookApp()
with app:
hook(Event.PreToolUse, message="not allowed", block=True,
only_if=[Command(r"rm\s+-rf")])
evt = mock_event(tool="Bash", command="ls -la")
from captain_hook import dispatch
results = dispatch(app, evt)
assert not any(r.action.value == "block" for r in results)
TranscriptFixture¶
For hooks that depend on transcript context, use TranscriptFixture:
from captain_hook import Input, Block, Allow, Warn, TranscriptFixture, nudge, TouchedFile, RanCommand
nudge(
"Run tests after editing",
only_if=[TouchedFile("**/*.py")],
skip_if=[RanCommand(r"pytest")],
tests={
Input(command="echo hi", transcript=TranscriptFixture(messages=[
{"role": "assistant", "content": [
{"type": "tool_use", "name": "Edit", "id": "t1",
"input": {"file_path": "src/main.py", "old_string": "a", "new_string": "b"}},
]},
])): Warn(),
},
)