Testing
Introduction
Section titled “Introduction”Writing tests for a Discord bot has historically been painful — you either test against a live Discord server (slow, flaky, rate-limited) or you build your own mocking infrastructure from scratch. Warden takes a different approach: the framework ships with a complete testing toolkit that boots your application with full dependency injection but without any Discord connection.
The testing utilities are designed to feel familiar — TestBot boots your application without a Discord connection, and FakeInteraction plays the role of a real interaction so your assertions can run synchronously and predictably.
Warden uses Vitest as its test runner. If you scaffolded your project with create-@warden, Vitest is already configured and ready to go. Just run:
pnpm testIf you set up your project manually, follow these steps:
-
Install Vitest as a dev dependency:
Terminal window pnpm add -D vitest -
Add a test script to your
package.json:{"scripts": {"test": "vitest"}} -
Create a
vitest.config.ts:import {defineConfig} from 'vitest/config';export default defineConfig({test: {globals: true,environment: 'node',setupFiles: ['./tests/setup.ts'],},}); -
Create the setup file to load
reflect-metadata(required for DI):tests/setup.ts import 'reflect-metadata';
TestBot
Section titled “TestBot”TestBot boots your entire framework — scanning decorated classes, wiring up the DI container, registering middleware — without connecting to Discord. This means your tests exercise the real pipeline, not a simplified mock of it.
import {TestBot} from '@warden/core/testing';
const bot = TestBot.create().discover(d => d.load('./src/**/*.ts'));That’s it. The bot is ready to execute handlers. No token needed, no gateway connection, no rate limits.
Executing handlers
Section titled “Executing handlers”Use bot.execute() to dispatch a fake interaction through the full middleware and handler chain:
const interaction = FakeInteraction.chatInput({commandName: 'warn', options: {user: '123456789', reason: 'spam'}});await bot.execute(interaction);The interaction passes through global middleware, typed middleware, permission checks, cooldowns — everything that would happen in production. Only the Discord connection is absent.
Scoping
Section titled “Scoping”You can scope TestBot to a subset of your handlers for focused tests:
// Only load moderation commandsconst bot = TestBot.create().discover(d => d.load('./src/commands/mod/**/*.ts'));FakeInteraction
Section titled “FakeInteraction”FakeInteraction provides builders for every interaction type Discord supports. Each builder produces an object that behaves like a real discord.js interaction but runs entirely in memory.
Chat input (slash commands)
Section titled “Chat input (slash commands)”import {FakeInteraction} from '@warden/core/testing';
const interaction = FakeInteraction.chatInput({ commandName: 'warn', options: { user: '123456789012345678', reason: 'Spamming in #general', severity: 'high', },});Subcommands
Section titled “Subcommands”const interaction = FakeInteraction.chatInput({ commandName: 'mod', subcommand: 'ban', options: { user: '123456789012345678', reason: 'Repeated violations', },});Button interactions
Section titled “Button interactions”const interaction = FakeInteraction.button({ customId: 'ban/123456789012345678',});Modal submissions
Section titled “Modal submissions”const interaction = FakeInteraction.modal({ customId: 'warn-reason/123456789012345678', fields: { reason: 'Spamming invite links', severity: 'medium', },});Select menu interactions
Section titled “Select menu interactions”const interaction = FakeInteraction.selectMenu({ customId: 'mod-action-select', values: ['warn'],});Each builder accepts an optional second argument for additional context like guildId, userId, or channelId:
const interaction = FakeInteraction.chatInput( {commandName: 'warn', options: {user: '123', reason: 'spam'}}, {guildId: '987654321', userId: '111222333', channelId: '444555666'},);Assertion helpers
Section titled “Assertion helpers”Every FakeInteraction tracks what happened to it during execution. You can assert against these properties directly:
| Property | Type | Description |
|---|---|---|
replied | boolean | Whether reply() or followUp() was called |
lastReply | string | EmbedBuilder | The content of the most recent reply |
deferred | boolean | Whether deferReply() was called |
ephemeral | boolean | Whether the reply was ephemeral |
replies | Array | All replies, in order |
const interaction = FakeInteraction.chatInput({commandName: 'warn', options: {user: '123', reason: 'spam'}});await bot.execute(interaction);
expect(interaction.replied).toBe(true);expect(interaction.ephemeral).toBe(false);expect(interaction.lastReply).toContain('warned');Testing commands
Section titled “Testing commands”Let’s test a /warn command end-to-end. The command creates a warning in the database and replies with a confirmation:
import {describe, it, expect, vi} from 'vitest';import {TestBot, FakeInteraction} from '@warden/core/testing';import {container} from 'tsyringe';
describe('WarnCommand', () => { const bot = TestBot.create().discover(d => d.load('./src/**/*.ts'));
it('warns a user and replies with the case number', async () => { // Mock the case service const mockCaseService = { createWarning: vi.fn().mockResolvedValue(42), }; container.register(CaseService, {useValue: mockCaseService});
const interaction = FakeInteraction.chatInput({ commandName: 'mod', subcommand: 'warn', options: { user: '123456789012345678', reason: 'Spamming in #general', }, });
await bot.execute(interaction);
expect(mockCaseService.createWarning).toHaveBeenCalledWith( expect.any(String), // guildId '123456789012345678', // userId 'Spamming in #general', // reason expect.any(String), // moderatorId ); expect(interaction.replied).toBe(true); expect(interaction.lastReply).toContain('case #42'); });
it('requires ModerateMembers permission', async () => { const interaction = FakeInteraction.chatInput( {commandName: 'mod', subcommand: 'warn', options: {user: '123', reason: 'test'}}, {permissions: []}, // no permissions );
await bot.execute(interaction);
expect(interaction.replied).toBe(true); expect(interaction.lastReply).toContain('permission'); });});Testing events
Section titled “Testing events”Events work the same way. Instead of a FakeInteraction, you dispatch the event directly through the bot:
import {describe, it, expect, vi} from 'vitest';import {TestBot} from '@warden/core/testing';import {container} from 'tsyringe';
describe('AutoModFilterEvent', () => { const bot = TestBot.create().discover(d => d.load('./src/**/*.ts'));
it('deletes messages containing banned words', async () => { const mockMessage = { content: 'check out this banned-word', author: {bot: false, username: 'troublemaker'}, guildId: '123', delete: vi.fn(), channel: {send: vi.fn()}, };
await bot.emit('messageCreate', mockMessage);
expect(mockMessage.delete).toHaveBeenCalled(); });
it('ignores messages from bots', async () => { const mockMessage = { content: 'check out this banned-word', author: {bot: true, username: 'some-bot'}, guildId: '123', delete: vi.fn(), };
await bot.emit('messageCreate', mockMessage);
expect(mockMessage.delete).not.toHaveBeenCalled(); });});Testing components
Section titled “Testing components”Buttons, modals, and select menus are tested with their respective FakeInteraction builders:
describe('BanButton', () => { const bot = TestBot.create().discover(d => d.load('./src/**/*.ts'));
it('bans the user from the dynamic custom ID', async () => { const mockMember = { ban: vi.fn(), user: {username: 'troublemaker'}, };
const mockGuild = { members: {fetch: vi.fn().mockResolvedValue(mockMember)}, };
const interaction = FakeInteraction.button({ customId: 'ban/123456789012345678', guild: mockGuild, });
await bot.execute(interaction);
expect(mockMember.ban).toHaveBeenCalled(); expect(interaction.replied).toBe(true); expect(interaction.lastReply).toContain('banned'); });});Testing error handlers
Section titled “Testing error handlers”You can verify that your error handlers catch and respond correctly by triggering an error in a command:
describe('CommandErrorHandler', () => { const bot = TestBot.create().discover(d => d.load('./src/**/*.ts'));
it('replies with an error message when a command fails', async () => { // Register a case service that throws container.register(CaseService, { useValue: { createWarning: vi.fn().mockRejectedValue(new Error('Database offline')), }, });
const interaction = FakeInteraction.chatInput({ commandName: 'mod', subcommand: 'warn', options: {user: '123', reason: 'test'}, });
await bot.execute(interaction);
expect(interaction.replied).toBe(true); expect(interaction.lastReply).toContain('Something went wrong'); });
it('handles authorization errors gracefully', async () => { const interaction = FakeInteraction.chatInput( {commandName: 'ban', options: {user: '123', reason: 'test'}}, {permissions: []}, );
await bot.execute(interaction);
expect(interaction.replied).toBe(true); expect(interaction.ephemeral).toBe(true); expect(interaction.lastReply).toContain('permission'); });});Mocking services
Section titled “Mocking services”The DI container is your best friend when it comes to mocking. Since every dependency is injected, you can replace any service with a mock before running your test:
import {container} from 'tsyringe';
// Replace a singleton servicecontainer.register(CaseService, { useValue: { createWarning: vi.fn().mockResolvedValue(42), getWarningCount: vi.fn().mockResolvedValue(3), getStats: vi.fn().mockResolvedValue({warnings: 10, mutes: 5, bans: 2}), },});
// Replace Config with test valuescontainer.register(Config, { useValue: { get: vi.fn((key: string) => { const values: Record<string, unknown> = { 'moderation.maxWarnings': 3, 'moderation.logChannel': '999888777', 'moderation.muteRole': '111222333', }; return values[key]; }), },});A typical test helper might look like this:
export function mockCaseService(overrides = {}) { return { createWarning: vi.fn().mockResolvedValue(1), getWarningCount: vi.fn().mockResolvedValue(0), getStats: vi.fn().mockResolvedValue({warnings: 0, mutes: 0, bans: 0}), search: vi.fn().mockResolvedValue([]), ...overrides, };}// In your testcontainer.register(CaseService, {useValue: mockCaseService({ createWarning: vi.fn().mockResolvedValue(42),})});Testing console commands
Section titled “Testing console commands”Project-local @cli classes — the console commands you write in src/console/ — are tested with the same philosophy as slash-command handlers: a fake console captures output and scripts prompt answers, and the runConsoleCommand harness runs the class through its real lifecycle without touching stdin or stdout.
Both are exported from @warden/console/testing:
import {describe, it, expect} from 'vitest';import {FakeConsole, runConsoleCommand} from '@warden/console/testing';import SeedCommand from '../../src/console/SeedCommand';
describe('SeedCommand', () => { it('refuses to seed without confirmation', async () => { const console = new FakeConsole(); console.prompts.answer('Insert 3 rows into users?', false);
const code = await runConsoleCommand(SeedCommand, { args: {file: 'fixtures/users.json'}, console, });
expect(code).toBe(1); expect(console.output).toContain('Cancelled'); });
it('dry-run does not touch the database', async () => { const console = new FakeConsole();
const code = await runConsoleCommand(SeedCommand, { args: {file: 'fixtures/users.json'}, options: {'dry-run': true}, console, });
expect(code).toBe(0); expect(console.output).toContain('Dry-run'); });});FakeConsole
Section titled “FakeConsole”A drop-in replacement for the real Console that captures every write into output (a single string, ANSI-stripped) and lines (the same split by newline). Prompt answers are scripted up front with prompts.answer(question, value) and consumed in order — ask for a prompt that wasn’t scripted and the test fails with an explicit Unscripted prompt error instead of hanging waiting for stdin.
| Property | Description |
|---|---|
output | Raw captured text, all tags and ANSI stripped |
lines | output.split('\n') — convenient for toEqual on line-level assertions |
prompts.answer(q, v) | Script an answer by its prompt text |
prompts.pending() | Array of scripted answers that were never consumed (useful as a post-test assertion) |
runConsoleCommand
Section titled “runConsoleCommand”Builds a throwaway DI child-container, constructs the command, injects a FakeConsole, and returns the exit code the command produced. No argv parsing — you pass args and options as plain objects, which keeps tests focused on behaviour instead of string-based flag plumbing.
Signature:
runConsoleCommand<T extends ConsoleCommand>( Command: new (...args: any[]) => T, opts?: { args?: Record<string, unknown>; options?: Record<string, unknown>; console?: FakeConsole; },): Promise<number>;See Writing your own console commands for the authoring side of this.