Mocks
Stand in for discord.js interactions, messages, users and Discord's errors, with the rules discord.js follows.
You'll learn
- Mock any discord.js class, with the properties a test needs
- Build a command's options, a modal's fields and a message with its mentions
- Give a member roles, and read its permissions as discord.js computes them
- Reset mocks between tests, and reject with Discord's own errors
Before this
A handler takes discord.js objects: an interaction, a message, a reaction, the user behind them. meocord/testing
mocks every one of them. A mock keeps its class's prototype chain, so instanceof holds at every level and a mock can
go straight to code that expects the real class.
Every method is a mock function, with .mock.calls, which Vitest's and Jest's matchers read. Under Node's test runner
and bun test, assert through .mock.calls itself: each call is recorded as an array of its arguments,
mock.calls[0][0], not node:test's { arguments } record.
When to use it
Use these mocks for the inputs a testing module runs, and for services that read discord.js objects. They follow discord.js's rules, so a handler that replies twice, or reads a server outside one, fails in the test as it would in the bot.
For a value you own, such as a service's settings, pass a plain object or use
overrideProvider instead. A mock is for what Discord sends.
Example
it('behave like the real class', async () => {
const interaction = createMockInteraction(ChatInputCommandInteraction)
// instanceof holds at every level, and type guards run discord.js's own logic
expect(interaction).toBeInstanceOf(BaseInteraction)
expect(interaction.isChatInputCommand()).toBe(true)
expect(interaction.isButton()).toBe(false)
// Replying twice throws, as it does against Discord
await interaction.reply({ content: 'hi' })
expect(interaction.replied).toBe(true)
await expect(interaction.reply({ content: 'again' })).rejects.toThrow()
// Every method is still a mock function
expect(interaction.reply).toHaveBeenCalledWith({ content: 'hi' })
})createMockInteraction(Class, overrides?) mocks the class you pass, here a
slash command. Its type guards and its replies behave as discord.js's do, and each call is recorded.
How it works
A mock is built from the class's prototype, with its methods replaced by mock functions:
- Type guards run discord.js's logic.
isButton(),isRepliable(),isChatInputCommand()and the rest answer from the class the mock was made from. They're still mock functions, so a test can override one. - Replies follow Discord's rules. Replying or deferring twice throws, and
followUp(),editReply()anddeleteReply()throw before any reply. After a command shows a modal,editReply(),fetchReply()anddeleteReply()reject with Unknown Message (10008), since there's no reply. An autocomplete'srespond()works once, and refuses more than 25 choices. - Ids are Discord's shape. An interaction gets an
id, achannelIdand auser, a person rather than a bot, each a snowflake no other mock in the run has. Two mocks are two users, so a per-user cooldown counts them apart; give them oneuser, or one messageauthor, to count them together. Ids you give are kept. - Creation times come from the id, as discord.js reads them:
createdTimestampandcreatedAtare the time anidyou give encodes, or, with the generated id, the time the mock was made. AcreatedTimestampyou set wins. - A mock without a
guildIdis a DM.inGuild(),inCachedGuild()andinRawGuild()answer from the mock'sguildIdandguild, and in a DMguildandmemberarenull. - An interaction has the channel it came from. In a server,
channelis a text channel of that server, the one itsguildcaches underchannelId; in a DM, it's the user's DM channel. Itssend()resolves, and its type guards, such asisTextBased(), answer as discord.js's do. - A channel you give sets where the mock is. Given to
createMockInteractionorcreateMockMessage, it sets thechannelId,guildIdandguildyou leave out: a DM channel makes the mock a DM, and a server's channel puts it in that server. A server's channel that names no server goes in the mock's. A channel in another server than theguildyou give, or a DM channel beside aguildId, is refused, naming both. - A member has roles and permissions. An interaction's or a message's
memberhas the server's @everyone role, andpermissionsandmemberPermissionsare computed from its roles as discord.js computes them, so a role or permission guard runs on a mock as it does in Discord. - A select menu has picked nothing unless given. Its
valuesare an empty array, and so are the collections of what its kind picks:usersandmembers,roles, orchannels, each an emptyCollection. Give the choices a test needs in the overrides, as theCollections discord.js holds: itsvaluesare then their ids, as Discord sends them. - An interaction has a client. One made without a
clientgets one fromcreateMockClient(), as a message does: its user is inclient.users.cache, its channel inclient.channels.cacheonce read, andclient.useris the mock bot. - Locales are set as Discord sends them.
localeis'en-US', andguildLocaleis'en-US'in a server andnullin a DM, so a translator works on a default mock.
it('reads an interaction’s creation time from its id, as discord.js does', () => {
const before = Date.now()
const made = createMockInteraction(ButtonInteraction, { customId: 'x' })
const given = createMockInteraction(ButtonInteraction, { customId: 'x', id: '1200000000000000000' })
// A generated id: the time the mock was made
expect(made.createdTimestamp).toBeGreaterThanOrEqual(before)
// An id you give: the time it encodes
expect(given.createdAt).toEqual(new Date(SnowflakeUtil.timestampFrom('1200000000000000000')))
})Overrides
The second argument sets properties as the mock is built. It's the only way to set what discord.js makes read-only, such
as a modal's customId and fields, or the client. A misspelled property name is a compile error.
To put a command somewhere a user-installed app can be used, set context and authorizingIntegrationOwners:
const at = (context: InteractionContextType, owners?: Partial<Record<ApplicationIntegrationType, string>>) =>
getInstallContext(
createMockInteraction(ChatInputCommandInteraction, { context, authorizingIntegrationOwners: owners }),
)
describe('getInstallContext', () => {
it('tells the four places a command can run apart', () => {
// The bot's own server, installed to the server
expect(
at(InteractionContextType.Guild, { [ApplicationIntegrationType.GuildInstall]: '876543210987654321' }),
).toEqual({
where: 'guild',
botInstalled: true,
})
// A server without the bot, through a user install
expect(
at(InteractionContextType.Guild, { [ApplicationIntegrationType.UserInstall]: '123456789012345678' }),
).toEqual({
where: 'guild',
botInstalled: false,
})
expect(at(InteractionContextType.BotDM)).toEqual({ where: 'bot-dm', botInstalled: true })
expect(at(InteractionContextType.PrivateChannel)).toEqual({ where: 'private-channel', botInstalled: false })
})
})Options and fields
createChatInputOptions(record) builds a command's options, found by name as
the real resolver finds them:
it('take options as Discord sends them', () => {
const interaction = createMockInteraction(ChatInputCommandInteraction)
interaction.options = createChatInputOptions({
subcommandGroup: 'admin',
subcommand: 'ban',
reason: 'spam',
days: 7,
})
expect(interaction.options.getSubcommand(true)).toBe('ban')
expect(interaction.options.getString('reason')).toBe('spam')
// An option of another type, or a required one that is absent, throws discord.js's error
expect(() => interaction.options.getString('days')).toThrow('Option "days" is of type: 4; expected 3.')
expect(() => interaction.options.getNumber('missing', true)).toThrow('Required option "missing" not found.')
// data is nested under the subcommand path, as MeoCord reads it to build a handler's params
expect(interaction.options.data[0]).toMatchObject({
name: 'admin',
type: ApplicationCommandOptionType.SubcommandGroup,
})
})- A user, role, channel or attachment option is set both as the id and as the resolved object, so a handler that
reads only one of the two is caught.
getAttachment()returns theAttachmentgiven, andnullfor an option not given. A user option carries its user, and in a server its member:getMember()isnullin a DM, and a member you give answersgetUser()with its user. - Each getter reads its option as discord.js does, and throws discord.js's own error: a
TypeErrorwith itscode. A getter of another type throws, whether or not it's asked withrequired: true:getInteger()on1.5, a number option, throwsOption "x" is of type: 10; expected 4., while a whole number reads as either. A user or member read as a role, or a role read as a user or member, isnull, or that error when asked withrequired: true, since the option may be a mentionable one. - A missing option asked with
required: truethrowsRequired option "x" not found., andgetSubcommand()throws when there's none, unless givenfalse, as in discord.js. So doesgetChannel()givenchannelTypes, for a channel of another type, andgetFocused()when no option isfocused. subcommandGroup,subcommandandfocusedare reserved names: the last names the option an autocomplete is typing.
A modal's submitted fields come from createModalFields({ body: 'It crashed' }), which discord.js doesn't let a
test build. A file upload field takes an array of Attachments: createModalFields({ screenshot: [attachment] }).
Messages, servers and the rest
createMockMessage()mocks a message that tracks whether it was deleted:delete(),edit(),reply(),react(),pin()andunpin()throw once it is. It takes anid,content,components,embedsandflags, and builders or JSON forcomponentsandembeds. What the content mentions is cached as the gateway delivers it: a<@id>in the client'susers.cache, and in a server inguild.members.cache; a<@&id>role and a<#id>channel in their caches too.authorsends a message as a user you give, such as one fromcreateMockUser(), orclient.userfor one the bot sent. It's cached on the client, and in a server the message'smemberis the guild's cached member for that user, made and cached when there's none. Every message from that author in oneguildyou give has the same member, and so does an interaction given the sameuserandguild.message.memberreads that cache each time, so a test that deletes the author's member fromguild.members.cachegetsnull, as discord.js gives for an author it hasn't cached.createMockGuild({ members, roles, channels })puts those in the server's caches, where a command's typed params are read from. Give it tocreateMockMessage({ guild }), or passguild: nullfor a DM.createMockClient()has real, emptyusersandchannelscaches, and one bot user, the same in every mock, asclient.user.createMockMember({ user, guild, roles, nickname })makes a member with the roles given; see Members and roles.createMockUser()mocks a person,bot: false. A DM to the user, or to a member of theirs, goes through the user's one DM channel, whichcreateDM()resolves to.createMockChannel(Class)mocks a channel of the class you pass, such asTextChannelorThreadChannel. Its type guards answer for that class, and its managers,messages,threadsormembers, have real, empty caches, with the channel as theirchannel, and asthreadon a thread'smembers. Give one tocreateMockMessage({ channel })to send a message there, or to an interaction to have it come from there.createMock<Interface>()mocks a type with no class at runtime, such as a service's interface. A type has no shape at runtime, so every property is a mock function, data included:if (settings.enabled)always passes. Pass the values the code reads,createMock<Settings>({ enabled: false }).
Two messages from one author count against that user's cooldown, as they would from one person in Discord:
it('counts two messages from one author against that member’s cooldown', async () => {
const module = MeoCordTestingModule.fromApp(App).compile()
const ana = createMockUser()
const first = createMockMessage({ author: ana, content: '!daily' })
const retry = createMockMessage({ author: ana, content: '!daily' })
await module.dispatch(first)
await module.dispatch(retry)
expect(first.reply).toHaveBeenCalledWith('You claimed 100 coins. Come back tomorrow.')
// The same author, so the same user's cooldown refuses the second
expect(retry.reply).not.toHaveBeenCalled()
})A message and an interaction from that user in one server share the member:
it('gives a message and an interaction from one user in one server the same member', () => {
const ana = createMockUser()
const guild = createMockGuild()
const message = createMockMessage({ author: ana, guild })
const click = createMockInteraction(ButtonInteraction, { user: ana, guild, guildId: guild.id })
expect(click.member).toBe(message.member)
expect(guild.members.cache.get(ana.id)).toBe(message.member)
})Members and roles
createMockMember({ user, guild, roles, nickname }) makes a member of a server, with
the roles you give. Put it in createMockGuild({ members }), and an interaction or a message from its user in that
server has it as its member, so a role guard sees its roles:
it('lets in a member with a required role, and no one without it', async () => {
const moderator = createMockInteraction(Role, { id: ROLE_IDS.moderator, name: 'moderator' })
const ana = createMockUser()
const guild = createMockGuild({ members: [createMockMember({ user: ana, roles: [moderator] })] })
const fromAna = createMockInteraction(ChatInputCommandInteraction, { user: ana, guildId: guild.id, guild })
const fromStranger = createMockInteraction(ChatInputCommandInteraction, { guildId: guild.id, guild })
await expect(module.invoke(ModerationSlashController, 'ban', fromAna)).resolves.toEqual({ ran: true })
await expect(module.invoke(ModerationSlashController, 'ban', fromStranger)).resolves.toEqual({ ran: false })
})A role is a mock Role with the id a guard checks: createMockInteraction(Role, { id }). The member's
roles.cache holds the server's @everyone role, then the roles given, and roles.add(), remove() and set() change
them. roles.highest ranks them by position, then by id. Its permissions combine its roles', @everyone's included,
and the server's owner has every permission:
it("takes a member's permissions from its roles, @everyone's included", () => {
const kick = createMockInteraction(Role, {
permissions: new PermissionsBitField([PermissionFlagsBits.KickMembers]),
})
const member = createMockMember({ roles: [kick] })
expect(member.permissions.has(PermissionFlagsBits.KickMembers)).toBe(true)
expect(member.permissions.has(PermissionFlagsBits.BanMembers)).toBe(false)
expect([...member.roles.cache.values()]).toEqual([member.guild.roles.everyone, kick])
})What discord.js computes from these, the mocks compute too, and keep computing after resetAllMocks():
role.comparePositionTo(other)andguild.roles.comparePositions(a, b)rank by position, then by id;channel.permissionsFor(member)andmember.permissionsIn(channel)apply the channel'spermissionOverwritesto the member's roles, and an interaction'sappPermissionsare the bot's in its channel;- a manager's
resolve()andresolveId()read its cache, given an id or the item, andguild.members.resolve(user)finds that user's member; guild.members.meis the bot's member: the cached one forclient.user, or one with @everyone, made once.
A member made without a server joins the one whose members it's given to. An interaction's channel is one of that
server's, and a manager's fetch(id) finds what the server caches:
it('answers in the channel the interaction came from, and finds a member by id', async () => {
const ana = createMockMember()
const guild = createMockGuild({ members: [ana] })
const interaction = createMockInteraction(ChatInputCommandInteraction, { guildId: guild.id, guild })
const channel = interaction.channel as TextChannel
await channel.send('Posted here.')
expect(channel.send).toHaveBeenCalledWith('Posted here.')
expect(guild.channels.cache.get(interaction.channelId)).toBe(channel)
await expect(guild.members.fetch(ana.id)).resolves.toBe(ana)
})What methods return
A method that returns a promise in discord.js resolves, so await and .catch() work with no setup:
| Method | Resolves to |
|---|---|
send(), a message's reply(), crosspost(), forward(), and an interaction's editReply(), followUp(), fetchReply() | a mock message |
an interaction's reply(), deferReply(), update(), deferUpdate(), showModal() | undefined |
a manager's fetch(id), or fetch({ user }) and the like | its cached item with that id, or a new one it caches |
a guild's members.fetch({ user: ids }) | a Collection of each, as fetch(id) gives it |
a manager's fetch() for a list | an empty Collection |
a manager's create() and edit() | a mock of its item |
createDM() | a mock DM channel |
a structure's own edit(), fetch(), delete() and setters, but a message's edit() and delete() | the structure itself |
a message's edit() | a new mock message |
a message's delete(), pin() and unpin() | undefined |
| any other method that returns a promise | undefined |
it('resolves methods that return a promise in discord.js to something to work with', async () => {
const client = createMockClient()
const guild = createMockGuild()
const channel = createMockChannel(TextChannel)
// send() and reply() resolve to a message, so .catch() and awaited results just work
await expect(client.users.send('1', 'hi').catch(() => undefined)).resolves.toBeInstanceOf(Message)
// A manager's fetch by id resolves to that item, and a list fetch to an empty collection
await expect(client.users.fetch('1')).resolves.toBeInstanceOf(User)
await expect(guild.members.fetch('1')).resolves.toBeInstanceOf(GuildMember)
await expect(guild.members.fetch()).resolves.toEqual(new Collection())
// A structure's own edits and setters resolve to the structure
await expect(channel.setName('general')).resolves.toBe(channel)
})A method that returns a value at once returns what discord.js computes where the mock has what it needs:
resolve(), comparePositionTo(), permissionsFor(), message.mentions.has(user), isReady(), and avatarURL()
and iconURL(), null with no avatar or icon set. Any other returns undefined. A test still decides with
mockReturnValue, mockResolvedValue and mockRejectedValue.
Values a mock can't compute
Some of what discord.js computes depends on Discord's state a mock doesn't hold, so the mock reads it as a truthy placeholder:
- a message's
editable,deletable,pinnable,crosspostable,bulkDeletableandhasThread; - a member's
manageable,kickable,bannableandmoderatable; - a role's
editable, and a channel'sviewable,manageableanddeletable, and their thread and voice counterparts; partialon messages, users, channels and reactions.
The first time a test reads one, the run logs a warning that names it and says how to set it. Set the value the test
relies on, such as message.editable = false or member.kickable = false, and it is read without a warning.
message.thread is the thread the message's channel caches under the message's id. Cache one with
channel.threads.cache.set(message.id, thread) for a message that started a thread. Without one, it is a placeholder
thread, with a warning, since discord.js reads null there.
Collectors
A collector's callback answers a click with respond() after the handler has returned. It takes the theme of the app
the click's client belongs to, so a click a test builds must come from the same client as the call that started the
collector. The gateway does that for the bot; in a test, pass { client: interaction.client }:
it('takes the module’s theme when the click comes from the client the call came to', async () => {
const module = MeoCordTestingModule.create({ app: PollApp, controllers: [PollController] }).compile()
const open = createMockInteraction(ButtonInteraction, { customId: 'poll/open' })
await module.dispatch(open)
// The gateway gives a collector's click the bot's client
const vote = createMockInteraction(ButtonInteraction, { customId: 'poll-vote', client: open.client })
await onCollect!(vote)
const { embeds } = getResponse(vote).calls[0].payload as { embeds: { color?: number }[] }
expect(embeds[0].color).toBe(resolveColor('#5865F2'))
})Resetting between tests
Vitest's clearMocks and restoreMocks reach only vi.fn(), so meocord/testing has its own. clearAllMocks()
forgets what every mock it made has recorded. resetAllMocks() also undoes what a test
told them, back to how each was created:
it('clears what mocks recorded, and resets what a test told them', async () => {
const client = createMockClient()
const notify = createMockFn().mockReturnValue('sent')
client.users.fetch.mockRejectedValue(new Error('Unknown User'))
notify()
// Calls are forgotten; behaviour a test set stays
clearAllMocks()
expect(notify.mock.calls).toEqual([])
expect(notify()).toBe('sent')
// Behaviour goes back to how each mock was created, the defaults above included
resetAllMocks()
expect(notify()).toBeUndefined()
await expect(client.users.fetch('1')).resolves.toBeDefined()
})A generated project resets after every test from vitest.setup.ts, as The testing module
shows. An older project gets the same by adding that file and setupFiles: ['./vitest.setup.ts'] to its
vitest.config.ts.
Discord's errors
createDiscordError(code) builds the DiscordAPIError discord.js throws, for a mock
to reject with:
- 10062: the three seconds to answer passed;
- 40060: the interaction was already acknowledged;
- 50001: missing access;
- 50027: the fifteen-minute token has expired.
respond() passes the error on to the handler, and getResponse keeps the refused call,
with its error, without counting it as sent:
it('reject with the errors discord.js throws, which getResponse records', async () => {
const interaction = createMockInteraction(ButtonInteraction, { customId: 'late' })
// 10062: the three seconds to answer passed
interaction.update.mockRejectedValueOnce(createDiscordError(10062))
await expect(respond(interaction).send('Refreshed.')).rejects.toBeInstanceOf(DiscordAPIError)
// The refused call stays in calls, with its error, and nothing counts as sent
expect(getResponse(interaction)).toMatchObject({
sent: false,
calls: [{ method: 'update', error: { code: 10062 } }],
})
})A manager's fetch(id) finds or makes the item it's asked for, so a test of an ID that isn't a member, or of a user
that doesn't exist, rejects the fetch with Discord's code:
guild.members.fetch.mockRejectedValue(createDiscordError(10007)) for a member, and
message.client.users.fetch.mockRejectedValue(createDiscordError(10013)) for a user. A typed message param that names
that ID is then refused, as "is not a member of this server" or "no user has the ID …". Use mockRejectedValue rather
than mockRejectedValueOnce: a message naming several IDs fetches them together, then one by one when that fails. A
mention never reaches the fetch, since a member it names comes from the message's mentions.
Gotchas
- A read-only property can't be assigned after creation. TypeScript refuses
modal.customId = …on aModalSubmitInteraction, as discord.js declares it read-only. Set it in the overrides, where a misspelling is caught too. - A MeoCord mock you configure once is reset after the first test. Set return values in the test that relies on
them, or in
beforeEach. Avi.fn()of your own only has its calls cleared. - A command's options aren't there by default. A mock
ChatInputCommandInteractionhas no options until you giveoptions: createChatInputOptions({ … })in its overrides, or assign it afterwards.
Next steps
- Invoke and dispatch: running handlers with these inputs.
- Testing recipes: themes, guards, cooldowns and collectors under test.
- Message commands: the typed params a mock server's caches feed.