There is a test kit, it ships inside Claude Code, and it is 512 lines of the declarations. It is also hidden: claude plugin test does not appear in claude plugin --help at 2.1.272. It works, and it runs Anthropic's own suite for the diff mod at 135 passing tests, which is the reason to trust it.
claude plugin test ./my-mod
The five exports
import { describe, expect, mock, test, tier } from "claude-code/testing"
That is the whole module. No runner config, no setup file, no plugin system of its own.
tier, once, at the top
tier('user')
Module scope, called once, before anything else. It says which tier your plugin is seated in for these tests, and it changes behaviour rather than labelling it: a next.to call is refused from user and permitted from prepend, so a test of an organisation policy that forgets this passes for the wrong reason. PluginTier = Exclude<Tier, 'core'>. sec-default's tests declare tier('prepend'); telemetry's declare tier('builtin').
A test
test('a dangerous command is denied', async ($, on) => {
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
if (e.command.includes('rm -rf /')) return { deny: 'no' }
return next(e)
})
const result = await $.tool.call({ tool: 'Bash', command: 'rm -rf /' })
expect(result.deny, 'a guard that lets this through is not a guard').toBe('no')
})
The body receives an engine interface and an on of its own. You register the hooks under test inside the body and then drive them by calling verbs on $, which raise real dispatches through a real fold.
TestOptions has two keys, plugins and timeoutMs, and the default timeout is 5,000 ms. Note that this is half the hook budget, so a hook deliberately testing the 10-second overrun needs the option raised.
When a test fails, the failure carries what the engine reported meanwhile: every hook it skipped, and why. That is the declarations' own wording and it is the feature that makes this kit better than a mocked harness, because the fail-open behaviour that hides a broken guard in production is printed in your test output.
The ordering trap, declared
The plugins load at the test's first call on
$, so a test registers its hooks before it.
Register everything, then touch $. A hook registered after the first $ call is not in the chain and your assertion is measuring the chain without it.
The bottom of the chain throws
Below your hooks, the engine's test harness puts a hook that throws and names its event. So an operation you did not stub rejects rather than resolving to undefined. That is deliberate and it is the right default: a test that forgot to stub $.fs.read fails loudly instead of quietly asserting against nothing.
You stub by hooking. There is no separate mocking API for operations:
test('it reads the config file', async ($, on) => {
on('fs.read', async ($, e, next) => ({ text: '{"ok":true}' }))
on('session.start', myRealHook)
await $.session.start({ cwd: '/work' })
expect(seen).toEqual({ ok: true })
})
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 are firm that it is self-contained: "it closes over nothing of the test file." Which is the point. You are testing composition, and a fake plugin that reaches into your test's variables is not composing, it is colluding.
The test's $ is the engine's own
next.origin inside a test is the engine rather than a plugin. A hook that branches on origin sees the same thing it sees when the engine raises a real dispatch, which is usually what you want and is occasionally the reason a test disagrees with reality: a dispatch raised by another plugin has a different origin and you have to build that case with plugins.
A tool.call with no tool_use_id gets one minted, so you do not have to invent identifiers. EngineNounEvent excludes ui.resolve, so you cannot drive resolution directly.
Pressing a button
await $.ui.press({ plugin: 'my-mod', key: 'copy' })
It rejects when no such Button is drawn on the terminal, or when several are and no instance is named. So a press test is also a render test, and a button that silently stopped being drawn fails here rather than passing on a stale assumption.
mock
Three of them: mock.clock, mock.store, mock.env.
const clock = mock.clock()
await clock.advance(60_000) // fire every timer due in the next minute
await clock.settle() // advance(0): let pending microtasks run
mock.clock does not suspend the hook budget. Advancing an hour of fake time does nothing to the ten seconds of real time your hook has. A test that advances the clock inside a hook that is also doing real work can still hit the budget, and the failure will read as a timeout rather than as a clock problem.
Matchers
Twenty-seven of them, and the list is Bun's rather than Jest's, which matters in exactly two places.
| 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 |
Plus six asymmetric ones for use inside a structure: expect.any, expect.anything, expect.stringContaining, expect.stringMatching, expect.objectContaining, expect.arrayContaining.
toStartWith and toEndWith are the two that are not in Jest. If you are used to Jest, you will reach for toMatch(/^x/) and it works, and if you are reading Anthropic's tests you will meet the other two.
expect takes a message, and the message leads
expect(result.deny, 'a guard that lets this through is not a guard').toBe('no')
The message is printed first, above the diff. Which makes it the place to say why the assertion matters rather than what it checks, since the diff already says what it checks.
What the kit does not have
Being explicit, because you will go looking for these:
- No
beforeEach,afterEach,beforeAll,afterAll. Write a helper and call it. - No
test.each. Write a loop. - No
test.skipand notest.only. Comment it out, or do not write it.
The absence of only is the one that will annoy you while debugging a suite. There is no flag for it either; the granularity is the file.
Validation is the other half
claude plugin validate ./my-mod is fast, needs no session, and belongs in CI ahead of the tests. It parses both manifests, compiles the module, runs the capability scan, and prints the inventory.
Be clear about what it does and does not catch, because a green validate has a specific meaning:
| It catches | It does not |
|---|---|
| A forbidden import | A misspelt operation ($.fs.bogusVerb passes) |
$ bound, passed, spread or read |
A userConfig default of the wrong type |
A malformed matcher, including [] |
Anything about behaviour |
Unknown keys in userConfig |
A required field that nobody will configure |
The four options cross-field rules |
A CI job that runs validate then test then loads the mod once with --plugin-dir and greps the debug log for the load line covers all three of those columns. The third step is the one people skip, and it is the one that catches the config default.
What I could not test
I have not run the kit against a mod that draws a Pane, so everything above about $.ui.press is read off the declarations rather than measured. The rest of this page is from the declarations plus the shipped suites, and the 135-test diff run is the evidence that the runner itself works.