BitaKit
How-to guides

Testing plugins

Mount a plugin folder, exercise its Actions and inspect state with automatic cleanup.

mountPlugin from @bitakit/core/testing runs one plugin folder without React or a framework. It uses the same discovery conventions as an app and awaits service startup before returning.

Generate a first test

In an app, the CLI writes a starting test for an existing plugin:

npx bitakit add test counter

tests/counter.test.ts mounts src/plugins/counter, checks that the plugin starts, then stops it. When the plugin places Actions in shell.* slots, the file includes the Shell stand-in. The command then prints what the app still needs as commands: the dev dependencies below that package.json does not list, npm pkg set type=module and npm pkg set scripts.test="node --test tests/*.test.ts". It never edits package.json or overwrites a file. Add the behavior you want to keep, as in the next section.

Test the Counter

Use the service and increment Action from Add state and Add an Action, before adding Settings. From that project's root, install the test dependencies:

npm install --save-dev @bitakit/discovery jiti typescript @types/node

Use Node.js 24 or newer and "type": "module" in package.json. Create tests/counter.test.ts. The type-only import derives the service type from its existing definition; it does not start or register another service.

import assert from 'node:assert/strict';
import test from 'node:test';
import { defineContract, getService } from '@bitakit/core';
import { mountPlugin } from '@bitakit/core/testing';
import type counterValue from '../src/plugins/counter/services/value.ts';

const counterService = defineContract('counter.value').of<ReturnType<typeof counterValue.create>>();

test('increment changes the shared Counter value', async () => {
  const plugin = await mountPlugin('src/plugins/counter');
  try {
    const counter = getService(plugin.services, counterService);
    assert.equal(counter.state.get().count, 0);
    const result = await plugin.execute('increment');
    assert.equal(result.status, 'completed');
    assert.equal(counter.state.get().count, 1);
  } finally {
    await plugin.stop();
  }
});
node --test tests/counter.test.ts

Node strips types; it does not typecheck. Include the test in a strict TypeScript check with noEmit and allowImportingTsExtensions. Keep headless plugin files free of JSX; the helper skips ui/ and does not verify rendering.

A plugin in the Shell navigation

A plugin made by npx bitakit add plugin places its open Action in shell.navigation. Without Shell, startup fails with Slot "shell.navigation": no owning definition exists. Pass a stand-in for the slot owner:

import { definePlugin, defineSlot } from '@bitakit/core';

const shell = definePlugin({
  id: 'shell',
  slots: { navigation: defineSlot({ accepts: 'actions' }) },
});
const plugin = await mountPlugin('src/plugins/notes', { plugins: [shell] });

Two plugins together

plugins also takes plugin folders. Mount the listener and pass the emitter's folder, so the test runs both real plugins and neither needs a hand-written stand-in:

import type feed from '../src/plugins/activity/services/feed.ts';

const feedService = defineContract('activity.feed').of<ReturnType<typeof feed.create>>();

const plugin = await mountPlugin('src/plugins/activity', { plugins: ['src/plugins/notes'] });
await plugin.execute('notes.add', 'Ship');
assert.deepEqual(getService(plugin.services, feedService).state.get().titles, ['Ship']);

Options and returned handles

OptionPurpose
idOverride the ID inferred from the manifest, plugin folder or package.
pluginsOther plugins for this test: plugin folders or definePlugin objects (stubs).
servicesSupply host-owned services, for example a fake auth implementation.
configOverridesOverride developer configuration for the test.

The result contains id, runtime, app, actions, services, events, execute and stop. execute('increment') uses the mounted plugin's prefix; full Action IDs also work. Generated or manually augmented ActionMap entries type full IDs and their inputs/results.

events.recorded contains { type, data } for events emitted while mounted, including startup. Use it to assert cross-plugin notifications after a mutation. A failed startup cleans up the partial runtime and rejects with the attributed startup error. Always await stop() before replacing a runtime or finishing a test; cleanup failures are test failures.

For UI tests, renderWithBitaKit from @bitakit/ui/testing mounts the existing provider and awaits readiness. The test must supply a DOM environment such as jsdom; the helper does not install one. See the package's testing entry for its typed options.

On this page