World & State
Seed the in-memory Discord state the mock bot serves — guilds, channels, members, roles, permissions — and query what the bot built.
The world is the in-memory Discord state the mock bot serves. Instead of talking to Discord, the mock client reads guilds, channels, members, roles, and messages from a world you seed before the bot boots, and writes every change the bot makes — created channels, messages, replies, edits, bans, role changes, timeouts — back into that same state. Recorded REST actions prove that calls happened; world state proves what the bot built.
You build a world with mockWorld(), register entities onto it, and pass it to createMockBot({ world }). During the test you assert on it through the read-only bot.world reader, which exposes get, query, and all readers over every entity type the bot created or you seeded.
Entities are written into the real client cache (CacheFrom.Test), so ctx.guild(), ctx.channel(), member.voice(), and related cache reads resolve exactly like production cache hits — no interceptors required.
Seeding a world
mockWorld() returns a WorldBuilder. Each register* method mints an entity with sensible defaults, stores it on the world, and returns it so you can reference its id in later registrations and in dispatches.
import { createMockBot, mockWorld } from '@slipher/testing';
const world = mockWorld();
const guild = world.registerGuild({ name: 'Slipher Lab' });
world.registerChannel(guild.id);
world.registerMember(guild.id, { nick: 'soc' });
await using bot = await createMockBot({ commands: [WhereCommand], world });
await bot.slash({ name: 'where', guildId: guild.id });The registrars are scoped: a channel, role, member, or emoji is always registered under a guild, and a thread, invite, webhook, or stage instance under a channel. Registering against an id that was never seeded throws a descriptive TypeError listing what is seeded, so a typo in a guild id fails loud instead of silently producing an empty cache.
registerGuild() also auto-creates the guild's @everyone role (a role whose id equals the guild id, at position 0). Use everyonePermissions to grant base permissions to everyone, and ownerId to pin the owner — otherwise apiGuild mints a random owner id.
The common registrars:
registerGuild(options?)— seeds a guild and its@everyonerole.registerChannel(guildId, options?)— a channel under a guild; passoverwritesfor permission overwrites.registerThread(parentChannelId, options?)— a thread under an existing channel (defaults to a public thread, carries the parent's guild andparent_id).registerRole(guildId, options?)— a role; setpermissionsandpositionfor hierarchy.registerMember(guildId, options?)— a member; itsuseris added to the world's users automatically if new.registerBotMember(guildId, options?)— the bot's own member (used for bot-side permission checks).registerVoiceState(guildId, options?)— a member's voice state.registerMessage(channelId, options?)— seeded message history.
Beyond these, the builder also registers emojis, stickers, invites, webhooks, automod rules, scheduled events, guild templates, soundboard sounds, stage instances, and audit-log entries (registerEmoji, registerSticker, registerInvite, registerWebhook, registerAutoModRule, registerScheduledEvent, registerGuildTemplate, registerSoundboardSound, registerStageInstance, registerAuditLogEntry).
For domain state the mock should never interpret, setData(key, value) stashes an app-specific value in the world's passthrough store (chainable; read it back with bot.worldData).
createMockBot deep-clones the world, so a world built in a shared helper is safe to reuse across tests. Build worlds in a factory function rather than at module scope when you want full isolation.
Voice states
Seed voice states for commands and middlewares that read member.voice() — music, moderation, and temp-voice bots:
const world = mockWorld();
const guild = world.registerGuild();
const channel = world.registerChannel(guild.id, { name: 'General' });
const member = world.registerMember(guild.id);
world.registerVoiceState(guild.id, { userId: member.user.id, channelId: channel.id });member.voice() then resolves from the cache like a production hit.
The mock models voice state only. Actual voice connections and audio are out of scope.
Permissions
Bare dispatches stay permissive. The bot's app_permissions default to every bit (DEFAULT_PERMISSIONS), so botPermissions guards pass. The invoking member defaults to a realistic non-admin set (DEFAULT_MEMBER_PERMISSIONS) — enough to view a channel and reply, but no moderation bits. Pass memberPermissions: 'all' for an admin invoker, or restrict a test explicitly to trigger onPermissionsFail (member) or onBotPermissionsFail (bot):
await bot.slash({ name: 'ban', memberPermissions: [] });
await bot.slash({ name: 'ban', permissions: [] });Use permissionBits for readable bitfields. It accepts an array of permission flag names, a bigint, or a raw string, and returns the wire string:
import { apiRole, permissionBits } from '@slipher/testing';
const mod = apiRole({ id: 'mod', permissions: permissionBits(['BanMembers']) });
await bot.slash({ name: 'ban', memberRoles: [mod] });For Discord-like permission tests, seed the world and dispatch as a registered member. The mock then computes member.permissions and app_permissions from @everyone, the member's roles, the bot member, and channel overwrites — the real Discord permission algorithm, including owner short-circuit, Administrator, and overwrite layering:
const world = mockWorld();
const guild = world.registerGuild({
id: 'guild',
ownerId: 'owner-user', // apiGuild otherwise mints a random owner id
everyonePermissions: ['SendMessages'],
});
const mod = world.registerRole(guild.id, { permissions: ['BanMembers'], position: 5 });
const member = world.registerMember(guild.id, { roles: [mod.id] });
const denied = world.registerChannel(guild.id, {
overwrites: [{ id: mod.id, type: 'role', deny: ['BanMembers'] }],
});
world.registerBotMember(guild.id, { roles: [mod.id] });
await using bot = await createMockBot({ commands: [BanCommand], world });
await bot.slash({ name: 'ban', guildId: guild.id, channel: denied, user: member.user });Role positions are written into Seyfert's role cache, so hierarchy checks can read client.cache.roles.values(guildId) just like production.
If a dispatch targets a seeded guild with an unregistered user, the mock warns once. Register that user with world.registerMember(...), or pass explicit memberPermissions to bypass the computation.
Once a bot member is seeded with registerBotMember(...), the moderation REST routes (ban, kick, bulk-ban, edit-member, add-role, remove-role) enforce the bot's computed guild permissions and role hierarchy, returning Discord's 403 50013 just like production. Without a seeded bot member those routes stay permissive — useful for unit-style tests that are not asserting bot authorization. Seed the bot member whenever the permission contract matters.
To drive a REST error branch directly, intercept the route with apiError():
bot.rest.intercept(Routes.ban, () => apiError(403, 50013, 'Missing Permissions'));MockApiError is intentionally small. Commands that branch on Seyfert's real error classes should test that real parse path separately.
Querying world state
World state includes both the entities you seeded and the writes the bot made during the test: created channels and threads, messages, interaction replies, edits, followups, DMs, bans, role changes, timeouts, and channel overwrites.
You read all of it through bot.world, which carries three readers that share the same method names but differ in cardinality:
bot.world.get.*returns exactly one entity, throwing aWorldStateError(with a list of candidates) if the query matches zero or more than one. Use it when a test asserts a unique entity exists.bot.world.query.*returns the single match orundefined— handy for optional chaining when the entity may not be there.bot.world.all.*returns an array of every match (empty when none).
Each reader exposes the same 24 entity methods — guild, channel, thread, dm, member, role, message, rawMessage, voiceState, ban, reaction, pin, pollVote, threadMember, emoji, invite, autoModRule, sticker, scheduledEvent, webhook, guildTemplate, soundboardSound, stageInstance, and auditLogEntry. Every method takes a query object whose keys narrow the match: { id }, { guildId, name }, { guildId, userId }, and so on.
Channels, guilds, and roles are the primary assertion surface:
const channel = bot.world.query.channel({ guildId: guild.id, name: 'acme-s1' });
expect(channel?.lastMessage?.content).toContain('Welcome');
expect(channel?.lastMessage?.component('Approve')).toMatchObject({ customId: 'approve' });
expect(channel?.lastMessage?.embeds[0]).toMatchObject({
title: 'Acme S1',
fields: [{ name: 'Budget', value: '$5,000' }],
});DMs are queryable by the recipient's user id:
expect(bot.world.query.dm({ userId: user.id })?.lastMessage?.content).toBe('Check your inbox');Channels and roles also resolve by id alone — no guild id needed — mirroring how Discord keys them globally. The role view carries permissions and color, not just identity:
expect(bot.world.query.channel({ id: channel.id })?.name).toBe('general');
expect(bot.world.query.role({ id: role.id })?.permissions).toBe('4'); // BanMembersWhen a test expects an entity to be unique, reach for get so a missing or ambiguous match fails loud instead of returning undefined:
expect(bot.world.get.guild({ id: guild.id }).name).toBe('Slipher Lab');Seed message history with registerMessage; bot.client.channels.fetchMessages() then returns it newest-first without an interceptor:
world.registerMessage(channel.id, { content: 'old' });
world.registerMessage(channel.id, { content: 'new' });
expect(await bot.client.channels.fetchMessages(channel.id)).toMatchObject([
{ content: 'new' },
{ content: 'old' },
]);Use ChannelView.overwrites for permission-matrix assertions. The direct interaction reply also remains available as result.reply?.body.data when the channel view is not the clearest assertion surface.
Beyond channels, guilds, and roles, bot.world (a WorldStateReader) reaches the rest of the entity types the same way — bot.world.all.reaction({ messageId }), bot.world.query.ban({ guildId, userId }), bot.world.query.pin({ channelId }), and so on for webhooks, emojis, invites, automod rules, scheduled events, poll voters, and more. It also exposes snapshot() / diff() to capture state at a point in time and read mutations declaratively instead of with field-by-field point queries.
Recorded actions
Everything else the bot does goes through REST and is recorded. Each action carries { seq, method, route, body, query, response }, in order:
import { Routes } from '@slipher/testing';
const edit = await bot.waitForAction(Routes.editOriginalResponse);
expect(edit.body).toMatchObject({ content: 'done' });
bot.actions; // every call, in orderWhen a command reads from the API, stub the endpoint so the read resolves without hitting the unhandled-REST trap:
bot.rest.intercept('GET', '/guilds/:guildId', (_action, params) => ({ id: params.guildId, name: 'Stubbed' }));By default, unmatched REST requests throw. Set createMockBot({ onUnhandledRest: 'warn' | 'silent' }) when a test intentionally leans on the synthetic fallback shapes for unmodeled routes.
When simulateGateway is on (the default), stateful writes such as a member edit also emit the matching gateway event (GUILD_MEMBER_UPDATE, GUILD_MEMBER_REMOVE, CHANNEL_CREATE, MESSAGE_CREATE, …) so the world stays consistent with what a real bot would observe after the REST call.
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.
Assertions
Runner-agnostic readers that fail loud when nothing happened, plus worked recipes for bans, plugins, custom clients, and REST failures.