Skip to content

Testing

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:

Terminal window
pnpm test

If you set up your project manually, follow these steps:

  1. Install Vitest as a dev dependency:

    Terminal window
    pnpm add -D vitest
  2. Add a test script to your package.json:

    {
    "scripts": {
    "test": "vitest"
    }
    }
  3. Create a vitest.config.ts:

    import {defineConfig} from 'vitest/config';
    export default defineConfig({
    test: {
    globals: true,
    environment: 'node',
    setupFiles: ['./tests/setup.ts'],
    },
    });
  4. Create the setup file to load reflect-metadata (required for DI):

    tests/setup.ts
    import 'reflect-metadata';

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.

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.

You can scope TestBot to a subset of your handlers for focused tests:

// Only load moderation commands
const bot = TestBot.create().discover(d => d.load('./src/commands/mod/**/*.ts'));

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.

import {FakeInteraction} from '@warden/core/testing';
const interaction = FakeInteraction.chatInput({
commandName: 'warn',
options: {
user: '123456789012345678',
reason: 'Spamming in #general',
severity: 'high',
},
});
const interaction = FakeInteraction.chatInput({
commandName: 'mod',
subcommand: 'ban',
options: {
user: '123456789012345678',
reason: 'Repeated violations',
},
});
const interaction = FakeInteraction.button({
customId: 'ban/123456789012345678',
});
const interaction = FakeInteraction.modal({
customId: 'warn-reason/123456789012345678',
fields: {
reason: 'Spamming invite links',
severity: 'medium',
},
});
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'},
);

Every FakeInteraction tracks what happened to it during execution. You can assert against these properties directly:

PropertyTypeDescription
repliedbooleanWhether reply() or followUp() was called
lastReplystring | EmbedBuilderThe content of the most recent reply
deferredbooleanWhether deferReply() was called
ephemeralbooleanWhether the reply was ephemeral
repliesArrayAll 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');

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');
});
});

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();
});
});

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');
});
});

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');
});
});

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 service
container.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 values
container.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:

tests/helpers.ts
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 test
container.register(CaseService, {useValue: mockCaseService({
createWarning: vi.fn().mockResolvedValue(42),
})});

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');
});
});

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.

PropertyDescription
outputRaw captured text, all tags and ANSI stripped
linesoutput.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)

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.