Skip to content
GitHub

TestingModule

class in meocord/testing Since 4.0.0

class TestingModule

A compiled testing module, which runs handlers as the bot does and resolves the classes it built.

Use invoke to run a handler you name, dispatch to send an input through the bot's routing, and emit for a gateway event. getResponse then reports what a handler sent.

Examples

TypeScript
import { expect } from 'vitest'

@Controller()
class TicketController {
  @Command('ticket/{id}/close', CommandType.BUTTON)
  async close(interaction: ButtonInteraction, { id }: { id: string }) {
    await respond(interaction).send(`Ticket ${id} closed.`)
  }
}
const module = MeoCordTestingModule.create({ controllers: [TicketController] }).compile()
const { handlers } = await module.dispatch(createMockInteraction(ButtonInteraction, { customId: 'ticket/7/close' }))
expect(handlers).toEqual([{ controller: TicketController, method: 'close', ran: true }])

Members

constructor

new TestingModule(
  container: Container,
  controllers?: readonly (new (...args: any[]) => unknown)[],
  eventClasses?: readonly (new (...args: any[]) => unknown)[],
  providers?: ProviderMap,
  order?: readonly unknown[],
  messageOptions?: MessageCommandOptions,
  lifecycle?: readonly LifecycleUnit[],
  constructed?: ReadonlySet<unknown>,
  appWarnUnanswered?: boolean | undefined,
  services?: readonly (new (...args: any[]) => unknown)[],
  shutdownTimeout?: number,
)

Parameters

NameTypeDefaultSinceDescription
containerContainer
controllers?readonly (new (...args: any[]) => unknown)[]4.1.0
eventClasses?readonly (new (...args: any[]) => unknown)[]4.1.0
providers?ProviderMap4.1.0
order?readonly unknown[]4.1.0
messageOptions?MessageCommandOptions4.1.0
messageOptions.prefix?| MessagePrefix | (( message: Message, ) => | MessagePrefix | null | undefined | Promise< MessagePrefix | null | undefined >)4.1.0

What a message starts with to reach a patterned handler: a prefix, a list of them, or a function of the message returning them, such as a server's own prefix. Without one, a pattern matches the message as it is. Where a function finds none, returning an empty list, undefined or null, no prefix starts a command for that message, though a mention still does when mention is on; only '' takes it as it is. A handler's own prefix replaces it.

messageOptions.mention?boolean | 'only'false4.1.0

Also accepts a mention of the bot, <@id> or <@!id>, where a prefix goes. 'only' accepts nothing else in a server, neither a prefix nor the message as it is, so a server's messages reach commands only when they mention the bot, which Discord delivers with their text even without the privileged MessageContent intent. A direct message, addressed to the bot already, starts as usual: after the prefix, or as it is without one.

messageOptions.caseSensitive?booleanfalse4.1.0

Matches the prefix, a pattern's literal words, its choice words and its flag names in the case written. A text param keeps the case the user typed either way.

messageOptions.types?Record<string, MessageParamType>4.1.0

Param types of the app's own, used in patterns as {name:type} by their key here. Declare each in MessageParamTypes as well, so a handler's params are typed from its pattern.

messageOptions.deleteUsageRepliesAfter?number104.1.0

How long a reply showing a command's usage, or a guard's or validation's reason, stays before it is deleted, in seconds. 0 keeps it.

messageOptions.replyEmoji?booleanfalse4.1.0

Begins every text reply MeoCord sends to a message with the theme's emojis.warning: a command's usage, a guard's or validation's reason, a UserError's message, an @On listener's of a message event included, and the direct messages dmOnError and dmOnCooldown send. The built-in help's reply begins with emojis.info instead. The emoji is the call's resolved theme's, so it follows @UseTheme and themeFor.

messageOptions.dmOnError?booleanfalse4.1.0

Tells the author of a message command, in a direct message, when it fails with an error no filter handled, naming the command, the channel and the server. The error is logged as without it, and nothing is said in the channel: a message cannot be answered privately there. A command sent in a direct message is answered in it. The text is meocord.dm.error, around what the fallback answers the error with, such as the cooldown store's meocord.cooldown.storeDown, in the server's language. A member whose direct messages are closed is not told. Only patterned handlers are answered, not a listener for every message. A command refused because the cooldown store failed is told once per outage per author.

messageOptions.dmOnCooldown?booleanfalse4.1.0

Tells the author of a message command, in a direct message, when a @Cooldown refuses it, with how long to wait, once per wait: retrying before it ends sends nothing more. The notice is counted in the app's cooldown store, so it holds across shards with a shared store. The text is meocord.dm.cooldown, around the cooldown's own wait text, in the server's language. A command sent in a direct message is answered in it, and a member whose direct messages are closed is not told. Only patterned handlers are answered.

messageOptions.help?boolean | MessageHelpOptionsfalse4.1.0

Answers !help with the message commands that work here, leaving out hidden ones and those with guards of their own, since help runs only the app's guards, and !help <command> with the one it names, listed or not, from the description each handler gives. true uses the word help; { command, aliases } names other words. It answers only after a prefix or a mention, once the app's @MeoCord({ guards }) allow it, and an app's own handler for the word runs instead. The reply's text comes from the presenter's messageHelp when it has one.

messageOptions.handlers?'sequential' | 'concurrent''sequential'`; the next major version (5.0) may run them concurrently by default4.2.0

How a message's handlers run: the patterned handler it matched, then every @MessageHandler() listener. 'sequential' runs each once the one before it has settled, so a slow command delays the listeners after it. 'concurrent' starts them together and settles once all have: each keeps its own guards, interceptors, filters and observers, and no order holds between them, so a listener can run before the command has written what it reads. The built-in help and a command's usage are answered first either way.

messageOptions.slowHandlerWarning?booleantrue` in a running bot, `false` in a `MeoCordTestingModule4.2.0

Warns, once per handler, when a message's handler takes 5 seconds or more with listeners waiting after it, under handlers: 'sequential', naming the handler and how many it held back. A testing module warns only when this is true, since a test's fake clock can pass 5 seconds inside a handler.

lifecycle?readonly LifecycleUnit[]4.1.0
constructed?ReadonlySet<unknown>4.1.0

The lifecycle units the container has constructed, which close() shuts down.

appWarnUnanswered?boolean | undefined4.1.0

The app's warnUnanswered, which dispatch follows as the bot does.

services?readonly (new (...args: any[]) => unknown)[]4.1.0

The app's listed services, made at init() as the bot makes them before it logs in.

shutdownTimeout?number4.1.0

How long close() waits for the onShutdown hooks, as the bot's shutdownTimeout does.

themeCache Since 4.1.0

get themeCache(): ThemeCache

The module's ThemeCache: the instance its classes inject, holding what themeFor, or overrideThemeFor, looked up in this module's calls. Clear a result to have the next call look it up again. Each module has its own.

close Since 4.1.0

close(): Promise<void>

Runs the onShutdown hooks of every class and provided value the module has constructed, once, as the bot does when it stops: one at a time, in reverse, so a class stops before the classes and providers it uses. A factory's value, such as a connection pool init() made, is closed after everything that injects it, whether or not init({ ready: true }) ran. Nothing is constructed just to be shut down. Every hook runs even when one fails. Calling it again does nothing more.

It first gives up the theme read outside calls, if init({ ready: true }) made it this module's; reads outside calls then return MeoCord's defaults until another module or app is ready.

The cooldown store and what it injects shut down last, in the same sequence as the bot's. When any of them has an onShutdown, the calls invoke, dispatch and emit have under way finish first, then the store operations they started, so the store's last writes still reach it. The module stops waiting for the whole sequence after its shutdownTimeout, 10 seconds unless set, and logs that it did. Give a test whose fake store never answers, or whose onShutdown never settles, a short shutdownTimeout.

Returns

Promise<void>

Once every hook has run. Rejects with the error of a hook that failed, or an AggregateError naming each when several did.

Examples

TypeScript
const module = await MeoCordTestingModule.create({ providers: [{ provide: POOL, useFactory: createPool }] })
  .compile()
  .init()

await module.close()

expect(module.get(POOL).ended).toBe(true)

dispatch Since 4.1.0

dispatch(input: Interaction | Message): Promise<DispatchedCall>

dispatch(
  reaction: MessageReaction | PartialMessageReaction,
  options: { user: User | PartialUser; action?: ReactionHandlerAction },
): Promise<DispatchedCall>

Sends an interaction, a message or a reaction through the bot's own dispatch: routed over the module's controllers and its app's message options exactly as the bot routes it, then run through the full pipeline of each handler it reaches. What the user is sent is sent to the mock, as the bot sends it: the handler's answer, a usage reply, or the built-in fallback's answer to an error no filter handles. Inputs the bot skips, such as a message from a bot, reach nothing. The module waits for its observers.

dispatch tests what the bot does with an input: which handler it reaches, with what params, and what the user sees. To test one handler you name, whatever would route to it, use invoke.

Parameters

NameTypeDescription
inputInteraction | Message

An interaction or a message; or a reaction, with the user who reacted and whether they added it, ReactionHandlerAction.ADD unless given.

Returns

Promise<DispatchedCall>

Every handler reached, in the order it ran, whether any ran, and the first error a call ended with. An error the fallback answers as the user's own outcome resolves: a usage reply, an unknown command, or a guard's, a cooldown's, a validation's or a UserError's refusal, as the fallback answers each for an interaction or a message. Any other error no filter handles rejects the call once the fallback has answered and every handler has run: with that error, or an AggregateError when several were left unhandled.

Examples

TypeScript
const module = MeoCordTestingModule.create({ app: App, controllers: [CardController] }).compile()
const interaction = createMockInteraction(ButtonInteraction, { customId: 'card/summary/7' })

const { handlers } = await module.dispatch(interaction)

expect(handlers).toEqual([{ controller: CardController, method: 'summary', ran: true }])

Parameters

NameTypeDescription
reactionMessageReaction | PartialMessageReaction
options{ user: User | PartialUser action?: ReactionHandlerAction }
options.userUser | PartialUser
options.action?ReactionHandlerAction

Returns

Promise<DispatchedCall>

Every handler reached, in the order it ran, whether any ran, and the first error a call ended with. An error the fallback answers as the user's own outcome resolves: a usage reply, an unknown command, or a guard's, a cooldown's, a validation's or a UserError's refusal, as the fallback answers each for an interaction or a message. Any other error no filter handles rejects the call once the fallback has answered and every handler has run: with that error, or an AggregateError when several were left unhandled.

Examples

TypeScript
const module = MeoCordTestingModule.create({ app: App, controllers: [CardController] }).compile()
const interaction = createMockInteraction(ButtonInteraction, { customId: 'card/summary/7' })

const { handlers } = await module.dispatch(interaction)

expect(handlers).toEqual([{ controller: CardController, method: 'summary', ran: true }])

emit Since 4.1.0

emit<E extends keyof ClientEvents>(
  event: E,
  ...args: ClientEvents[E]
): Promise<EmitResult>

Emits a client event to the module's @On and @Once handlers, through the same pipeline the app runs them in: the global guards of the module's app, then each handler's own. Handlers on the module's controllers, class providers and their dependencies all receive it. A @Once handler handles only the first event, as it would on a client.

Parameters

NameTypeDescription
eventE

The client event, such as 'guildMemberAdd'.

...argsClientEvents[E]

The event's arguments, typed from discord.js's ClientEvents.

Returns

Promise<EmitResult>

How many handlers ran. Rejects once every handler has settled if any threw: with that error when one handler failed, or an AggregateError of them when several did.

Examples

TypeScript
const module = MeoCordTestingModule.create({ controllers: [WelcomeController] }).compile()
const member = createMock<GuildMember>()

const { ran } = await module.emit('guildMemberAdd', member)

expect(ran).toBe(1)

get

get<T>(token: ProviderToken<T> | ServiceIdentifier<T>): T

Resolves an instance from the module, as the bot would inject it.

Parameters

NameTypeDescription
tokenProviderToken<T> | ServiceIdentifier<T>

A controller, a provided token, or another bound class.

Returns

T

The instance, with its dependencies and overrides applied.

Throws

  • When the instance depends on a factory that returns a promise and init() has not run.

init Since 4.1.0

init(options?: TestingModuleInitOptions): Promise<this>

Resolves the module's useFactory providers, awaiting those that return a promise, in dependency order. invoke, dispatch and emit call it first; call it yourself before get resolves anything that depends on an asynchronous factory. Calling it again does nothing more.

With { ready: true }, it then runs every onReady hook once, as the bot does once it is online: one at a time, each class after the classes and providers it injects, the observers' last. Every hook runs even when one fails. Pair it with close, which runs the onShutdown hooks.

Ready, the module's app theme is also the one useTheme() reads outside any call, as a bot's is once it is online, unless another module or app in the process was ready first: that one keeps it, and this module's theme reaches its own calls only. close gives it up; nothing takes it over.

Parameters

NameTypeDefaultDescription
options?TestingModuleInitOptions

ready to also run the onReady hooks, with the client and primary to pass them.

options.ready?| boolean | { client?: Client<true> primary?: boolean }false

Also runs every onReady hook, once, in dependency order, as the bot does once it is online. true hands each hook a mock client from createMockClient and { primary: true }; an object sets either.

Returns

Promise<this>

The module, once every factory has made its value and every onReady hook asked for has run. Rejects with the error of a factory or a hook that failed, or an AggregateError of the hooks when several did.

Examples

TypeScript
const module = await MeoCordTestingModule.create({
  controllers: [NotesController],
  providers: [{ provide: DATABASE, useFactory: async () => createTestDatabase() }],
})
  .compile()
  .init({ ready: true })

expect(module.get(NotesStore).loaded).toBe(true)
await module.close()

invoke Since 4.1.0

invoke<C extends new (...args: any[]) => unknown, M extends HandlerName<C>>(
  controller: C,
  methodName: M,
  ...args: HandlerArgs<C, M>
): Promise<InvocationResult>

Runs a handler through the same pipeline dispatch runs: @Defer's acknowledgement, the global guards of the module's app, then the handler's own, in order and once each; then the interceptors, the app's first, around validation, pipes, cooldowns and the handler; all inside the handler's exception filters. Guards resolve from this module, so overrideGuard stubs apply and guards that inject ExecutionContext receive it. overrideInterceptor and overrideFilter stubs apply the same way.

Calling the controller method directly runs its guards but no interceptors, validation or filters; invoke is the way to test everything dispatch runs around a handler.

invoke tests one handler you name, and an error no filter handles rejects the call. That includes the errors the bot answers the user with: a guard's GuardDeniedError, a UserError and a CooldownError reject invoke, where dispatch resolves { ran, error } and sends the answer. To test what the bot does with an input, which handler it reaches and what the user is sent, use dispatch.

Parameters

NameTypeDescription
controllerC

A controller of the module: one given to create, or one of the app fromApp built it from.

methodNameM

The handler method's name.

...argsHandlerArgs<C, M>

The arguments dispatch would pass: the interaction, message or reaction, then the handler's params. With an interaction alone, the params are built as dispatch builds them: a command's or an autocomplete's options, or the handler's customId params with a modal's fields or a select menu's choices. An interaction's customId or command name must be one dispatch routes to the handler, ranking every handler of the module as the bot does; a mock built without one is not checked. With a message alone, a patterned @MessageHandler gets the params its pattern captures from the content, after the prefix of the module's app, with typed params resolved as dispatch resolves them, from the message's guild caches first; a message without content gets {}. A word that is not a value of its type, and a prefixed message that names the command but leaves out a param, go through the handler's filters as a MessageUsageError, as dispatch answers them.

Returns

Promise<InvocationResult>

Whether the handler ran, and the error a filter handled, if any. Rejects with an error no filter handles, or with the error a filter throws: the built-in fallback, which answers such errors in the bot, does not run here. Rejects before running anything with an interaction or a message the handler's route does not match, naming both.

Examples

TypeScript
const module = MeoCordTestingModule.create({ controllers: [ModerationController] }).compile()
const interaction = createMockInteraction(ChatInputCommandInteraction)

// A guard that returns false
const { ran } = await module.invoke(ModerationController, 'ban', interaction)

expect(ran).toBe(false)
expect(interaction.reply).not.toHaveBeenCalled()

// A guard that throws GuardDeniedError, or a handler that throws UserError
await expect(module.invoke(ModerationController, 'kick', interaction)).rejects.toThrow(GuardDeniedError)

// dispatch answers it as the bot does, and resolves with the outcome
const outcome = await module.dispatch(createMockInteraction(ChatInputCommandInteraction, { commandName: 'kick' }))
expect(outcome.ran).toBe(false)
expect(outcome.error).toBeInstanceOf(GuardDeniedError)

See also