Mock Bot
Boot a real Seyfert client in-process — no token, no gateway, no network — and drive your real commands, components, events, and plugins through the actual pipeline.
The mock bot runs your real command classes end-to-end. Where mockCommandContext() hands your run() a fake context, the mock bot builds a real CommandContext the same way production does: a raw APIInteraction goes through HandleCommand, options are parsed by Seyfert's resolver, middlewares run, and every REST call is recorded instead of sent.
import { createMockBot } from '@slipher/testing';
import { expect, test } from 'vitest';
import { GreetCommand } from '../src/commands/greet';
test('greet replies through the real pipeline', async () => {
await using bot = await createMockBot({ commands: [GreetCommand] });
const result = await bot.slash({ name: 'greet', options: { name: 'slipher' } });
expect(result.content).toBe('Hello, slipher!');
});Use bot.slash(GreetCommand, { options }) when you want option inference from the command class. Use bot.slash({ name, ... }) for concise raw by-name payload dispatch.
The raw interaction response stays available as result.reply?.body ({ type, data }) for the rare assertion where the Discord wire shape itself is the contract. Prefer result.content, result.deferred, and result.edits for normal behavior checks.
Embeds and components come back parsed and typed, so you assert without casting: result.embedView?.title, result.embedViews, result.components, result.component('Approve')?.customId, and result.textDisplays (components-v2). The raw result.embeds/result.embed expose the flattened Discord payloads for wire-shape assertions.
Creating a mock bot
createMockBot() accepts:
commands,components,events— your real classes, registered programmaticallymiddlewares— same record you would pass toclient.setServicesglobalMiddlewares— forwarded to Seyfert's global middleware hooksworld— entities to seed into the client cachesimulateGateway— emit matching member update/remove events for stateful writes; defaults totrueonUnhandledRest— throw by default, or warn/stay silent for explicitly allowed fallback readsplugins— Seyfert plugins loaded through the mock client lifecycleclientOptions— forwarded to the SeyfertClientconstructor, excluding plugin loadingloadFromConfig,commandsDir,componentsDir,eventsDir,langsDir— load the real bot through Seyfert's loadersshards,shardLatency— shape the in-processMockGatewaybotId,applicationIdprefixes,mentionAsPrefix— enable prefix/message-command dispatch withsay()
Test your real bot
Import the classes you ship. Commands, events, and components are registered the same way as production:
import { createMockBot } from '@slipher/testing';
import { BanCommand } from '../src/commands/moderation/ban';
import { ConfirmButton } from '../src/components/confirm';
import guildMemberAdd from '../src/events/guildMemberAdd';
await using bot = await createMockBot({
commands: [BanCommand],
components: [ConfirmButton],
events: [guildMemberAdd],
});Or boot the production set from seyfert.config, using the same loaders client.start() would use:
await using bot = await createMockBot({ loadFromConfig: true });
await bot.slash({ name: 'ping' });The config must be resolvable from the test runner's working directory. Bot configs usually point at compiled output, so build before running broad integration specs.
Explicit directories override the config:
import { join } from 'node:path';
await using bot = await createMockBot({
loadFromConfig: true,
commandsDir: join(process.cwd(), 'dist/commands'),
});Execution model
Every dispatcher returns a lazy Dispatch. Await it directly for one-shot runs, or call dispatch.until(Routes.ban) first to step through the same dispatch. For a dispatch that opens a modal, drive it in one call with .fillModal(...) / .timeoutModal(). Awaiting always releases any active checkpoint, so it cannot deadlock.
- Immediate replies, deferrals, and modal opens are captured in
result.replies. - REST work awaited by the command is classified into
result.edits,result.followups, and dispatch-scopedresult.actions. waitForAction()is for work your command did not await: timers, fire-and-forget promises, queue/scheduler side effects.- Dispatches do not reject for command errors; Seyfert routes them through error hooks, and you assert the user-facing error reply.
| After you awaited | Guaranteed |
|---|---|
await dispatch | everything run() awaited: replies, edits, followups, and dispatch actions |
dispatch.until(...) resolved | the matched call started; response is still undefined while suspended |
await emit(...) | REST work the handler awaited |
| nothing | only waitForAction(...) observes fire-and-forget work |
An unhandled error inside a command/component/modal/event handler rejects the Dispatch by default (onCommandError: 'throw'). Pass onCommandError: 'capture' to surface it on result.error instead.
Unit Tests
Test a single command's run() body fast with fixtures only — build a mock context, call run() directly, and assert on the recorded replies.
Dispatching
Drive raw payloads and interactions through the real bot pipeline — slash commands, components, modals, and events — with lazy, step-able dispatches and fully typed results.