Claude Code ships a test kit for mods. Its declarations run to 1,073 lines at 2.1.286, up from about 1,050 at 2.1.283. The command used to be hidden, and at 2.1.286 claude plugin --help finally lists it as test [dir] Run a mod's tests. Up to 2.1.286 it ran whether CLAUDE_CODE_ENABLE_FUNCTION_HOOKS was unset or 0. At 2.1.287 that variable does nothing anywhere, and what the kit checks is the rollout switch. The 2.1.287 bundle refuses to run a plugin's tests with hooks modules are turned off in this process: the rollout switch served off, and a plugin's tests run only while it is on, and Anthropic's testing page says the command then exits 1. The switch serves on by default, so you'll only meet that where Anthropic has turned installed mods off. At 2.1.272 it ran Anthropic's own suite for the diff mod at 135 passing tests, which is why I trust the runner.
claude plugin test ./my-mod
It finds the *.test.ts and *.test.tsx files in the folder, loads your mod from that folder the same way a session would, and runs each file in the same sandbox your hooks get: no filesystem, no network and no processes.
The five exports
import { describe, expect, mock, test, tier } from "claude-code/testing"
That's the whole module. There's no runner config and no setup file.
tier, once, at the top
tier('user')
Call it once at module scope, before anything else. It says which tier your mod loads in for these tests, and user is the default. It changes what happens, too. A next.to call is refused from user and allowed from prepend, so a test of an organisation policy that forgets to set the tier passes for the wrong reason. sec-default's tests declare tier('prepend') and telemetry's declare tier('builtin'), from when those two were still called that. At 2.1.286 they're cc-plugin-sec-default and cc-plugin-telemetry.
A test
test('rm -rf / is denied', async ($, on) => {
on('tool.call', { tool: 'Bash' }, () => { throw new Error('the command ran') })
const result = await $.tool.call({ tool: 'Bash', command: 'rm -rf /' })
expect(result.deny, 'a guard that lets this through is not a guard').toBeDefined()
})
The body gets two things. $ is the engine's own, so calling a verb on it raises a real dispatch that folds through your mod the way a live session would. on registers hooks that sit beneath every plugin, your mod included. So you don't register the hooks you're testing in the body, since your mod's register already did that. You use on to stand in for whatever core would have done underneath.
test takes options before the body if you need them: plugins, timeoutMs and, since 2.1.284, options. The default timeout is 5,000 ms, which is half the hook budget, so a test of a hook that runs past its 10 seconds needs it raised.
options is your mod's userConfig values, standing in for what the person stored in settings. The declarations say register(on, options) gets them the way a real load would: defaults filled in, then validated, and a bad required field fails the load. Leave it out and you get the manifest's defaults. This passed at 2.1.286 against a guard whose blocked option defaults to rm -rf /:
test('a configured value replaces the default', { options: { blocked: 'git push' } }, async ($, on) => {
on('tool.call', { tool: 'Bash' }, async () => ({ result: { stdout: 'ran', stderr: '' } }))
const pushed = await $.tool.call({ tool: 'Bash', command: 'git push origin main' })
const removed = await $.tool.call({ tool: 'Bash', command: 'rm -rf /' })
expect(pushed.deny).toBeDefined()
expect(removed.deny).toBeUndefined()
})
The declarations add that plugins you pass in plugins get no options at all.
When a test fails, the failure lists everything the engine reported while it ran: each hook it skipped, and why. That's the best thing about this kit. The fail-open behaviour that hides a broken guard in production gets printed right in your test output. A failing run at 2.1.286 looks like this, and exits 1:
(fail) a deliberate failure prints its message [39.42ms]
AssertionError: this one is meant to fail
expect(received).toBeDefined()
Expected: defined
Received: undefined
2 pass
2 fail
Ran 4 tests across 1 file. [0.67s]
Register before you touch $
The declarations say it plainly:
The plugins load at the test's first call on
$, so a test registers its hooks before it.
So register everything first, then call $. This page used to say a hook registered later was quietly left out of the chain. At 2.1.287 the late on throws, as Anthropic's kit rules say. Mine registered fs.write after one $.tool.call and got this:
TypeError: on("fs.write") after the test first called $: the hooks beneath the plugins are registered before that, as a module registers its hooks in register()
The bottom of the chain throws
Under the test's own hooks there's one more, and it throws with the name of its event. When the test body itself calls something nobody stubbed, the call rejects and the test fails. When your mod's hook makes the call, which is the usual case, the throw lands inside that hook and the kit skips the hook the way a session would. The test carries on without it, and the skip is only printed if a later check fails. Anthropic's stub section says the same.
This page used to say a test that forgot to stub $.fs.read fails loudly. For a guard it's worse than quiet, because a skipped guard fails open and the call goes straight on to your tool.call stub. At 2.1.287 I loaded an allowlist mod whose tool.call hook reads allow.json and denies anything not in it, and stubbed nothing but tool.call. A test that git status is allowed passed. A test that rm -rf / is denied failed like this:
(fail) no fs.read stub: a command off the list is denied [37.72ms]
AssertionError: expect(received).toBeDefined()
Expected: defined
Received: undefined
the engine reported:
a94-allow's tool.call hook was skipped: a94-allow: no implementation for fs.read
So test the refusal as well as the pass. A guard test that only checks the allowed path passes with the guard gone.
You stub an operation by hooking it. There's no separate mocking API for operations. A stub for a mods API call such as $.fs.read answers with { value }, the thing the call resolves to in your mod, or with { deny } to make the call reject:
test('the allowlist is read from disk', async ($, on) => {
on('fs.read', async () => ({ value: '["git status"]' })) // stands in for the file
on('tool.call', { tool: 'Bash' }, async () => ({ result: { stdout: '', stderr: '' } })) // stands in for running it
const allowed = await $.tool.call({ tool: 'Bash', command: 'git status' })
const refused = await $.tool.call({ tool: 'Bash', command: 'rm -rf /' })
expect(allowed.deny).toBeUndefined()
expect(refused.deny, 'a command off the list must not run').toBe('not on the allowlist: rm -rf /')
})
That passed at 2.1.287 against the allowlist mod. This page's example used to stub fs.read with a bare string, async () => '["git status"]', and only checked the allowed command, so it passed with the allowlist broken. With the refusal check added, the bare stub fails:
(fail) bare stub: a command off the list is denied [36.65ms]
AssertionError: expect(received).toBeDefined()
Expected: defined
Received: undefined
the engine reported:
test's fs.read hook was skipped: test: returned something that is not a result object
a94-allow's tool.call hook was skipped: a94-allow: no implementation for fs.read
Anthropic's page gives the error for a bare value as returned neither { value } nor { deny }. For a bare string at 2.1.287 I got returned something that is not a result object, so look for either. Their stub table has the right shape for the calls mods make most, and it's worth keeping open while you write tests. It also says the kit answers $.ui.invalidate and $.state itself and that $.clock needs mock.clock(on). The first of those held in my run at 2.1.287, where pressing a Button whose onPress calls $.ui.invalidate went through with no stub.
A tool.call stub has to answer with { result } or { deny }. At 2.1.286 one that returns {} is skipped, and the test fails with the reason:
HooksError: no implementation for tool.call
nothing beneath the plugins answers tool.call: a test answers it with on('tool.call', ...)
the engine reported:
test's tool.call hook was skipped: test: returned neither { result } nor { deny }
This page's example used to stub it with ({}), so if you copied that, this is why it fails.
Other plugins in the chain
test('an org policy above us still wins', { plugins: [policyPlugin] }, async ($, on) => {
// ...
})
Plugin is { name, tier?, register }, and the declarations say its register "closes over nothing of the test file". That's the point of it. You're testing how plugins compose, and a fake plugin that reaches into your test's variables isn't composing, it's cheating.
Things about the test's $
next.origin inside a test is the engine, never a plugin. A hook that branches on origin sees the same thing it sees when the engine raises a real dispatch. That's usually what you want, and now and then it's why a test disagrees with real life: a dispatch raised by another plugin has a different origin, and you build that case with plugins.
A tool.call without a tool_use_id gets one made up for it. You can't drive ui.resolve directly. This page used to put that down to the engine never raising it, which was wrong. Anthropic's reference says it runs while mods load, once for each app, render site and mod, and the 2.1.287 declarations say the same, so it has already run by the time a test could ask.
$.classic.<Event> raises the old settings-hook events, $.classic.SessionStart({ source: 'clear' }) for one. No settings hook runs beneath, so your test's on('classic.SessionStart') is the bottom. PreToolUse isn't there because it rides $.tool.call. At 2.1.286 $.tool.call raises classic.PreToolUse beneath the plugins' tool.call hooks and above the test's own, so a test's on('classic.PreToolUse', async () => ({ deny: 'classic says no' })) stops the call before your tool.call stub runs. It resolves as { result: 'Error: classic says no', text: 'classic says no', isError: true }. With { deny: '' } it's { result: 'Error: Hook PreToolUse:Bash denied this tool', text: 'Hook PreToolUse:Bash denied this tool', isError: true }.
Testing what you draw
Since 2.1.280 the kit can draw your UI and act on it. $.ui.mount draws one component through your mod on a surface you name, and hands back a handle. Pass it what Claude Code would pass your hook, the requestId included, which for a pane is the id your mod opened it with. This is the shape Anthropic's drawing test uses, and it passed at 2.1.287 against a pane that draws a 3 files line and a refresh button:
const PANE = {
plugin: 'my-mod',
component: 'Pane',
requestId: 'my-mod-pane', // the id the mod passed to $.ui.open
viewport: { columns: 100, rows: 30 },
props: { title: 'Files', isFocused: true, bodyColumns: 60, placement: 'inline', scroll: { offset: 0, bodyRows: 10 }, view: {} },
} as const
test('the pane lists every file on both surfaces that draw', async ($) => {
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({ ...PANE, surface })
expect(await ui.find({ type: 'Text', text: '3 files' })).toBeDefined()
await ui.press({ key: 'refresh' })
await ui.unmount()
}
})
The old version of this example was wrong twice. It left out requestId, and the declarations say one is made up when it's absent, so a hook that compares e.requestId with its pane id passes the made-up one on with next(e). Nothing beneath answers, and at 2.1.287 the mount rejects:
HooksError: $.ui.mount: no implementation for ui.render
nothing beneath the plugins answers ui.render: a test answers it with on('ui.render', ...)
It also looked the line up with find({ key: 'count' }), and at 2.1.287 that returns undefined even though the hook gave the Text that key. A Text loses its key in the drawn tree, {"type":"Text","children":["3 files"]}, while a Button keeps its key in props, which is why press({ key: 'refresh' }) still works. So find text by type and text, the way Anthropic's page does, and keep keys for the controls you press. The declarations' own example reads a count with find({ key: 'count' }), and if that count is a Text it comes back undefined too.
You always have to name the surface, since running one body over several surfaces is how you show the mod works on each. The handle is typed by that surface, so there's no input on mobile because the phone has no Input. The 2.1.287 declarations still list four surfaces and the kit still mounts all four. In my run vscode and mobile drew the same tree as the terminal. Only the terminal and the desktop app draw a mod at all, though, going by Anthropic's overview, so that's the loop their page uses and the one above. A pass on vscode or mobile says the tree fits the element table the declarations give them and nothing more.
mount rejects when the tree your hook returned won't draw there, and says why. At 2.1.287 a prop the element doesn't take came back like this:
HooksError: $.ui.mount: a94-pane drew nothing on the terminal surface: a94-pane: ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own
A hand-built { type: 'Svg', ... } node on the terminal was refused the same way, with Svg is not an element of the terminal surface. An Svg you got from $.ui.resolve(e) is a different case. The terminal's table has no Svg, the resolved one draws an empty fragment, the mount succeeds and nothing is reported. drawn() is where you'd see it, as an empty Box. The UI page has both cases.
The handle has drawn() for the whole tree as plain data, find() for one element, press, input and select to act, redraw(props) to draw again with new props, and resize, key and pointer for a Client. The kit runs your hooks and your Client modules. It never paints anything, so it can't catch a surface's own drawing bugs.
You can also act without mounting. $.ui.press, $.ui.input and $.ui.select find the element by plugin and key:
await $.ui.press({ plugin: 'my-mod', key: 'copy' })
await $.ui.input({ plugin: 'notes', key: 'q', text: 'groceries' })
await $.ui.select({ plugin: 'notes', key: 'sort', value: 'date' })
Each one rejects when no such element is drawn, or when several are and you didn't say which. press takes a link too, for a link inside a Markdown. So a press test is also a render test, and a button that quietly stopped being drawn fails here.
mock
There are three, and each one takes the test's on, because it works by registering hooks beneath the plugins:
test('the reminder fires after a minute', async ($, on) => {
const clock = mock.clock(on)
mock.store(on, { seen: 0 })
mock.env(on, { MY_MOD_DEBUG: '1' })
// ...
await clock.advance(60_000) // fire every timer due in the next minute
await clock.settle() // let pending work run without moving time
})
mock.store starts with whatever entries you give it, and mock.env treats any variable you didn't list as unset.
mock.clock doesn't pause the hook budget. An hour of fake time does nothing to the ten real seconds your hook has, and a wait held past that budget is let go as if the hook overran. A test that advances the clock inside a hook that's also doing real work can still run out of time, and the failure will look like a timeout and not a clock problem.
Matchers
There are 23, and the list follows Bun, not Jest, which matters in two places. Anthropic's testing page names eight of them. The 2.1.287 declarations still carry all 23.
| Group | Matchers |
|---|---|
| Equality | toBe toEqual toStrictEqual toMatchObject |
| Containment | toContain toContainEqual toHaveLength toHaveProperty |
| Nullish and truth | toBeUndefined toBeDefined toBeNull toBeTruthy toBeFalsy toBeNaN |
| Numbers | toBeGreaterThan toBeGreaterThanOrEqual toBeLessThan toBeLessThanOrEqual |
| Strings | toMatch toStartWith toEndWith |
| Types and throwing | toBeInstanceOf toThrow |
.not flips any of them, and .resolves and .rejects check what a promise settles with. Inside a structure you've got expect.any, expect.anything, expect.stringContaining, expect.stringMatching, expect.objectContaining and expect.arrayContaining.
toStartWith and toEndWith aren't in Jest. If you're used to Jest you'll reach for toMatch(/^x/), which works fine, and you'll meet the other two when you read Anthropic's tests.
expect takes a message, and it goes first
expect(result.deny, 'a guard that lets this through is not a guard').toBeDefined()
The message prints above the diff. The diff already shows what was checked, so use the message to say why it matters.
What the kit doesn't have
You'll go looking for these, so here they are:
- No
beforeEach,afterEach,beforeAllorafterAll. Write a helper and call it. - No
test.each. Write a loop. - No
test.skipand notest.only. Comment it out.
Missing only is the one that'll annoy you while debugging. There's no flag for it either, so the smallest thing you can run is one file.
Types in your editor
When a session loads your mod from a folder you own, Claude Code writes its declarations into it, and you don't run anything for that. Anthropic's types section lists the files. At 2.1.286 there were four, the same four as 2.1.283. At 2.1.287 there are six, and the debug log names every one on load:
type root of a94-fresh at /tmp/a94-mods-probe/live-fresh/.claude-plugin/types: entries claude-code, claude-code-tools, claude-code-mcp; wrote .claude-plugin/types/claude-code/index.d.ts, .claude-plugin/types/claude-code-tools/index.d.ts, .claude-plugin/types/claude-code-mcp/index.d.ts, .claude-plugin/types/tsconfig.json, .claude-plugin/types/.gitignore, tsconfig.json
.claude-plugin/types/claude-code/index.d.ts, the declarations.claude-plugin/types/claude-code-tools/index.d.ts, the built-in tools' inputs and results, new at 2.1.287.claude-plugin/types/claude-code-mcp/index.d.ts, the inputs of the MCP tools that were connected, new at 2.1.287.claude-plugin/types/tsconfig.json, which is strict withnoUncheckedIndexedAccess, setsjsxtoreactwithhas the factory, and includeshooks,typesandtests. At 2.1.287 it also lists the three type roots undertypes..claude-plugin/types/.gitignore, which is just*. Anthropic's list leaves this one out.tsconfig.jsonat the root, which only extends./.claude-plugin/types/tsconfig.json
Anthropic's page adds one more kind, a folder named for each plugin your plugin.json lists under dependencies, holding what that plugin adds to $. My probes had no dependencies, so I haven't seen one written. A second load that finds the files current logs wrote nothing in place of the list.
Everything under .claude-plugin/types/ is ignored by that .gitignore. The root tsconfig.json isn't, so it'll show up in git status the first time you load the mod. Commit it or ignore it, whichever you like, but don't be surprised by it.
Validate first
claude plugin validate ./my-mod is fast, needs no session and should run in CI before the tests. It parses both manifests, compiles the module, runs the capability scan and prints what the module hooks and calls.
A green validate means something specific, so know what it covers:
| It catches | It doesn't catch |
|---|---|
| A forbidden import | A misspelt operation ($.fs.bogusVerb passes) |
$ bound, spread or read as a value, in a helper too |
$.plugin.name(), which throws at runtime |
Unknown keys in userConfig |
A userConfig default of the wrong type |
The four options rules |
A required field nobody will configure |
| Anything about behaviour |
A CI job that runs validate, then the tests, then loads the mod once with --plugin-dir covers all of that. The last step is the one people skip, and it's the only one that catches a bad config default. You don't need --debug for it at 2.1.286, since a module that won't load prints <plugin>: hooks module did not load: ... to plain stderr, and a good load logs loaded (worker, environment N, tier user) in the debug log. That's for claude -p with --plugin-dir, which is what a CI job runs. Anthropic's troubleshooting page covers where the same error goes in an interactive session. At 2.1.287 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS no longer turns a module off, so a module that's turned off is down to the rollout switch, a policy or disableAllHooks, and the gotchas page has the line each one leaves.
What I haven't checked
At 2.1.286 I ran mount and press in the kit on a PromptHint, with $.ui.mount({ plugin, surface: 'desktop', component: 'PromptHint', props }) and then ui.press({ key: 'copy' }). It needed a stub at the bottom, on('ui.render', ...), or the mount rejects with HooksError: $.ui.mount: no implementation for ui.render. At 2.1.287 I ran the pane example on this page in the kit against a real Pane hook, and it passes as written. I haven't watched that pane in a live terminal or the desktop app, and the kit never paints, so a tree that mounts can still draw badly. The rest comes from the declarations, the shipped suites and runs at 2.1.272, 2.1.283, 2.1.286 and 2.1.287.