Invoke and dispatch
Run one handler you name with invoke, or send an input through the bot's routing with dispatch.
You'll learn
- Run a handler through the pipeline with invoke
- Check which handler an interaction, message or reaction reaches with dispatch
- Read what the user was sent with getResponse
Before this
A testing module runs handlers in two ways. invoke runs the handler you name, through everything the bot runs
around it. dispatch hands the module an input, an interaction, a message or a reaction, and lets it find the
handlers the way the bot does.
Both run the whole pipeline, and both leave a record of what the user was sent.
When to use it
Use invoke for most handler tests: you know which handler you're testing, and you want its guards, validation and
answer checked. The method name and its arguments are type-checked against the handler.
Use dispatch when routing is the question: which handler a customId, a command or a message reaches, with which
params, and what the user sees when nothing matches. It also answers errors with the built-in fallback, as the bot
does, so it shows the member's view of a failure.
To check a route without running anything, resolveRoute is lighter. To test a service
on its own, build it with new.
Example
it('routes a click to the handler its customId matches, as the bot does', async () => {
const module = MeoCordTestingModule.create({ controllers: [CardButtonController] }).compile()
const click = createMockInteraction(ButtonInteraction, { customId: 'card/111111111111111111/like' })
const { ran, handlers } = await module.dispatch(click)
expect(ran).toBe(true)
expect(handlers).toEqual([{ controller: CardButtonController, method: 'like', ran: true }])
expect(getResponse(click).calls[0]).toMatchObject({ method: 'update', payload: { content: 'Liked.' } })
})The click's customId, card/111111111111111111/like, reaches CardButtonController.like through the module's
routing table. handlers lists every handler it ran, and getResponse shows the update
the handler sent.
How it works
Both run the handler through the pipeline: @Defer, guards, interceptors around
validation, pipes, cooldowns and the handler, all inside its exception filters. Guards and filters resolve from the
module, so overrideGuard stubs work, and so does an injected ExecutionContext.
They differ in where the handler comes from, and in what happens to an error:
invoke(Controller, 'method', ...args) | dispatch(input) | |
|---|---|---|
| Handler | the one you name | whichever the bot's routing reaches |
| Params | built from the input, as dispatch builds them | the same |
| Resolves to | { ran, error? } | { ran, handlers, error? } |
| An error no filter handles | rejects, and the fallback doesn't run | the fallback answers, then it rejects |
| The user's own outcome | a refusal rejects | answered, and resolves with error |
The user's own outcome is a usage reply, an unknown command, or the refusal of a guard, a cooldown or the cooldown
store, a validation or a UserError. Both wait for the module's observers before they resolve.
Running a handler with invoke
Pass the arguments dispatch would: the interaction, message or reaction, then the handler's params. Passed alone, an
interaction gets its params built as dispatch builds them: a command's or an autocomplete's options, or a component's
customId params with a modal's fields or a select menu's choices. A message passed alone gets what its
@MessageHandler pattern captures after the app's prefix, the handler's own prefix, or a mention, typed as dispatch
types it; a word that isn't of its type, or a missing param, goes through the handler's filters as a
MessageUsageError.
invoke resolves to { ran }. ran is false when a guard stopped the call or an interceptor skipped the handler,
and error is set when a filter handled one. A guard that returns false stops the call with no answer:
it('runs in an allowed channel, and a guard that returns false stops it silently', async () => {
const allowed = createMockInteraction(ChatInputCommandInteraction, { channelId: '111111111111111111' })
const elsewhere = createMockInteraction(ChatInputCommandInteraction, { channelId: '222222222222222222' })
await expect(module.invoke(ModerationSlashController, 'trade', allowed)).resolves.toEqual({ ran: true })
await expect(module.invoke(ModerationSlashController, 'trade', elsewhere)).resolves.toEqual({ ran: false })
expect(getResponse(elsewhere).sent).toBe(false)
})A guard that throws GuardDeniedError, a handler that throws UserError, and a cooldown's CooldownError reject
invoke with that error, where dispatch answers the user and resolves with it as error. Assert them with
await expect(module.invoke(...)).rejects.toThrow(GuardDeniedError).
The interaction must be one dispatch routes to the handler, ranking every handler of the module as the bot does. A
customId another handler's pattern takes first, or a command another handler takes by its subcommand path, rejects,
naming the handler that runs, and so does one no pattern takes, or a command the handler doesn't handle, so a typo in a
test doesn't pass silently. A handler declared under two patterns gets the params of the one dispatch picks. A mock
built without a customId or command name isn't checked.
Sending input with dispatch
dispatch routes over the module's controllers, with its app's message options, exactly as the bot does. A message
reaches its command after the app's prefix:
it('reads a message after the app’s prefix, and runs the command it names', async () => {
const module = MeoCordTestingModule.create({ app: App, controllers: [DiceMessageController] }).compile()
const message = createMockMessage({ content: '!roll 20 for initiative' })
const { handlers } = await module.dispatch(message)
expect(handlers.map(({ method }) => method)).toEqual(['roll'])
expect(message.reply).toHaveBeenCalledWith(expect.stringMatching(/^\d+ \(for initiative\)$/))
})A reaction takes the user who reacted and, optionally, the action; it's an add unless you say otherwise:
it('delivers a reaction with its user and action', async () => {
const module = MeoCordTestingModule.create({ controllers: [StarReactionController] }).compile()
const message = createMockMessage()
const reaction = createMockInteraction(MessageReaction, { message, emoji: { name: '⭐' } as never })
const user = createMockInteraction(User, { username: 'mika', bot: false })
await module.dispatch(reaction, { user, action: ReactionHandlerAction.ADD })
expect(message.reply).toHaveBeenCalledWith('mika starred this.')
})handlers lists each handler reached, in the order it ran, with its own ran and error. A message can reach a
patterned handler and every @MessageHandler() listener at once, and a reaction several handlers.
When nothing matches
An input no route takes is answered as the bot answers it: "Command not found!" for an interaction, and the call
resolves with that error; an empty list for an autocomplete, and the call resolves with no error. Either way, handlers
is empty:
it('answers a click no route takes as the bot does, and reports why', async () => {
const module = MeoCordTestingModule.create({ controllers: [CardButtonController] }).compile()
const click = createMockInteraction(ButtonInteraction, { customId: 'card/111111111111111111/share' })
const { ran, handlers, error } = await module.dispatch(click)
expect([ran, handlers]).toEqual([false, []])
expect(error).toBeInstanceOf(CommandNotFoundError)
expect(getResponse(click).sent).toBe(true)
})Whatever the bot skips reaches nothing: a message from a bot, or a bot's reaction to a handler without
bots: true, resolves to { ran: false, handlers: [] }.
A button, select menu or modal submission no route takes may belong to a collector. When the interaction's client has
another interactionCreate listener, dispatch waits the same 1.5 seconds the bot does before answering "not
found", and says nothing if the listener answered first. A mock's own client has no listeners, so the answer is
immediate. See Components.
Reading what was sent
getResponse(interaction) reports every answer a mock interaction got, whether the
handler made it through respond() or with discord.js directly, such as interaction.reply() or
interaction.followUp():
state: where the answer stands,'unanswered','deferred'or'replied';sent: whether a reply, an update, an edit or a follow-up went out, counting only the calls Discord accepted;calls: each answer, once, in the order made, with what it sent, and theerrorof one Discord refused.
A call's payload is typed by its method: once a test checks call.method === 'reply', the payload is what
reply() takes: a string, a MessagePayload or its options. Narrow it to the options before reading content or
embeds: after typeof call.payload === 'object' && !(call.payload instanceof MessagePayload), with MessagePayload
from discord.js, call.payload.content compiles with no cast.
A call a mock rejects, such as a reply refused with 10062 once the three seconds have passed, stays in calls with its
error, and doesn't count as sent: the member saw nothing.
For messages and reactions, read the mock's own methods, such as message.reply.
Events and handler setup
To send a gateway event to the module's @On and @Once handlers, use module.emit(event, ...args). It resolves to
{ ran }, how many handlers ran, and once every handler has settled, rejects if any threw: with that error, or an
AggregateError holding each in its errors.
To check what a handler is set up with, without running it, use inspectHandler. It
lists the guards, interceptors, filters and cooldowns dispatch applies, in order, and reads the handler's metadata
as ExecutionContext does. Testing recipes has an example.
Gotchas
invokerefuses an input dispatch gives to another handler. Besideroll {sides}, a handler forroll 20wins the message!roll 20, and besidecard/{id}, a handler forcard/summarywins that click. Invoking the first of each pair with it rejects, naming the handler that runs. Invoke the handler dispatch picks, or dispatch the input.- An unhandled error rejects both. With
dispatch, the member still got the fallback's answer first. Assert the rejection withawait expect(...).rejects.toThrow(...), and readgetResponseafter it. - A collector's click needs the bot's client. A click built with a fresh mock client isn't the client the call
came to. Build it with
{ client: interaction.client }, as Mocks shows.
Build it
Each part of the feedback bot has a spec. Add one that runs the whole flow, from the form to the author's DM, through the same pipeline the bot uses. Ada submits in Indonesian, Grace approves, and the test checks what each of them sees:
describe('the feedback bot, from form to verdict', () => {
const STAFF = '900'
const module = MeoCordTestingModule.create({
controllers: [FeedbackController, ReviewController],
providers: [{ provide: FeedbackSettings, useValue: { reviewChannelId: '500', staffRoleId: STAFF } }],
// The app's presenter styles what respond() shows, as on the running bot
app: App,
}).compile()
// One client and one review channel, shared by every interaction, as on a running bot
const channel = createMockChannel(TextChannel)
channel.isSendable.mockReturnValue(true)
const client = createMockClient()
client.channels.fetch.mockResolvedValue(channel as never)
const inServer = { guildId: '1', guild: createMockGuild(), client: client as never }
it('carries feedback from the form to the staff, and the verdict back to its author', async () => {
// Ada writes in Indonesian
const submit = createMockInteraction(ModalSubmitInteraction, {
...inServer,
customId: 'feedback/submit',
locale: Locale.Indonesian,
user: createMockInteraction(User, { id: '111', username: 'ada' }),
fields: createModalFields({ about: 'Music bot', details: 'It skips songs.' }),
})
await module.invoke(FeedbackController, 'submit', submit)
expect(getResponse(submit).calls[0].payload).toMatchObject({
content: 'Terima kasih! Staf akan segera membacanya.',
})
// The review post, as the staff channel received it; its first button routes to approve
const { embeds, components } = channel.send.mock.calls[0][0] as MockMessageOverrides
const post = createMockMessage({ embeds, components })
const customId = JSON.parse(JSON.stringify(components))[0].components[0].custom_id as string
expect(resolveRoute(App, { type: CommandType.BUTTON, customId })).toMatchObject({
handler: ReviewController.prototype.approve,
params: { id: '1' },
})
// Grace, on the staff, approves it; they read Discord in Indonesian too
const staffMember = createMockInteraction(GuildMember, {
roles: { cache: new Collection([[STAFF, { id: STAFF }]]) } as never,
})
const approve = createMockInteraction(ButtonInteraction, {
...inServer,
customId,
locale: Locale.Indonesian,
member: staffMember,
message: post,
user: createMockInteraction(User, { id: '222', username: 'grace' }),
})
await module.invoke(ReviewController, 'approve', approve)
// While it worked, the post showed the presenter's loading view in their language; then the
// verdict, in the server's
const { calls } = getResponse(approve)
expect(JSON.stringify(calls[1].payload)).toContain('Sedang diproses…')
expect(JSON.parse(JSON.stringify(calls.at(-1)?.payload)).embeds[0]).toMatchObject({
title: 'Feedback #1 from ada',
footer: { text: 'Approved by grace.' },
})
expect(client.users.send).toHaveBeenCalledWith('111', { content: 'Masukanmu “Music bot” disetujui. Terima kasih!' })
})
})The review post Grace clicks is built from what the bot actually sent. resolveRoute(App, ...) confirms the
button's customId reaches approve, and app: App applies the app's presenter, so the loading view is the real one,
in Grace's language. Run npm test to see the flow pass.
Next steps
- Mocks: building the interactions, messages and reactions these take.
- Testing recipes: guards, cooldowns, themes and collectors.
- How a call runs: the pipeline both of them run.