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. With
useMockFn, it is your test runner's own mock, and the runner treats it as one of its own.
Under Node's test runner, 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)
})A server the bot isn't in
A user-installed command can run in a server the bot isn't in. discord.js then has no server to cache the member in,
so interaction.member is the member Discord sent: plain data, with roles as role ids and permissions as a
string. createMockRawMember() builds it. Give it as the interaction's member,
with the server's guildId and no guild:
inRawGuild()is true andinCachedGuild()false, andguildandchannelarenull, with itschannelIdkept;useris the member's user, andmemberPermissionsare the member's;- a user option's member is the member Discord resolves, with
rolesandpermissionsbut nouser; - the interaction is typed as discord.js types one from such a server, so the compiler sees
memberas raw data.
It takes the roles, permissions and user to give it, such as
createMockRawMember({ roles: [moderatorId], permissions: [PermissionFlagsBits.KickMembers] }). A guard that reads
member.roles.cache throws there, as it does in Discord, so a test of such a command finds the branch a handler needs:
interaction.inCachedGuild() before reading the cache, or member.roles as ids.
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 discord.js computes
Some of what discord.js computes depends on Discord's state: who sent a message, the bot's roles and permissions, a channel's overwrites. Unless told otherwise, a mock reads these 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.
Strict mocks
useStrictMocks(), called once in a test setup file before any mock is made, has the
mocks compute each of these with discord.js's own code, and message.thread read null without a cached thread. No
placeholder warning is logged. The mocks hold what those computations read, as Discord sends it:
- the bot's member is in its server's member cache from the start;
- @everyone has the permissions Discord gives it in a new server: the bot can view and send in a channel and join a voice channel, but not manage, pin, kick or ban;
- a channel or thread made without a server has one of its own, a thread with a text channel as its parent.
So a message another user sent isn't editable or deletable, and a member isn't kickable until the bot's member
has a role above theirs with Kick Members. Give the bot's member that role, through guild.members.me.roles.add(),
and the values follow. A value the test sets on a mock still wins over the computed one. A generated project's
vitest.setup.ts makes this call.
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'))
})Your test runner's mocks
useMockFn(vi.fn), called once in a test setup file, has meocord/testing make every mock
with the runner's own mock function. The runner then treats them as its own: Vitest's clearMocks and mockReset
config and vi.clearAllMocks() reach them, and vi.mocked(interaction.reply) gives Vitest's whole mock API, such as
withImplementation. A generated project's vitest.setup.ts makes this call.
- jest takes
useMockFn(jest.fn), in a file itssetupFileslists. - bun test takes
useMockFn(mock), withmockfrombun:test, in a filebunfig.tomlpreloads. Bun's matchers, such astoHaveBeenCalledWith, read only bun's own mocks, so this is what lets them read MeoCord's. - node:test keeps MeoCord's own mock function: its
mock.fnrecords calls in a shape of its own, whichuseMockFnrefuses.
Call it before any mock is made. Once a mock exists, a call with another function throws, since the two kinds would mix. A setup file runs first, so that is where it goes.
Resetting between tests
clearAllMocks() forgets what every mock from meocord/testing 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.
Under Vitest, its own mockReset puts a mock back to how it was created, so its config works too. Under jest and
bun, a runner's mockReset drops a mock's starting behaviour, and with it what MeoCord's mocks do, such as an
interaction refusing a second reply. Reset there with resetAllMocks(), which puts that behaviour back, and leave
jest's resetMocks off.
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.