The testing module
Build your controllers and services in a test, with no Discord connection, and run them as the bot does.
You'll learn
- Build a testing module from the classes a test needs
- Swap a dependency, a guard or an interceptor for a stand-in
- Run lifecycle hooks and keep tests apart
Before this
meocord/testing runs your controllers, services, guards and everything around them inside a test, with no
Discord connection and no token. MeoCordTestingModule builds a container from the classes you list, as the bot
builds one from @MeoCord, and the module it compiles runs handlers through the same pipeline the bot uses.
The mocks it comes with behave like discord.js, and work under Vitest, Jest, Node's test runner and bun test:
- Vitest runs a generated app's tests as they come.
- Jest needs Node's VM modules: meocord's CommonJS build loads packages that ship only as ES modules, which Jest
reads only with them. Run it as
NODE_OPTIONS=--experimental-vm-modules jest. - Node's test runner asserts through a mock's
.mock.calls. - bun test asserts through
.mock.callstoo: Bun's matchers, such astoHaveBeenCalledWith, accept only Bun's own mocks.
Whatever the runner, the tests need decorator metadata compiled in, which MeoCord reads to inject a class's
dependencies. Under Jest and Node's test runner, compile them with SWC or tsc with decorator metadata, as a generated
app's Vitest config does with SWC; node --test on .ts files strips the types without transforming decorators, so a
decorated class fails to load. bun test reads emitDecoratorMetadata from tsconfig.json.
When to use it
Use a testing module whenever the code under test is a controller, or anything MeoCord resolves for you: a service with injected dependencies, a guard, an interceptor, a presenter. It is how you check what a member sees.
A service that takes plain values needs no module: build it with new and test it as a class, as
Testing a service shows. To check which handler a customId or a message reaches
without running it, use resolveRoute instead.
Example
import { ChatInputCommandInteraction } from 'discord.js'
import { createChatInputOptions, createMockInteraction, getResponse, MeoCordTestingModule } from 'meocord/testing'
import { describe, expect, it } from 'vitest'
import { GreetingSlashController } from '@src/controllers/slash/greeting.slash.controller'
describe('GreetingSlashController', () => {
const module = MeoCordTestingModule.create({ controllers: [GreetingSlashController] }).compile()
it('greets by name', async () => {
const interaction = createMockInteraction(ChatInputCommandInteraction)
interaction.options = createChatInputOptions({ name: 'Ada' })
await module.invoke(GreetingSlashController, 'greet', interaction)
expect(getResponse(interaction).calls).toEqual([
{ method: 'reply', payload: expect.objectContaining({ content: 'Hello, Ada!' }) },
])
})
})The module is built from one controller and whatever it injects. invoke runs the greet handler with a mock
interaction whose options say Ada, and getResponse reports what the handler sent.
How it works
MeoCordTestingModule.create takes the controllers, providers and observers a
test needs, and returns a builder. compile() binds them into a fresh container and returns the module:
- Only what you list is built, plus what those classes inject. Nothing else from the app is loaded, so a test never starts a service it didn't ask for. To build the whole app instead, see Testing the whole app.
- Each
compile()is a new module, with its own services, its own cooldown counts and its own theme cache. - Handlers run through the pipeline:
@Defer, guards, interceptors, validation, pipes, cooldowns and exception filters, in the bot's order. Invoke and dispatch covers the two ways to run one.
Pass the app class as app to add what @MeoCord declares: its global guards, interceptors and filters, its
presenter, translator, message options, observers and theme, and its cooldown store and policy. The controllers are
still the ones you list. A store that injects something, such as a database pool, needs it in providers, or a
CooldownStore provider of your own, such as a MemoryCooldownStore, in the store's place.
it('runs the app’s global guards first, with app', async () => {
const module = MeoCordTestingModule.create({ app: App, controllers: [GreetingSlashController] }).compile()
await expect(module.invoke(GreetingSlashController, 'greet', greet('666666666666666666'))).resolves.toEqual({
ran: false,
})
await expect(module.invoke(GreetingSlashController, 'greet', greet('1'))).resolves.toEqual({ ran: true })
expect(inspectHandler(GreetingSlashController, 'greet', { app: App }).guards).toEqual([BlocklistGuard])
})Swapping a dependency
overrideProvider(Class).useValue(stub) replaces a dependency with a stand-in that has only the members the test
uses. A misspelled member is a compile error, so the stand-in can't drift from the class:
it('swaps a dependency for a stand-in', async () => {
const module = MeoCordTestingModule.create({ controllers: [GreetingSlashController] })
.overrideProvider(GreetingService)
.useValue({ buildGreeting: name => `Hi ${name}` })
.compile()
const interaction = greet('1')
await module.invoke(GreetingSlashController, 'greet', interaction)
expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: 'Hi Ada' })
})A provider can also be listed in any shape the app takes, useValue, useClass or useFactory, under a class or a
token; see Providers. overrideGuard, overrideInterceptor and overrideFilter swap
the stages around a handler the same way, wherever they apply: globally, on the controller or on the method.
module.get(Class) returns an instance, for a direct test of a service as the container built it.
Testing the whole app
MeoCordTestingModule.fromApp(App) builds the module as the bot builds itself:
every controller, service and provider @MeoCord lists, with what the app option takes from it: its global
guards, interceptors and filters, presenter, translator, message options, theme, observers and cooldown store.
A test lists nothing again, and replaces what it must by token in providers, before anything is made:
// A pool in memory, answering the two queries the store makes
function memoryPool() {
const rows: { id: number; user_id: string; text: string }[] = []
return {
query: vi.fn(async (sql: string, [userId, text]: string[] = []) => {
if (sql.startsWith('INSERT')) rows.push({ id: rows.length + 1, user_id: userId, text })
return { rows: rows.filter(row => row.user_id === userId).map(({ id, text }) => ({ id, text })) }
}),
}
}
describe('the notes app', () => {
it('saves and lists a note through its own wiring, with the database in memory', async () => {
// Every controller, service and provider comes from @MeoCord; the database factory never runs
const module = await MeoCordTestingModule.fromApp(App, {
providers: [{ provide: DATABASE, useValue: memoryPool() }],
})
.compile()
.init()
const user = createMockInteraction(User, { id: '111' })
const note = createMockInteraction(ChatInputCommandInteraction, {
commandName: 'note',
user,
options: createChatInputOptions({ text: 'Water the plants' }),
})
const notes = createMockInteraction(ChatInputCommandInteraction, { commandName: 'notes', user })
await module.dispatch(note)
await module.dispatch(notes)
expect(getResponse(notes).calls.at(-1)?.payload).toMatchObject({ content: '1. Water the plants' })
await module.close()
})
})compile() runs no factory. init() runs each one the test didn't replace and makes the app's services, as the
bot does before it logs in; the database factory replaced here never runs, so nothing connects. fromApp's
controllers and observers options add a test's own, and the override* methods still apply.
The module makes no Discord Client: a class that injects one, a guard, interceptor or filter included, is refused
where it is resolved, naming each class of the module that injects it, until the test provides one, such as { provide:
Client, useValue: createMockClient() }.
Lifecycle hooks in a test
init() resolves the module's factory providers, including the ones that return a promise, and runs no hook.
init({ ready: true }) also runs every onReady hook once, as the bot does when it comes online. close() runs the
onShutdown hooks of everything the module built:
it('runs onReady once the module is ready, and onShutdown on close', async () => {
const client = createMockClient()
const module = await MeoCordTestingModule.create({
providers: [{ provide: ReminderScheduler, useClass: ReminderScheduler }],
})
.compile()
.init({ ready: { client } })
module.get(ReminderScheduler).add('111111111111111111', 'Water the plants')
await vi.advanceTimersByTimeAsync(60_000)
await module.close()
await vi.advanceTimersByTimeAsync(60_000)
expect(client.users.send).toHaveBeenCalledTimes(1)
})- Order. The hooks run as the bot runs them:
onReadyone at a time, each class after the classes it injects;onShutdownin reverse, so a pool closes after everything that uses it. - The client.
onReadyreceives a mock client and{ primary: true }, unless you passinit({ ready: { client, primary } }). - Failures. Every hook runs even when one throws, and
initorclosethen rejects with that error, or with anAggregateErrornaming each hook that threw. - The cooldown store. The app's store, or the
CooldownStorea test provides in its place, gets itsonReadyfirst and itsonShutdownlast, once the store operations its calls started have settled. - A shutdown that hangs.
close()waits for the whole sequence for up toshutdownTimeout, 10 seconds unlesscreate()orfromApp()sets it, as the bot'sshutdownTimeoutdoes. It then stops waiting and logs that it did, and still rejects with a hook that failed before then. A test whose fake store never answers, or whoseonShutdownnever settles, sets it short, such asshutdownTimeout: 50. - The theme outside calls. Once ready, the module's app theme is the one
useTheme()reads outside any call, untilclose(), unless another module or app in the same process was ready first, which keeps it. See Testing recipes.
Running tests
A generated app has Vitest set up: vitest.config.ts compiles with SWC, which gives the decorator metadata
injection needs, and every generated component has a spec beside it. A generated controller's spec invokes its handler
through the testing module and checks the answer, so it fails when the handler stops answering.
npm test # once
npm run test:watch # on every change
npm run test:coverage # with a coverage reportvitest.setup.ts runs before every spec file. It has MeoCord's mocks made with vi.fn, so Vitest treats them as its
own, and strict, so they compute what discord.js computes, and resets them after every test:
import 'reflect-metadata'
import { resetAllMocks, useMockFn, useStrictMocks } from 'meocord/testing'
import { afterEach, vi } from 'vitest'
// meocord/testing makes its mocks with vi.fn, so Vitest's matchers, config and vi.mocked() treat them as its own
useMockFn(vi.fn)
// Mocks compute what discord.js computes, such as a message's editable or a member's kickable, rather than placeholders
useStrictMocks()
// Every test starts from meocord's mocks as they were created
afterEach(() => resetAllMocks())Tests don't load .env, so a real token never reaches a spec unless you ask for it, and tests run the same before
and after a build. A project whose tests need its variables loads the files meocord.config.ts reads under test, in
vitest.setup.ts: config({ path: ['.env.test.local', '.env.test', '.env'], quiet: true }), with config from
dotenv.
What tests can't tell you
The mocks behave like discord.js, but they aren't Discord. These fail only against the real thing:
- Discord's limits: 2,000 characters in a message, 25 choices, 5 buttons in a row, 45 characters in a modal title, and the rest of Discord's validation.
- Permissions and role order: a bot whose role sits below a member's can't time them out, whatever the code says.
- Intents: an event the bot never receives because its intent is missing.
- Startup: a missing or invalid token, a config that doesn't load, a native addon built for another platform.
Before a release, start the bot against a test server with npx meocord start --dev, and use each changed command
once. Start it with a missing and a wrong token too, and check that it says so and exits.
In CI
The checks a generated project runs locally are the ones to run in CI. Tests need no token and no network, so CI needs no secrets:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run test:coverage
- run: npx meocord build --prodGotchas
- State set once for a whole
describeis gone after the first test.vitest.setup.tsresets every MeoCord mock after each test, throughresetAllMocks(); avi.fn()of your own only has its calls cleared. Set what a mock returns in the test that relies on it, or inbeforeEach. - A module shared across tests shares its state. Cooldown counts and service fields carry over. Build one module
per test, or per
describewhen the tests change nothing in it. - Calling a controller method directly skips the pipeline.
module.get(Controller).method(interaction)runs its own guards, but no interceptors, validation or filters. Useinvoketo test what dispatch runs around a handler.
Build it
The feedback bot opens a form with /feedback and posts what members send for staff to review. Give its controller a
spec. The settings the bot reads from the environment become test values, and each test runs a handler as dispatch
would:
describe('FeedbackController', () => {
const settings = { reviewChannelId: '500', staffRoleId: '900' }
const compile = () =>
MeoCordTestingModule.create({
controllers: [FeedbackController],
// The real settings read the environment; the test says where the review channel is
providers: [{ provide: FeedbackSettings, useValue: settings }],
}).compile()
const ada = createMockInteraction(User, { id: '111', username: 'ada' })
it('opens the form in the member’s language', async () => {
const interaction = createMockInteraction(ChatInputCommandInteraction, { user: ada, locale: Locale.Indonesian })
await compile().invoke(FeedbackController, 'open', interaction)
const [call] = getResponse(interaction).calls
expect(call.method).toBe('showModal')
expect(JSON.parse(JSON.stringify(call.payload))).toMatchObject({
custom_id: 'feedback/submit',
title: 'Kirim masukan',
})
})
it('opens the form once every five minutes for each member', async () => {
const module = compile()
const open = () => createMockInteraction(ChatInputCommandInteraction, { user: ada })
await module.invoke(FeedbackController, 'open', open())
await expect(module.invoke(FeedbackController, 'open', open())).rejects.toBeInstanceOf(CooldownError)
})
it('posts the feedback for review with its buttons, and thanks the author privately', async () => {
const channel = createMockChannel(TextChannel)
channel.isSendable.mockReturnValue(true)
const client = createMockClient()
client.channels.fetch.mockResolvedValue(channel as never)
const interaction = createMockInteraction(ModalSubmitInteraction, {
customId: 'feedback/submit',
user: ada,
client: client as never,
fields: createModalFields({ about: 'Music bot', details: 'It skips songs.' }),
})
await compile().invoke(FeedbackController, 'submit', interaction)
expect(client.channels.fetch).toHaveBeenCalledWith('500')
const post = JSON.parse(JSON.stringify(channel.send.mock.calls[0][0]))
expect(post.embeds[0]).toMatchObject({
title: 'Feedback #1 from ada',
description: '**Music bot**\nIt skips songs.',
})
expect(post.components[0].components.map((button: { custom_id: string }) => button.custom_id)).toEqual([
'feedback/1/approve',
'feedback/1/reject',
])
expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: 'Thanks! The staff will read it soon.' })
expect(interaction.ephemeral).toBe(true)
})
})Run npm test. The form opens in Ada's language and refuses a second try within five minutes, and their submission
reaches the review channel with its buttons.
Next steps
- Invoke and dispatch: the two ways to run a handler, and which to use when.
- Mocks: the interactions, messages, users and errors a test passes in.
- Testing recipes: themes, guards, cooldowns and collectors under test.
- Services: designing services a test can build with
new.