Skip to main content
You can write automated tests for a mod and run them from your shell with 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 with claude 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
In your shell, run the tests from the first-mod directory:
The output names each test and whether it passed, with timings that vary from run to run:
Each $.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' }) 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. 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.
  • 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 or $.store.get, a stub supplies the answer.
This 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. To type /grade in a session, the mod also has to register the command:
grader/hooks/register.js
This test stubs the model call to check what the hook does with a passing reply:
grader/tests/grader.test.ts
The 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. 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 value
  • no implementation for followed by a name: your mod made that call and no stub answers it
The kit also exports in-memory mocks that answer a whole namespace for you. 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 $. Calling on after that throws an error such as on("ui.render") after the test first called $.
  • session.start 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:
    The second stub answers the $.command.register call that a session.start hook such as the tutorial’s makes. Without it, that call rejects with no implementation for command.register and 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 under the engine reported: only if a later check fails.
  • A hook that returns next(e) needs a stub to answer. When your ui.render hook returns next(e), for example to draw nothing while Claude is idle, mounting it fails with no implementation for ui.render. Register a stub that returns an element as plain data:
    With the stub registered, the mount succeeds, and ui.find({ type: 'Text' }) returns that element whenever your hook returned next(e).
  • A stub for turn.step is an async generator, and the test reads the stream to its end to get the result:
    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'.
  • 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 }.

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
This test runs /countdown 3 and moves the mock clock, so it checks three seconds of behavior without waiting three seconds:
countdown/tests/countdown.test.ts
The first 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
In your shell, run 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
The 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.

Test a mod that judges other mods

A mod your organization lists in prependPlugins 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 in tier('prepend'), to load your mod as prepend, append, or builtin, its place in the order mods run in. Without it, your mod loads as user.
  • 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.
This test file loads the policy mod from the admin page first. It checks that the policy mod refuses a mod that starts a process and admits one that doesn’t:
acme-guard/tests/guard.test.ts
In your shell, run 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