Follow Discord
Sweep 02 Oct 2026 · 18:55Z Build v2.1.288 509 read Stable v2.1.285 Latest v2.1.287 Next v2.1.288 Feeds RSS JSON llms.txt llms-full.txt Unofficial
One change · claude-code

Test a mod changedplugins/mods/test

Nearest release: v2.1.287, published 11 hours before upstream edited the page. Shown because the two are within 24 hours of each other. Nothing here says the release caused the edit.

Upstream edited this page at 2 Oct 2026 04:34 UTC, give or take a minute or two: the time comes from Anthropic’s own sitemap rather than from a commit. This site recorded the change at 2 Oct 2026 04:37 UTC.

Upstream edited
Recorded here
Lines+21added
Lines−19removed
From line 1 where the diff opens
First seen 1 Oct 2026 this site's first read of the page
Recorded edits2to this page, all time

## Test a mod that judges other mods

The whole hunk

from line 1, old and new numbered
/
lines
from line 1
11# Test a mod
22 
3> Write automated tests for a Claude Code mod that raise events, stub Claude Code's answers, and press buttons, with no session, sign-in, or network.
3> Write automated tests for a Claude Code mod that fire events, stub Claude Code's answers, and press buttons, with no session, sign-in, or network.
44 
5You can write automated tests for a mod and run them from your shell with [`claude plugin test`](/docs/en/plugins/mods/reference#commands). A test raises the events your hooks handle and checks what the hooks did, so you catch a problem before it reaches a session. The first example tests the mod from [Create a mod](/docs/en/plugins/mods/create).
5You can write automated tests for a mod and run them from your shell with [`claude plugin test`](/docs/en/plugins/mods/reference#commands). A test fires the events your hooks handle and checks what the hooks did, so you catch a problem before it reaches a session. The first example tests the mod from [Create a mod](/docs/en/plugins/mods/create).
66 
77## Write a test
88 
from line 10
1010 
1111Give each test file a name that ends in `.test.ts`, such as `first-mod.test.ts`, and save it anywhere in the plugin directory. Every test file needs at least one `test()`, or the run fails with `declares no test(): nothing ran`. A test file can import your mod's own files and sibling `.ts` helpers, so you can unit test plain functions, such as a game's rules, without the kit.
1212 
13This test raises two tool calls, runs the `/tally` command from [Create a mod](/docs/en/plugins/mods/create), and checks that the reply counts both. Its first line is a [stub](#stub-what-claude-code-would-answer), which answers the tool calls in Claude Code's place. Save it as `first-mod/tests/first-mod.test.ts`:
13This test fires two tool calls, runs the `/tally` command from [Create a mod](/docs/en/plugins/mods/create), and checks that the reply counts both. Its first line is a [stub](#stub-what-claude-code-would-answer), which answers the tool calls in Claude Code's place. Save it as `first-mod/tests/first-mod.test.ts`:
1414 
1515```typescript first-mod/tests/first-mod.test.ts theme={null}
1616import { expect, test } from 'claude-code/testing'
from line 19
1919 // Answer each tool call in Claude Code's place, so no tool runs
2020 on('tool.call', () => ({ result: 'ok' }))
2121 
22 // Raise two tool calls, which the mod's tool.call hook counts
22 // Fire two tool calls, which the mod's tool.call hook counts
2323 await $.tool.call({ tool: 'Bash', command: 'ls' })
2424 await $.tool.call({ tool: 'Read', file_path: 'README.md' })
2525 
from line 54
5454 
5555No model, store, or tool runs in a test, so wherever your mod expects Claude Code to answer, the test supplies the answer with a stub. A test function receives two arguments for that:
5656 
57* **`$`**: the test's own `$`, which stands where Claude Code does. It isn't the [mods API](/docs/en/plugins/mods/reference#mods-api-methods) that a hook receives. Each of its methods raises the event of the same name, sends it through your mod's hooks, and resolves to the result: `$.tool.call({ tool: 'Bash', command: 'ls' })` raises `tool.call`. `$.command.run`, `$.prompt.submit`, `$.session.start`, and `$.turn.complete` work the same way, and `$.classic.Stop` and the other `$.classic` methods raise a [settings hook event](/docs/en/plugins/mods/events#hook-the-settings-hook-events). A test can't raise a mods API call such as `ui.close` directly. Trigger it through your mod, for example by pressing the button that closes the pane.
57* **`$`**: the test's own `$`, which acts as Claude Code. It isn't the [mods API](/docs/en/plugins/mods/reference#mods-api-methods) that a hook receives. Each of its methods fires the event of the same name, sends it through your mod's hooks, and resolves to the result: `$.tool.call({ tool: 'Bash', command: 'ls' })` fires `tool.call`. `$.command.run`, `$.prompt.submit`, `$.session.start`, and `$.turn.complete` work the same way, and `$.classic.Stop` and the other `$.classic` methods fire a [settings hook event](/docs/en/plugins/mods/events#hook-the-settings-hook-events). A test can't fire a mods API call such as `ui.close` directly. Trigger it through your mod, for example by pressing the button that closes the pane.
5858* **`on`**: call it to register stubs, which are hooks that answer in Claude Code's place. Name a stub for a mods API call without the `$.`, so a stub registered as `store.get` answers your mod's `$.store.get`. When your mod calls [`$.model.complete`](/docs/en/plugins/mods/api#call-a-model) or [`$.store.get`](/docs/en/plugins/mods/interface#keep-state), a stub supplies the answer.
5959 
6060This example stubs a model call. The hook belongs to a mod named `grader`, and handles a `/grade` command that sends a sentence to a model and reports whether the reply starts with `PASS`. The file holds only the hook under test, so the mod also needs a `plugin.json` and a `hooks.json`, as in [Create a mod](/docs/en/plugins/mods/create#write-a-mod-yourself). To type `/grade` in a session, the mod also has to [register the command](/docs/en/plugins/mods/api#add-a-command):
from line 97
9797 
9898The test passes because the hook's `reply` is the object under `value`, whose `text` starts with `PASS`. To check the other branch, add a second test whose stub returns a `text` that starts with `FAIL`, and expect `Try again`.
9999 
100A stub for a mods API call returns an object with a `value` field, which holds what the call resolves to in your mod: `{ value: 7 }` makes `$.store.get` resolve to `7`. A stub for one of Claude Code's events, such as [`turn.step`](/docs/en/plugins/mods/reference#turns) or `tool.call`, returns that event's own result, such as `{ result: 'ok' }`. `$.session.send` and `$.prompt.fill` take their event's result too, as the table shows. [Look up what a stub returns](#look-up-what-a-stub-returns) shows which form each common name takes. Two errors mean a stub is wrong or missing. A failed test's output includes a block headed `the engine reported:`, and each error appears there:
100A stub for a mods API call returns an object with a `value` field, which holds what the call resolves to in your mod: `{ value: 7 }` makes `$.store.get` resolve to `7`. A stub for one of Claude Code's events, such as [`turn.step`](/docs/en/plugins/mods/reference#turns) or `tool.call`, returns that event's own result, such as `{ result: 'ok' }`. `$.session.send` and `$.prompt.fill` take their event's result too, as the table shows. [Look up what a stub returns](#look-up-what-a-stub-returns) shows which form each common name takes. These errors mean a stub is wrong or missing. A failed test's output includes a block headed `the engine reported:`, and each error appears there:
101101 
102102* `returned neither { value } nor { deny }`: a stub for a mods API call returned a bare value
103103* `no implementation for` followed by a name: your mod made that call and no stub answers it
from line 110
110110 
111111* **Register every stub before the test's first call on `$`.** Calling `on` after that throws an error such as `on("ui.render") after the test first called $`.
112112 
113* **[`session.start`](/docs/en/plugins/mods/reference#session) doesn't run by itself.** Each test starts with your module freshly loaded and none of its hooks called, so module-level variables hold their initial values. If a hook depends on what `session.start` sets up, raise it first:
113* **[`session.start`](/docs/en/plugins/mods/reference#session) doesn't run by itself.** Each test starts with your module freshly loaded and none of its hooks called, so module-level variables hold their initial values. If a hook depends on what `session.start` sets up, fire it first:
114114 
115115 ```typescript theme={null}
116116 // Answer the event after your hook passes it on with next(e)
from line 117
117117 on('session.start', () => ({ cwd: '/work' }))
118118 // Answer the $.command.register call your hook makes
119119 on('command.register', () => ({ value: undefined }))
120 // Raise the event, which runs your session.start hook
120 // Fire the event, which runs your session.start hook
121121 await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
122122 ```
123123 
from line 142
142142 return { turnId: e.turnId, index: e.index, answer: 'ok', toolUses: [], stopReason: 'end_turn', usage: null }
143143 })
144144 
145 // Raise one request to the model, which runs your turn.step hook
145 // Fire one request to the model, which runs your turn.step hook
146146 const stream = $.turn.step({ turnId: 't', index: 0, model: 'claude-test', messageCount: 1 })
147147 // Read every piece until the stream says it's done
148148 let step = await stream.next()
from line 152
152152 
153153 When the loop ends, `result` is the object the stub returned, after your `turn.step` hook has had the chance to change it. Here `result.answer` is `'ok'`.
154154 
155* **Raise a tool call with the tool's name and arguments as fields**, such as `await $.tool.call({ tool: 'Bash', command: 'ls' })`, and register a `tool.call` stub that returns `{ result }`.
155* **Fire a tool call with the tool's name and arguments as fields**, such as `await $.tool.call({ tool: 'Bash', command: 'ls' })`, and register a `tool.call` stub that returns `{ result }`.
156156 
157157### Look up what a stub returns
158158 
from line 173
173173| `session.start` | `() => ({ cwd: '/work' })` |
174174| `turn.start` | `($, e) => ({ turnId: e.turnId })` |
175175| `tool.call` | `() => ({ result: '...' })` |
176| `turn.complete` | `() => ({ text: '' })`. Raise it with `$.turn.complete({ turnId, answer, durationMs, isAborted: false, usage: null })`. |
176| `turn.complete` | `() => ({ text: '' })`. Fire it with `$.turn.complete({ turnId, answer, durationMs, isAborted: false, usage: null })`. |
177177| `prompt.submit` | `($, e) => ({ text: e.text })` |
178178| `prompt.fill` | `() => ({ isFilled: true })` |
179179| `$.prompt.read` | `() => ({ value: { text: '...', cursor: 0 } })` |
from line 181
181181| `$.session.messages` | `() => ({ value: [{ role: 'assistant', text: '...', toolUses: [] }] })` |
182182| `$.session.id`, `$.agent.list` | `() => ({ value: 'abc123' })`, `() => ({ value: [] })` |
183183| `session.send` | `() => ({ isDelivered: true })`. `e.to` arrives as a string even when your mod passed `{ sessionId }`. |
184| `session.receive` | `($, e) => ({ text: e.text })`. Raise it with `$.session.receive({ origin: { kind: 'peer-send-message' }, text })`. |
184| `session.receive` | `($, e) => ({ text: e.text })`. Fire it with `$.session.receive({ origin: { kind: 'peer-send-message' }, text })`. |
185185| `ui.render` | `() => ({ type: 'Text', props: {}, children: ['...'] })` |
186186 
187187`expect` has the assertions `toBe`, `toEqual`, `toMatch`, `toMatchObject`, `toContain`, `toBeDefined`, `toBeUndefined`, and `toThrow`, and `.not` before any of them.
from line 313
313313 Test a drawing after `/clear`
314314</h3>
315315 
316Each test starts with every `$.state` value at its default, which is how `/clear` leaves them. To test what your mod does next, skip `session.start`, raise `classic.SessionStart` with `source: 'clear'`, and check what your mod draws.
316Each test starts with every `$.state` value at its default, which is how `/clear` leaves them. To test what your mod does next, skip `session.start`, fire `classic.SessionStart` with `source: 'clear'`, and check what your mod draws.
317317 
318318This test checks the module from [Load a saved value again after `/clear`](/docs/en/plugins/mods/interface#load-a-saved-value-again-after-clear). Add it to the file from [Test a drawing](#test-a-drawing), where `PANE` is defined. That file's first test expects the button to save the count, as the button in [Save from more than one session](/docs/en/plugins/mods/interface#save-from-more-than-one-session) does:
319319 
from line 324
324324 // Answer the event after your hook passes it on with next(e)
325325 on('classic.SessionStart', () => ({}))
326326 
327 // Raise the event that fires after /clear, which runs your hook
327 // Fire the event that follows /clear, which runs your hook
328328 await $.classic.SessionStart({ source: 'clear' })
329329 
330330 const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
from line 336
336336 
337337The test passes when your `classic.SessionStart` hook has copied the stored `7` into `$.state` before the pane draws. Without that hook in your module, the pane draws `Count: 0`, `find` returns `undefined`, and the test fails at `toBeDefined`.
338338 
339## Test a mod that judges other mods
339<h2 id="test-a-mod-that-judges-other-mods">
340 Test a policy mod
341</h2>
340342 
341A mod your organization lists in [`prependPlugins`](/docs/en/plugins/mods/admin) can refuse another mod before it loads. To test one, set your mod's tier and give the test a second mod for yours to admit or refuse:
343A mod your organization lists in [`prependPlugins`](/docs/en/plugins/mods/admin) can refuse another mod before it loads. To test one, set your mod's tier and give the test a second mod for yours to allow or refuse:
342344 
343345* **`tier`**: call it once at the top of the test file, as in `tier('prepend')`, to load your mod as `prepend`, `append`, or `builtin`, its place in the [order mods run in](/docs/en/plugins/mods/events#the-order-mods-run-in). Without it, your mod loads as `user`.
344346* **`plugins`**: pass `test` an options object ahead of the test body. Its `plugins` array holds mods you write inline, each with a `name` and a `register` function. To load one somewhere other than `user`, add `tier` to it.
345347 
346This test file loads the [policy mod from the admin page](/docs/en/plugins/mods/admin#enforce-a-policy-with-a-mod-of-your-own) first. It checks that the policy mod refuses a mod that starts a process and admits one that doesn't:
348This test file loads the [policy mod from the admin page](/docs/en/plugins/mods/admin#enforce-a-policy-with-a-mod-of-your-own) first. It checks that the policy mod refuses a mod that starts a process and allows one that doesn't:
347349 
348350```typescript acme-guard/tests/guard.test.ts theme={null}
349351import { expect, test, tier } from 'claude-code/testing'
Feedback