{"page":"testing","title":"Testing a mod","blurb":"The built-in test kit: describe, expect, mock, test and tier, plus drawing your UI in a test.","build":"2.1.286","sections":["The five exports","tier, once, at the top","A test","Other plugins in the chain","Things about the test's $","Testing what you draw","mock","Matchers","What the kit doesn't have","Types in your editor","Validate first","What I haven't checked"],"limit":8000,"section":null,"offset":0,"count":7956,"total":22769,"next_offset":7956,"next_url":"https:\/\/changelogs.core-directive.com\/reference\/mods\/testing.json?offset=7956","markdown":"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](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/test) 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.\n\n```bash\nclaude plugin test .\/my-mod\n```\n\nIt 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.\n\n## The five exports\n\n```ts\nimport { describe, expect, mock, test, tier } from \"claude-code\/testing\"\n```\n\nThat's the whole module. There's no runner config and no setup file.\n\n## tier, once, at the top\n\n```ts\ntier('user')\n```\n\nCall 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`.\n\n## A test\n\n```ts\ntest('rm -rf \/ is denied', async ($, on) => {\n  on('tool.call', { tool: 'Bash' }, () => { throw new Error('the command ran') })\n\n  const result = await $.tool.call({ tool: 'Bash', command: 'rm -rf \/' })\n\n  expect(result.deny, 'a guard that lets this through is not a guard').toBeDefined()\n})\n```\n\nThe 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.\n\n`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.\n\n`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 \/`:\n\n```ts\ntest('a configured value replaces the default', { options: { blocked: 'git push' } }, async ($, on) => {\n  on('tool.call', { tool: 'Bash' }, async () => ({ result: { stdout: 'ran', stderr: '' } }))\n\n  const pushed = await $.tool.call({ tool: 'Bash', command: 'git push origin main' })\n  const removed = await $.tool.call({ tool: 'Bash', command: 'rm -rf \/' })\n\n  expect(pushed.deny).toBeDefined()\n  expect(removed.deny).toBeUndefined()\n})\n```\n\nThe declarations add that plugins you pass in `plugins` get no options at all.\n\nWhen 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:\n\n```text\n(fail) a deliberate failure prints its message [39.42ms]\n  AssertionError: this one is meant to fail\n\n  expect(received).toBeDefined()\n\n  Expected: defined\n  Received: undefined\n\n 2 pass\n 2 fail\nRan 4 tests across 1 file. [0.67s]\n```\n\n### Register before you touch $\n\nThe declarations say it plainly:\n\n> The plugins load at the test's first call on `$`, so a test registers its hooks before it.\n\nSo 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](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/test#follow-the-test-kits-rules) say. Mine registered `fs.write` after one `$.tool.call` and got this:\n\n```text\nTypeError: 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()\n```\n\n### The bottom of the chain throws\n\nUnder 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](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/test#stub-what-claude-code-would-answer) says the same.\n\nThis 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:\n\n```text\n(fail) no fs.read stub: a command off the list is denied [37.72ms]\n  AssertionError: expect(received).toBeDefined()\n\n  Expected: defined\n  Received: undefined\n\n  the engine reported:\n    a94-allow's tool.call hook was skipped: a94-allow: no implementation for fs.read\n```\n\nSo test the refusal as well as the pass. A guard test that only checks the allowed path passes with the guard gone.\n\nYou 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:\n\n```ts\ntest('the allowlist is read from disk', async ($, on) => {\n  on('fs.read', async () => ({ value: '[\"git status\"]' }))     \/\/ stands in for the file\n  on('tool.call', { tool: 'Bash' }, async () => ({ result: { stdout: '', stderr: '' } }))   \/\/ stands in for running it\n\n  const allowed = await $.tool.call({ tool: 'Bash', command: 'git status' })\n  const refused = await $.tool.call({ tool: 'Bash', command: 'rm -rf \/' })\n\n  expect(allowed.deny).toBeUndefined()\n  expect(refused.deny, 'a command off the list must not run').toBe('not on the allowlist: rm -rf \/')\n})\n```\n\nThat 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:\n\n```text\n(fail) bare stub: a command off the list is denied [36.65ms]\n  AssertionError: expect(received).toBeDefined()\n\n  Expected: defined\n  Received: undefined\n\n  the engine reported:\n    test's fs.read hook was skipped: test: returned something that is not a result object\n    a94-allow's tool.call hook was skipped: a94-allow: no implementation for fs.read\n```\n","previous_page":"config","next_page":"gotchas","url":"https:\/\/changelogs.core-directive.com\/reference\/mods\/testing","index_url":"https:\/\/changelogs.core-directive.com\/reference\/mods.json"}