Defaults & Scope
Organize a real mock-bot test suite, work through common pitfalls, and learn the package's current defaults and scope.
How to organize a real mock-bot test suite, work through common pitfalls, and what the package's current defaults and scope are.
Structuring a real suite
A mock-bot suite stays fast and isolated because booting a bot is in-process and cheap. A few conventions keep it that way:
- Use one fresh bot per test by default. Boot is in-process and cheap, and state isolation comes free.
- Hand-pick classes for focused specs (
commands: [BanCommand]). ReserveloadFromConfig: truefor broad smoke specs such as "every command registers". - Share fixtures as functions.
createMockBotdeep-clones the world, but module-level state in your command files still persists within a worker. - Prefer
await using bot = ...; useafterEach(() => bot.close())only when your runner or transpiler cannot handle explicit resource management. - Parallel test files are safe because each worker owns fully in-process bots.
A focused spec hand-picks the classes under test and leans on await using for cleanup:
import { createMockBot } from '@slipher/testing';
import { expect, test } from 'vitest';
import { BanCommand } from '../src/commands/moderation/ban';
test('/ban confirms', async () => {
await using bot = await createMockBot({ commands: [BanCommand] });
const result = await bot.slash({ name: 'ban' });
expect(result.content).toContain('Banned');
});A broad smoke spec instead boots the production set from seyfert.config so it exercises the same loaders client.start() would use:
import { createMockBot } from '@slipher/testing';
import { test } from 'vitest';
test('every command registers', async () => {
await using bot = await createMockBot({ loadFromConfig: true });
await bot.slash({ name: 'ping' });
});When you cannot use await using, fall back to closing the bot explicitly:
import { afterEach, beforeEach } from 'vitest';
import { createMockBot } from '@slipher/testing';
let bot: Awaited<ReturnType<typeof createMockBot>>;
beforeEach(async () => {
bot = await createMockBot({ commands: [BanCommand] });
});
afterEach(() => bot.close());Troubleshooting
Most failures map to one of these causes:
- "command X is not registered" — check
@Declare({ name }), the dispatch name, and whether the class reachedcommandsor compiledloadFromConfigoutput. - My collector never fires — send a real message first, click as the same user, and avoid passing a stale
source. - My modal flow hangs —
waitForuses real timers; the usual cause is a different user between the opener dispatch andfillModal(). no interceptor or world entity matched GET ...— seed the world,intercept()the route, or setonUnhandledRest: 'silent'for that test.- Decorator/transform errors on
@Declare— enableexperimentalDecoratorsin the test transform config. - Cache looks stale after
emit— emit full Discord event shapes, not partial patches. - Passes alone, fails in CI — reset module-level state in your command files with
beforeEach.
Current defaults
The package is pre-1.0. Current defaults are:
- The single default user (
TEST_USER_ID). - Strict
onUnhandledRest: 'error', with opt-in REST fallback shapes underonUnhandledRest: 'warn' | 'silent'. - Newest-first message lists.
- An unhandled error inside a command/component/modal/event handler rejects the
Dispatchby default (onCommandError: 'throw'); passonCommandError: 'capture'to surface it onresult.errorinstead.
The default ids are real, numeric-string snowflakes, so they survive the BigInt-based seyfert helpers (avatarURL, createdAt, timestamp decoding) the way a real id does:
import {
TEST_BOT_ID, // '900000000000000001'
TEST_APPLICATION_ID, // '900000000000000002'
TEST_GUILD_ID, // '900000000000000003'
TEST_CHANNEL_ID, // '900000000000000004'
TEST_USER_ID, // '900000000000000005'
} from '@slipher/testing';The read-only bot.world (WorldStateReader) is the supported way to assert on the ~20 entity types the views don't surface (pins, reactions, bans, webhooks, emojis, invites, automod rules, scheduled events, poll voters, …).
Scope
The mock bot is in-process and runner-agnostic: no WebSocket server, no fake timers, no assertions. Discord-side behavior the world does not model is simulated with rest.intercept(). Files ride along raw as result.reply?.files and action.files, so attachment presence is asserted through the captured reply or recorded action.