Fixtures
Plain mock objects — mock contexts, factories, and recording stubs — for fast unit tests of a command's run() body in isolation.
Fixtures are plain mock objects for fast unit tests of a run() body in isolation. They bundle no assertions, spies, or fake timers, so they are runner-agnostic and work with whichever test runner you bring (Vitest, Jest, node:test, …). The examples here use Vitest.
The core is mockCommandContext(): a stand-in for a Seyfert command context with the fields most commands touch, plus working Slipher stubs (logger, queues, scheduler) that record what your command does so you can assert on it afterward. Factories (mockUser, mockGuild, …) build the entities, with deterministic ids you can override.
Rule of thumb: pure run() logic → fixtures; anything touching option parsing, middlewares, permissions, components, REST, or events → the mock bot. Both come from the same import and can coexist in one suite.
Mock command contexts
mockCommandContext() builds a stand-in context you hand straight to a command's run(). Declare the command in its own file, exactly as you ship it:
// 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}` });
}
}Then, in the test, pass the command class to mockCommandContext. It infers the option types from the command, derives the name from @Declare, and binds the command so ctx.run() takes no argument:
// 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'),
});
});Calling run() skips option parsing, middlewares, and permissions on purpose — that's what fixtures are for. To exercise the whole pipeline, reach for the mock bot.
When you don't have the command class to hand, the object form still works as a plain response sink — pass commandName and typed options yourself, and drive your handler however you like. ctx.run() is unavailable in this form (nothing is bound):
const ctx = mockCommandContext<{ user: ReturnType<typeof mockUser> }>({
commandName: 'ban',
options: { user: mockUser({ id: '123' }) },
});mockCommandContext() includes:
author,user,guild(),channel(),me(), andmemberoptionsandmetadatawrite,editOrReply,followup, anddeferReplylogger,queues, andschedulerstubs, plusclientwith the same stub instancesresponses,lastResponse(), andclearResponses(), plus the typed readerslastEmbed(),lastEmbeds(),lastComponents(), andlastTexts()run()when built from a command class — runs the bound command against this mock
Every reply method (write, editOrReply, followup) pushes onto the same responses array, so you assert on what was sent without spies. The stubs on the context and on client are the same instances: ctx.client.logger === ctx.logger, ctx.client.queues === ctx.queues, and ctx.client.scheduler === ctx.scheduler.
MockCommandContext models the Seyfert fields most command tests touch, but it is intentionally not a full CommandContext implementation. Use mockClient({ extra }) when a command touches client surfaces that this package does not model.
Component and modal contexts
Components and modals follow the same class-first shape. mockComponentContext(MyButton) returns a harness: .run(input) executes the component's run() and returns the context it used, while .filter(input) runs its filter() (matching customId first). The input is optional — defaults are derived from the class.
import { mockComponentContext } from '@slipher/testing';
import { expect, test } from 'vitest';
import ConfirmButton from './components/confirm';
test('confirm button replies', async () => {
const button = mockComponentContext(ConfirmButton);
const ctx = await button.run({ customId: 'confirm' });
expect(ctx.lastResponse()).toMatchObject({ content: 'Confirmed' });
});mockModalContext(MyModal) works the same way via .run(). When you don't have the class, the object form builds a raw context — pass customId and fields, and read them back with interaction.getInputValue(id, required?):
import { mockModalContext } from '@slipher/testing';
const ctx = mockModalContext({ customId: 'feedback', fields: { reason: 'spam' } });
const reason = ctx.interaction.getInputValue('reason', true);Scenes
mockScene() wires a consistent set of entities and a command context in one call — the channel belongs to the guild, the member wraps the user, and ctx is built from all of them. It is also class-first, so scene.ctx.run() runs the bound command:
import { mockScene } from '@slipher/testing';
import BanCommand from './commands/ban';
const scene = mockScene(BanCommand, { options: { user: mockUser({ id: '123' }) } });
await scene.ctx.run();
// scene.user / scene.guild / scene.channel / scene.member are all consistently linkedFactories
Factories build entities with sensible defaults; every field is overridable, including the generated ids.
import { mockChannel, mockGuild, mockMember, mockUser } from '@slipher/testing';
const user = mockUser({ username: 'socram' });
const guild = mockGuild({ name: 'Slipher Lab' });
const channel = mockChannel({ guildId: guild.id });
const member = mockMember({ user });Factory outputs carry both ergonomic camelCase accessors and their snake_case wire counterparts (for example globalName/global_name, preferredLocale/preferred_locale). The camelCase field is the convenient accessor for assertions, while the snake_case field lets the same object drop straight into wire-shaped payloads. Both are part of the contract.
Slipher stubs
The logger, queues, and scheduler stubs are functional recorders: calling them mutates in-memory state you can read back, rather than performing real work.
const ctx = mockCommandContext();
ctx.logger.info('queued');
ctx.client.logger.info('also queued');
ctx.logger.add({ command: 'welcome' });
await ctx.queues.get('welcome').add('send', { userId: ctx.author.id });
ctx.scheduler.add('reminder', '30m', () => undefined);
expect(ctx.logger.entries).toHaveLength(3);
expect(ctx.logger.currentContext.command).toBe('welcome');
expect(ctx.queues.get('welcome').jobs).toHaveLength(1);
expect(ctx.scheduler.tasks).toHaveLength(1);logger.add() mutates currentContext and records a synthetic { level: 'add' } entry, so tests can assert context changes in order alongside regular log calls. Each log level (trace, debug, info, warn, error, fatal) appends an entry to logger.entries with its level and args.
queues.get(name) returns a per-name queue whose add records jobs onto queue.jobs, and scheduler.add / interval / cron record tasks onto scheduler.tasks.
queue.add(name, payload, options) uses the third argument to disambiguate named jobs. A call like queue.add('send', { delay: '5s' }) is ambiguous — it could mean a string payload plus job options, or a named job whose payload happens to look like job options. The mock throws a descriptive TypeError instead of guessing. Pass the options explicitly to force a named job: queue.add('send', { payload: true }, { delay: '5s' }).
Behavior recipes
The fixtures bundle no behavior beyond recording. Attach runner-specific behavior by replacing the method or nested surface you need:
import { vi } from 'vitest';
import { mockCommandContext, mockGuild, mockMember } from '@slipher/testing';
const ctx = mockCommandContext();
ctx.guild = vi.fn(async () => ({
...mockGuild(),
members: { fetch: vi.fn(async () => mockMember()) },
}));The same pattern works on any runner — for example, with Jest:
import { mockCommandContext, mockGuild, mockMember } from '@slipher/testing';
const ctx = mockCommandContext();
ctx.guild = jest.fn(async () => ({
...mockGuild(),
members: { fetch: jest.fn(async () => mockMember()) },
}));For commands with large entity graphs, reach for vitest-mock-extended or jest-mock-extended in the app test suite:
import { mockDeep } from 'vitest-mock-extended';
import type { CommandContext } from 'seyfert';
const ctx = mockDeep<CommandContext>();Deterministic ids
Generated ids are deterministic and overridable on every factory. When a test asserts on a generated id, reset the counter first so the sequence is reproducible:
import { beforeEach } from 'vitest';
import { resetMockIds } from '@slipher/testing';
beforeEach(() => resetMockIds());