Skip to content

Testing Plugins ​

You do not need to hand-assemble the setup context or write as never in plugin tests. @zhin.js/testing offers two layers:

LayerUseEntry points
Fake runtime (unit tests)Fast, no test app; runs setup(), commands, middleware and handlers directlycreateFakePlugin / runPluginSetup / runCommand / runTool ...
Real runtime (integration)Real module discovery, command parsing, permissions and send chainPluginTestKit
bash
pnpm add -D @zhin.js/testing

zhin.js, @zhin.js/runtime and @zhin.js/tool are peer dependencies, and the kit ships no @zhin.js/* copy of its own: core, commands, adapters, the Plugin Runtime and so on all resolve through the zhin.js your plugin installs. The tests therefore run on the same stack as a real bot, and a version mismatch can no longer install a second copy (which the Runtime rejects with Multiple package locations). pnpm and npm add missing peers automatically; package managers that skip peers (such as yarn 1) need them installed together: yarn add -D zhin.js @zhin.js/runtime @zhin.js/tool @zhin.js/testing.

Upgrade @zhin.js/testing together with zhin.js: the kit requires a zhin.js at least as new as the one released with it. With an older zhin.js, npm stops with ERESOLVE and pnpm reports an unmet peer.

Run setup ​

The fake uses the real Scope, DisposeStack and GenerationHandoffStack, plus the real owner-bound databaseHostToken / scheduleHostToken / outboundHostToken facades over in-memory process hosts.

ts
import { runPluginSetup } from '@zhin.js/testing';
import plugin from '../plugin.js';

it('posts to the target group every 30 seconds', async () => {
  const fake = await runPluginSetup(plugin, { config: { target: 'g1' } });

  await fake.clock.advance(65_000);                     // advance the fake clock; due cron jobs run

  expect(fake.sent.map(call => call.kind)).toEqual(['send', 'send']);
  expect(fake.rows('notes')).toEqual([{ text: 'hello' }]); // table defined in setup
  await fake.dispose();                                  // runs lifecycle cleanups
});

Config validation and defaults ​

When the plugin declares a config schema, runPluginSetup treats config the way the real runtime does. It validates the value against the schema, fills in defaults, and keeps only the top-level fields the schema declares. Invalid config throws PluginConfigValidationError, whose issues use the same wording as startup errors (for example /limit must be >= 1). So you can pass only what a user would write and assert that the defaults apply:

ts
const fake = await runPluginSetup(plugin, { config: { target: 'g1' } });
expect(fake.config).toEqual({ target: 'g1', limit: 10 });      // limit comes from the schema default

await expect(runPluginSetup(plugin, { config: { limit: 0 } }))
  .rejects.toThrow(PluginConfigValidationError);               // missing target, limit out of range
  • To get the resolved config without running setup, use resolvePluginConfig(plugin, input).
  • When the plugin declares no config, config is passed through unchanged.

Behaviour change (@zhin.js/testing after 1.0.5)

Earlier versions of runPluginSetup passed config to setup() verbatim. If a test deliberately passes invalid or incomplete config, it now throws. Either complete the config, or opt out per test with runPluginSetup(plugin, { config, validateConfig: false }). createFakePlugin and fake.setup() are unchanged.

createFakePlugin(options) only builds the environment. Run fake.setup(plugin) afterwards, or hand fake.context to your own function.

OptionEffect
name / configPlugin name and the value of context.config.get()
resourcesExtra token bindings: [fakeResource(token, value)], typed by the token, no assertion
hosts{ database, schedule, outbound }; false simulates a missing host (for resources.has() branches)
clockStart time or a shared FakeClock
MemberMeaning
fake.context / resources / lifecycle / handoff / signalThe parts passed to setup
fake.database / schedule / outboundThe same facades resources.use(token) returns
fake.rows(name)Rows of a table the plugin created with define(name) (in-memory; where is equality only)
fake.sentEvery proactive send (send, sendTarget, reactions, recall, edit, typing); sendTarget carries the sending plugin
fake.outboundHost.failNext(error)Make the next send reject, to test failure paths
fake.clockadvance(ms) moves time and runs due jobs in order; set(time) changes time without firing
fake.scheduleHost.trigger(id)Run a registered job immediately
fake.featuresCapabilities registered in setup via addFeature / addCommand etc.
fake.missingRequirements(plugin)Tokens in requires that were not provided

Cron has six fields (sec min hour day month weekday) with *, */n, lists and ranges, evaluated in local time. database.state TTLs use the real Date.now() and are not affected by the fake clock.

Run commands, middleware and handlers ​

Definitions from convention directories can be executed directly; the fake builds their context:

ts
import { createFakeMessage, createFakePlugin, fakeResource, runCommand, runMiddleware, runHandler } from '@zhin.js/testing';
import checkin from '../commands/checkin/index.js';

const fake = createFakePlugin({ resources: [fakeResource(greeterToken, greeter)] });
const message = createFakeMessage({ sender: { id: 'u1', name: 'Ann', roles: ['admin'] } });

await runCommand(fake, checkin, { input: message, params: { days: 3 } });
expect(message.replies).toEqual(['checked in']);        // $reply calls are recorded

// With source (the command's index file or its directory), the Runtime's convention-path rules run first
await runCommand(fake, checkin, { source: new URL('../commands/checkin/index.ts', import.meta.url) });

const { continued } = await runMiddleware(fake, gate, createFakeMessage({ content: 'skip' }));
expect(continued).toBe(false);                          // did the middleware call next()?

await runHandler(fake, onReceive, createFakeMessage()); // `this` is the capability context

This is not a full dispatch

runCommand calls execute() directly: no route matching, parameter parsing, permit checks or admission. Pass already-parsed params. To verify those, use the real runtime below.

With source, runCommand first checks the directory with the Runtime's own convention-path rules (bindCommandSource from zhin.js/command): bracket syntax, the params declaration the directory implies, and "one dynamic segment, and it is the last one". A layout the Runtime refuses to load, such as commands/term/[word]/[...definition], fails in the unit test too. Without source nothing is checked. Tests generated by zhin new pass source.

Run Agent tools ​

runTool(fake, tool, input, options) runs the default export of tools/<name>/index.ts the way the runtime's tool index does:

  • If inputSchema is an executable schema (for example Zod), it parses the input first. JSON Schema inputs pass through unchanged, as they do in the runtime.
  • If the tool declares adapter, you must pass a matching client.

tool can also be the local name of a tool registered through addTool() in setup().

ts
import { createFakeMessage, createFakePlugin, runTool } from '@zhin.js/testing';
import lookup from '../tools/lookup/index.js';

const fake = createFakePlugin({ config: { prefix: '> ' } });
const message = createFakeMessage({ id: 'm1', sender: { id: 'u1' },
  conversation: { endpoint: { id: 'bot', adapter: 'qq' }, kind: 'group', id: 'g1' } });

const result = await runTool(fake, lookup, { word: 'zhin' }, { message });
// inside the tool, context.origin = { kind: 'im', platform: 'qq', endpoint: 'bot', scope: 'group', sceneId: 'g1', messageId: 'm1' }
// context.principal = { subjectId: 'u1', roles: [] }
OptionEffect
messageDerives origin (kind: 'im') and principal from an inbound message, the same fake message your command tests use
origin / principalSet explicitly; these take precedence over message
policyDefaults to no permissions, attended, network disabled
clientNative client port { adapter, get() }; required for tools bound to an adapter
question / signal / traceId / turnId / sessionKeyThe matching context fields

createToolContext(fake, options) only builds the context. toolOriginFromMessage(message) converts a message to the tool origin shape.

No model or approval

runTool skips model discovery, approval, permission and policy checks. It only exercises the tool's own logic.

Partial fakes: fakeOf ​

When a collaborator or event payload only needs a few members, use fakeOf<T>({...}) instead of {...} as never. The cast lives in one place and call sites are still checked against Partial<T>:

ts
const orchestrator = fakeOf<ActivityFeedbackOrchestrator>({ startPhase: vi.fn(), stopPhase: vi.fn() });

Prefer real or kit-provided fakes when they exist; keep fakeOf for narrow collaborators.

Real runtime: PluginTestKit ​

The fake bypasses the renderer, outbound middleware and endpoint negotiation. To verify that a message really goes through that chain, use PluginTestKit: it mounts a disposable real test app and synthetic endpoints capture the final payload. See the kit README for usage and limits.

kit.messageDiagnostics is the same bounded, body-free reader the Console serves at GET /api/messages/diagnostics; use it to assert each message's route, dispatch status and delivery outcome.

Official example ​

plugins/services/activity-feedback/tests/activity-feedback-runtime.test.ts has been migrated: setup runs through createFakePlugin with the event bus and hosts, payloads and the orchestrator use fakeOf, and the file no longer contains as never.

Next ​