claude plugin test. 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.
Write a test
A test loads your mod, sends events through its hooks the way Claude Code would, and checks what the hooks did, without a session, a sign-in, or a network. You run tests from your shell withclaude plugin test, and each test file imports the test kit, a test library in the claude-code/testing module.
Give 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.
This test raises two tool calls, runs the /tally command from Create a mod, and checks that the reply counts both. Its first line is a stub, which answers the tool calls in Claude Code’s place. Save it as first-mod/tests/first-mod.test.ts:
first-mod/tests/first-mod.test.ts
first-mod directory:
$.tool.call went through the mod’s tool.call hook, which added one to its count and passed the call on to the stub. No ls ran and no file was read. $.command.run then went to the mod’s command.run hook, and answer is the object that hook returned.
The command exits with status 1 when a test fails, so it works in CI. If your own mods can’t load in the shell that runs it, it prints a line starting claude plugin test: hooks modules are turned off with the reason, and exits with status 1.
Stub what Claude Code would answer
No 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:$: the test’s own$, which stands where Claude Code does. It isn’t the mods API 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' })raisestool.call.$.command.run,$.prompt.submit,$.session.start, and$.turn.completework the same way, and$.classic.Stopand the other$.classicmethods raise a settings hook event. A test can’t raise a mods API call such asui.closedirectly. Trigger it through your mod, for example by pressing the button that closes the pane.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 asstore.getanswers your mod’s$.store.get. When your mod calls$.model.completeor$.store.get, a stub supplies the answer.
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. To type /grade in a session, the mod also has to register the command:
grader/hooks/register.js
grader/tests/grader.test.ts
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.
A 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 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 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:
returned neither { value } nor { deny }: a stub for a mods API call returned a bare valueno implementation forfollowed by a name: your mod made that call and no stub answers it
mock.clock(on) answers $.clock, mock.store(on, { count: 7 }) answers $.store from a store that starts with those entries, and mock.env(on, { CI: 'true' }) answers $.env.get from those variables. mock.clock returns a mock clock that your test advances, so a test of a timer doesn’t wait. mock.store returns nothing, so to check what your mod saved, write the two store stubs yourself as the drawing test does.
Follow the test kit’s rules
The test kit has a few rules of its own, and breaking one produces the errors new test authors hit first:-
Register every stub before the test’s first call on
$. Callingonafter that throws an error such ason("ui.render") after the test first called $. -
session.startdoesn’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 whatsession.startsets up, raise it first:The second stub answers the$.command.registercall that asession.starthook such as the tutorial’s makes. Without it, that call rejects withno implementation for command.registerand the kit skips your hook, so nothing after the call in the hook runs. The test doesn’t fail at that point. The skipped hook is listed underthe engine reported:only if a later check fails. -
A hook that returns
next(e)needs a stub to answer. When yourui.renderhook returnsnext(e), for example to draw nothing while Claude is idle, mounting it fails withno implementation for ui.render. Register a stub that returns an element as plain data:With the stub registered, the mount succeeds, andui.find({ type: 'Text' })returns that element whenever your hook returnednext(e). -
A stub for
turn.stepis an async generator, and the test reads the stream to its end to get the result:When the loop ends,resultis the object the stub returned, after yourturn.stephook has had the chance to change it. Hereresult.answeris'ok'. -
Raise a tool call with the tool’s name and arguments as fields, such as
await $.tool.call({ tool: 'Bash', command: 'ls' }), and register atool.callstub that returns{ result }.
Look up what a stub returns
Every mods API call your mod makes in a test needs a stub that answers in Claude Code’s place, except the few the kit answers itself:$.ui.invalidate and $.state calls. For $.clock calls, use mock.clock(on), or your mod’s $.clock.now() fails with no implementation for clock.now.
This table lists the ones mods use most. The first column is the call your mod makes or the event it passes on with next(e). The second is the function to pass to on under that name, so the $.store.get row becomes on('store.get', ($, e) => ({ value: saved.get(e.key) })). A '...' in a stub marks text for you to fill in:
expect has the assertions toBe, toEqual, toMatch, toMatchObject, toContain, toBeDefined, toBeUndefined, and toThrow, and .not before any of them.
Test a timer
A mod that runs work on a timer needs a clock the test controls, so the test can move time forward instead of waiting.const clock = mock.clock(on) returns a mock clock that starts at 0 and moves only when your test moves it. To start at another time, pass it in milliseconds, as in mock.clock(on, { now: 5000 }). The clock has these methods:
This hook belongs to a mod named
countdown, and handles a /countdown command that takes a number of seconds, starts a one-second $.clock.every timer, and shows a toast at zero. As with grader, the file holds only the hook under test and doesn’t register the command:
countdown/hooks/register.js
/countdown 3 and moves the mock clock, so it checks three seconds of behavior without waiting three seconds:
countdown/tests/countdown.test.ts
expect shows that the toast doesn’t come early, and the second shows that it comes once. Each advance resolves after the timers that came due have run, so the check on the next line sees their effect.
Test a drawing
A test can draw one of your mod’s render sites, then press, type into, and find the elements it drew.$.ui.mount draws the site through your mod’s ui.render hook and returns a handle with a method for each of those. To cover several apps in one test, set surface to the app to draw for. This test opens the pane from Build a pane with tabs, switches tabs, presses the button, and checks the count in the terminal and the Desktop app:
hello-tabs/tests/hello-tabs.test.ts
claude plugin test from the hello-tabs directory. The test passes when both apps draw the count line and the mod has saved 2. The count carries over from the first app to the second because both mounts use the same loaded module.
The handle that $.ui.mount returns has these methods, which address elements by the key you gave them:
Each method resolves after your handler has finished, so you can check the result on the next line. Set
props to what Claude Code would pass for that site. The render sites table lists each site’s props, and the types for your build have their types.
A drawing test checks the tree your hook returns and whether it’s valid for that app. It doesn’t check how the app paints it, so look at a new layout in a real session as well.
Test a drawing after /clear
Each 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.
This test checks the module from Load a saved value again after /clear. Add it to the file from 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 does:
hello-tabs/tests/hello-tabs.test.ts
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.
Test a mod that judges other mods
A mod your organization lists inprependPlugins 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:
tier: call it once at the top of the test file, as intier('prepend'), to load your mod asprepend,append, orbuiltin, its place in the order mods run in. Without it, your mod loads asuser.plugins: passtestan options object ahead of the test body. Itspluginsarray holds mods you write inline, each with anameand aregisterfunction. To load one somewhere other thanuser, addtierto it.
acme-guard/tests/guard.test.ts
claude plugin test from the acme-guard directory. Both tests pass with the policy mod as the admin page shows it.
The kit loads every mod at the test’s first call on $. When your mod refuses one, that call throws, and the message names the refused mod, the mod that refused it, and your reason. In the second test nothing is refused, so reader answers the tool call before it reaches the stub.
Next steps
- Troubleshoot a mod: find out why a mod does nothing in a session
- Mods reference: every event’s input and result, for writing stubs