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.
This is the fast path: you test one command's run() body in isolation, with plain fixtures and no mock bot. By the end you will have a passing Vitest test that never boots a client.
When to use this
Reach for fixtures when you only care about the logic inside run() — the branching, the reply you build, the stub calls. No option parsing, no middlewares, no permissions, no REST.
It is much faster than the mock bot, because nothing boots. You build a stand-in context and call the method directly, so each test is a single function call.
Rule of thumb: pure run() logic → fixtures. Anything touching option parsing, middlewares, permissions, components, or events → the mock bot.
Declare the command in its own file
Keep the command exactly as you ship it — in its own file, with its real decorators and options. The test imports that real class; nothing is rewritten for testing.
// commands/ban.ts
import { Command, Declare, Options, createUserOption, type CommandContext } from 'seyfert';
const options = {
user: createUserOption({ description: 'User to ban', required: true }),
};
@Declare({ name: 'ban', description: 'Ban a user' })
@Options(options)
export default class BanCommand extends Command {
async run(ctx: CommandContext<typeof options>) {
await ctx.write({ content: `Banned ${ctx.options.user.id}` });
}
}Build a mock context and run the command
mockCommandContext() builds a stand-in context carrying just the fields a command touches. Pass it the command class and it infers the option types, derives the name from @Declare, and binds the command — so ctx.run() executes the real run() body with no argument and no cast.
// ban.test.ts
import { mockCommandContext, mockUser } from '@slipher/testing';
import { expect, test } from 'vitest';
import BanCommand from './commands/ban';
test('replies after banning', async () => {
// options are typed from BanCommand — no manual generic, no cast
const ctx = mockCommandContext(BanCommand, {
options: { user: mockUser({ id: '123' }) },
});
// the command is already bound, so run() needs no argument
await ctx.run();
// every reply lands in ctx.responses, so you assert without spies
expect(ctx.lastResponse()).toMatchObject({
content: expect.stringContaining('Banned'),
});
});Passing the class is the preferred form: the options are inferred, the command is bound, and there is no manual new and no as unknown as cast to maintain. If you don't have the class to hand, the object form — mockCommandContext({ commandName, options }) — still builds a plain response sink for driving a handler yourself.
Assert on the recorded replies
Every reply method (write, editOrReply, followup) pushes onto the same ctx.responses array. So you assert on what the command sent by reading that array — no spies, no mocks to wire up.
expect(ctx.responses.at(-1)).toMatchObject({
content: expect.stringContaining('Banned'),
});Use ctx.responses.at(-1) for the last reply, or index into the array to check an earlier one. ctx.lastResponse() reads the latest, and ctx.clearResponses() resets between phases of a longer test.
When you need the full pipeline
Calling run() skips option parsing, middlewares, and permissions on purpose — that is exactly what fixtures are for. The moment you need any of those, fixtures are the wrong tool.
To exercise the whole pipeline — real option parsing, middlewares, permissions, components, or events — switch to the mock bot. It boots your real client and drives commands end to end, at the cost of speed.
References
- The Toolkit › Fixtures — the full fixtures API: factories, stubs, and deterministic ids.
- Setup — boot the mock bot when you need the full pipeline.
Modals
Open a modal from a command or component, submit it with field values using fillModal, and assert the reply through the real pipeline.
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.