Discord.js: Mocking Discord JS for Unit Testing

Created on 13 Nov 2019  路  17Comments  路  Source: discordjs/discord.js

Seeing as the original issue (#727) was closed over a year ago (with no real conclusion to the issue), and unit testing is still a hassle to implement, I'm opening this issue.

Describe the ideal solution

The ideal solution would be for snippets like the one below to simply work:

import {Guild, TextChannel, Message} from 'discord.js';
import {MockClient} from 'discord.js/mock';
const client = new MockClient();
const guild = new Guild(client, {});
const channel =  new TextChannel(guild, {});
const msg = new Message(channel, {}, client);

msg.awaitReactions(r => r.emoji.name === '馃憤').then(() => {
  console.log('Thumbs up!');
});
msg.react('馃憤');
enhancement

Most helpful comment

It is actually possible to mock discord.js data, without relying on Discord API (at least for my use-case).

I needed to mock a Message received from a TextChannel. This required me to mock the following discord.js classes, in the following order:

  • Discord.Client
  • Discord.Guild
  • Discord.Channel
  • Discord.GuildChannel
  • Discord.TextChannel
  • Discord.User
  • Discord.GuildMember
  • Discord.Message

However there is only a small trick in order to properly resolve a guild from a message.

Before instantiating a new Discord.Message and after instantiating a Discord.GuildMember, make sure to manually add the freshly created guild-member to the collection of members of the guild, like this: this.guild.members.set(this.guildMember.id, this.guildMember);.


Here is the full source-code of my mocking solution for my use-case, in TypeScript (click to expand).

import Discord from 'discord.js';

export default class MockDiscord {
  private client!: Discord.Client;

  private guild!: Discord.Guild;

  private channel!: Discord.Channel;

  private guildChannel!: Discord.GuildChannel;

  private textChannel!: Discord.TextChannel;

  private user!: Discord.User;

  private guildMember!: Discord.GuildMember;

  private message!: Discord.Message;

  constructor() {
    this.mockClient();

    this.mockGuild();

    this.mockChannel();

    this.mockGuildChannel();

    this.mockTextChannel();

    this.mockUser();

    this.mockGuildMember();

    this.guild.members.set(this.guildMember.id, this.guildMember);

    this.mockMessage();
  }

  public getClient(): Discord.Client {
    return this.client;
  }

  public getGuild(): Discord.Guild {
    return this.guild;
  }

  public getChannel(): Discord.Channel {
    return this.channel;
  }

  public getGuildChannel(): Discord.GuildChannel {
    return this.guildChannel;
  }

  public getTextChannel(): Discord.TextChannel {
    return this.textChannel;
  }

  public getUser(): Discord.User {
    return this.user;
  }

  public getGuildMember(): Discord.GuildMember {
    return this.guildMember;
  }

  public getMessage(): Discord.Message {
    return this.message;
  }

  private mockClient(): void {
    this.client = new Discord.Client();
  }

  private mockGuild(): void {
    this.guild = new Discord.Guild(this.client, {
      unavailable: false,
      id: 'guild-id',
      name: 'mocked discord.js guild',
      icon: 'mocked guild icon url',
      splash: 'mocked guild splash url',
      region: 'eu-west',
      member_count: 42,
      large: false,
      features: [],
      application_id: 'application-id',
      afkTimeout: 1000,
      afk_channel_id: 'afk-channel-id',
      system_channel_id: 'system-channel-id',
      embed_enabled: true,
      verification_level: 2,
      explicit_content_filter: 3,
      mfa_level: 8,
      joined_at: new Date('2018-01-01').getTime(),
      owner_id: 'owner-id',
      channels: [],
      roles: [],
      presences: [],
      voice_states: [],
      emojis: [],
    });
  }

  private mockChannel(): void {
    this.channel = new Discord.Channel(this.client, {
      id: 'channel-id',
    });
  }

  private mockGuildChannel(): void {
    this.guildChannel = new Discord.GuildChannel(this.guild, {
      ...this.channel,

      name: 'guild-channel',
      position: 1,
      parent_id: '123456789',
      permission_overwrites: [],
    });
  }

  private mockTextChannel(): void {
    this.textChannel = new Discord.TextChannel(this.guild, {
      ...this.guildChannel,

      topic: 'topic',
      nsfw: false,
      last_message_id: '123456789',
      lastPinTimestamp: new Date('2019-01-01').getTime(),
      rate_limit_per_user: 0,
    });
  }

  private mockUser(): void {
    this.user = new Discord.User(this.client, {
      id: 'user-id',
      username: 'user username',
      discriminator: 'user#0000',
      avatar: 'user avatar url',
      bot: false,
    });
  }

  private mockGuildMember(): void {
    this.guildMember = new Discord.GuildMember(this.guild, {
      deaf: false,
      mute: false,
      self_mute: false,
      self_deaf: false,
      session_id: 'session-id',
      channel_id: 'channel-id',
      nick: 'nick',
      joined_at: new Date('2020-01-01').getTime(),
      user: this.user,
      roles: [],
    });
  }

  private mockMessage(): void {
    this.message = new Discord.Message(
      this.textChannel,
      {
        id: 'message-id',
        type: 'DEFAULT',
        content: 'this is the message content',
        author: this.user,
        webhook_id: null,
        member: this.guildMember,
        pinned: false,
        tts: false,
        nonce: 'nonce',
        embeds: [],
        attachments: [],
        edited_timestamp: null,
        reactions: [],
        mentions: [],
        mention_roles: [],
        mention_everyone: [],
        hit: false,
      },
      this.client,
    );
  }
}

All 17 comments

What exactly do you think msg.react('馃憤') is going to do in this scenario? What does "simply work" actually mean?

Normally, this method sends an API request to Discord. How do you foresee that behaviour changing here, behind the scenes?

Normally, this method sends an API request to Discord. How do you foresee that behaviour changing here, behind the scenes?

All REST requests (like Message#react) go through client.api, I suspect a MockClient would change its behaviour to fire events instead, while this is doable, it may not be for cases like GuildMemberStore#fetch (without arguments), as it uses the WebSocket instead.

I suspect a MockClient would change its behaviour to fire events instead

This is what I thought also - which means if you want to unit test a listener, you might as well just client.emit("event", mockData). There's no need to build another class with the REST API methods ripped out and rewritten.

@kyranet this is indeed the intended behaviour.

@Monbrey that would work, however the code below throws an exception.

import {Client, Guild, TextChannel, Message} from 'discord.js';

const client = new Client();
const guild = new Guild(client, {});
const channel =  new TextChannel(guild, {});
const msg = new Message(channel, {}, client);

Also its important to point out that the code I provided in the issue is simply an example, and I would expect the MockClient to allow any and every method on any and every construct to fire just as if a normal client was being used, expect for the part where the normal client would send http requests.

@Monbrey that would work, however the code below throws an exception.

What exception exactly? Something along the lines of required data missing from the Guild/Channel/Message you're trying to construct I assume, given that you're passing empty objects into real constructors. None of those classes have been mocked.

Also its important to point out that the code I provided in the issue is simply an example, and I would expect the MockClient to allow any and every method on any and every construct to fire just as if a normal client was being used, expect for the part where the normal client would send http requests.

In theory sure, that still doesn't mean you can construct Guild/TextChannel/Message without providing any data. Mocked objects still require mock data.

@Monbrey I'll provide a more detailed example in the morning.

you should mock the api, not the client. swapping out the logic of the client is likely to introduce bugs between the two.

Create a testing server (save the server id and channel id) and make a function that will run any command with the specified args passed. Run all variations of the command on the testing channel and assert that the last message sent was correct.

Don't test more than one command at a time because sending too many messages is api abuse. But if you want a method of testing your discord bot, that is how I do it.

Well if testing was done by actually making a client login & performing the testing there, there can be API spam as pointed out by @Tenpi but also inconvenience. It would be slow & you may actually need to alter the source code to be able to test it which I don't think is a good thing.

As to how the dummy data will be gained,

new Discord.MockUser(userProperties: UserPropInterface = DefaultUserProp): MockUser
new Discord.MockChannel(channelProperties: ChannelPropInterface = DefaultChannelProp): MockChannel
new Discord.MockGuild(guildProperties: GuildPropInterface = DefaultGuildProp, users: MockUser[], channels: MockChannel[])
new Discord.MockClient(guilds: MockGuild[]): MockClient

There will be no API calls done & all the return types of properties & methods would be the same... Everything would be synchronus (since there won't be any API calls) so it would be faster (but ofcourse promises returned to mock the API calls)

Lending my voice to say I would also be extremely interested in this. I've been bouncing some ideas around about this and creating some half-baked implementation ideas but haven't found a home run solution yet.

Hitting a test server is great for end to end testing but it is far less useful for unit testing.

I'm still getting up to speed on the terminology / architecture of the library (so this might be way off base), but would it be feasible to add support for loading fixtures directly into the cache system? I'm wondering if it would be possible to essentially shim the state you want so all the methods don't hit the API to retrieve anything.

This is already possible. Collections (stable) and DataStores (master) extend Map.

<Map|Collection|DataStore>.set(key, value)

Maybe it's an error in my code but I haven't been able to get an example working - I tried to create two mock users, create a mock guild, create a mock text channel, and then assign the channel to the guild and assign the two users to the guild via GuildMember associations but was unsuccessful. I'm not entirely sure where the breakdown is happening but I think when you try to link together different structures it's still trying to hit an API somewhere.

It is actually possible to mock discord.js data, without relying on Discord API (at least for my use-case).

I needed to mock a Message received from a TextChannel. This required me to mock the following discord.js classes, in the following order:

  • Discord.Client
  • Discord.Guild
  • Discord.Channel
  • Discord.GuildChannel
  • Discord.TextChannel
  • Discord.User
  • Discord.GuildMember
  • Discord.Message

However there is only a small trick in order to properly resolve a guild from a message.

Before instantiating a new Discord.Message and after instantiating a Discord.GuildMember, make sure to manually add the freshly created guild-member to the collection of members of the guild, like this: this.guild.members.set(this.guildMember.id, this.guildMember);.


Here is the full source-code of my mocking solution for my use-case, in TypeScript (click to expand).

import Discord from 'discord.js';

export default class MockDiscord {
  private client!: Discord.Client;

  private guild!: Discord.Guild;

  private channel!: Discord.Channel;

  private guildChannel!: Discord.GuildChannel;

  private textChannel!: Discord.TextChannel;

  private user!: Discord.User;

  private guildMember!: Discord.GuildMember;

  private message!: Discord.Message;

  constructor() {
    this.mockClient();

    this.mockGuild();

    this.mockChannel();

    this.mockGuildChannel();

    this.mockTextChannel();

    this.mockUser();

    this.mockGuildMember();

    this.guild.members.set(this.guildMember.id, this.guildMember);

    this.mockMessage();
  }

  public getClient(): Discord.Client {
    return this.client;
  }

  public getGuild(): Discord.Guild {
    return this.guild;
  }

  public getChannel(): Discord.Channel {
    return this.channel;
  }

  public getGuildChannel(): Discord.GuildChannel {
    return this.guildChannel;
  }

  public getTextChannel(): Discord.TextChannel {
    return this.textChannel;
  }

  public getUser(): Discord.User {
    return this.user;
  }

  public getGuildMember(): Discord.GuildMember {
    return this.guildMember;
  }

  public getMessage(): Discord.Message {
    return this.message;
  }

  private mockClient(): void {
    this.client = new Discord.Client();
  }

  private mockGuild(): void {
    this.guild = new Discord.Guild(this.client, {
      unavailable: false,
      id: 'guild-id',
      name: 'mocked discord.js guild',
      icon: 'mocked guild icon url',
      splash: 'mocked guild splash url',
      region: 'eu-west',
      member_count: 42,
      large: false,
      features: [],
      application_id: 'application-id',
      afkTimeout: 1000,
      afk_channel_id: 'afk-channel-id',
      system_channel_id: 'system-channel-id',
      embed_enabled: true,
      verification_level: 2,
      explicit_content_filter: 3,
      mfa_level: 8,
      joined_at: new Date('2018-01-01').getTime(),
      owner_id: 'owner-id',
      channels: [],
      roles: [],
      presences: [],
      voice_states: [],
      emojis: [],
    });
  }

  private mockChannel(): void {
    this.channel = new Discord.Channel(this.client, {
      id: 'channel-id',
    });
  }

  private mockGuildChannel(): void {
    this.guildChannel = new Discord.GuildChannel(this.guild, {
      ...this.channel,

      name: 'guild-channel',
      position: 1,
      parent_id: '123456789',
      permission_overwrites: [],
    });
  }

  private mockTextChannel(): void {
    this.textChannel = new Discord.TextChannel(this.guild, {
      ...this.guildChannel,

      topic: 'topic',
      nsfw: false,
      last_message_id: '123456789',
      lastPinTimestamp: new Date('2019-01-01').getTime(),
      rate_limit_per_user: 0,
    });
  }

  private mockUser(): void {
    this.user = new Discord.User(this.client, {
      id: 'user-id',
      username: 'user username',
      discriminator: 'user#0000',
      avatar: 'user avatar url',
      bot: false,
    });
  }

  private mockGuildMember(): void {
    this.guildMember = new Discord.GuildMember(this.guild, {
      deaf: false,
      mute: false,
      self_mute: false,
      self_deaf: false,
      session_id: 'session-id',
      channel_id: 'channel-id',
      nick: 'nick',
      joined_at: new Date('2020-01-01').getTime(),
      user: this.user,
      roles: [],
    });
  }

  private mockMessage(): void {
    this.message = new Discord.Message(
      this.textChannel,
      {
        id: 'message-id',
        type: 'DEFAULT',
        content: 'this is the message content',
        author: this.user,
        webhook_id: null,
        member: this.guildMember,
        pinned: false,
        tts: false,
        nonce: 'nonce',
        embeds: [],
        attachments: [],
        edited_timestamp: null,
        reactions: [],
        mentions: [],
        mention_roles: [],
        mention_everyone: [],
        hit: false,
      },
      this.client,
    );
  }
}

Updated @TotomInc example for v12


Click to expand code

import {
    Client,
    Guild,
    Channel,
    GuildChannel,
    TextChannel,
    User,
    GuildMember,
    Message,
} from "discord.js";

export default class MockDiscord {
    private client!: Client;
    private guild!: Guild;
    private channel!: Channel;
    private guildChannel!: GuildChannel;
    private textChannel!: TextChannel;
    private user!: User;
    private guildMember!: GuildMember;
    public message!: Message;

    constructor() {
        this.mockClient();
        this.mockGuild();
        this.mockChannel();
        this.mockGuildChannel();
        this.mockTextChannel();
        this.mockUser();
        this.mockGuildMember();
        this.guild.addMember(this.user, { accessToken: "mockAccessToken" });
        this.mockMessage();
    }

    public getClient(): Client {
        return this.client;
    }

    public getGuild(): Guild {
        return this.guild;
    }

    public getChannel(): Channel {
        return this.channel;
    }

    public getGuildChannel(): GuildChannel {
        return this.guildChannel;
    }

    public getTextChannel(): TextChannel {
        return this.textChannel;
    }

    public getUser(): User {
        return this.user;
    }

    public getGuildMember(): GuildMember {
        return this.guildMember;
    }

    public getMessage(): Message {
        return this.message;
    }

    private mockClient(): void {
        this.client = new Client();
    }

    private mockGuild(): void {
        this.guild = new Guild(this.client, {
            unavailable: false,
            id: "guild-id",
            name: "mocked js guild",
            icon: "mocked guild icon url",
            splash: "mocked guild splash url",
            region: "eu-west",
            member_count: 42,
            large: false,
            features: [],
            application_id: "application-id",
            afkTimeout: 1000,
            afk_channel_id: "afk-channel-id",
            system_channel_id: "system-channel-id",
            embed_enabled: true,
            verification_level: 2,
            explicit_content_filter: 3,
            mfa_level: 8,
            joined_at: new Date("2018-01-01").getTime(),
            owner_id: "owner-id",
            channels: [],
            roles: [],
            presences: [],
            voice_states: [],
            emojis: [],
        });
    }

    private mockChannel(): void {
        this.channel = new Channel(this.client, {
            id: "channel-id",
        });
    }

    private mockGuildChannel(): void {
        this.guildChannel = new GuildChannel(this.guild, {
            ...this.channel,

            name: "guild-channel",
            position: 1,
            parent_id: "123456789",
            permission_overwrites: [],
        });
    }

    private mockTextChannel(): void {
        this.textChannel = new TextChannel(this.guild, {
            ...this.guildChannel,

            topic: "topic",
            nsfw: false,
            last_message_id: "123456789",
            lastPinTimestamp: new Date("2019-01-01").getTime(),
            rate_limit_per_user: 0,
        });
    }

    private mockUser(): void {
        this.user = new User(this.client, {
            id: "user-id",
            username: "user username",
            discriminator: "user#0000",
            avatar: "user avatar url",
            bot: false,
        });
    }

    private mockGuildMember(): void {
        this.guildMember = new GuildMember(
            this.client,
            {
                deaf: false,
                mute: false,
                self_mute: false,
                self_deaf: false,
                session_id: "session-id",
                channel_id: "channel-id",
                nick: "nick",
                joined_at: new Date("2020-01-01").getTime(),
                user: this.user,
                roles: [],
            },
            this.guild
        );
    }

    private mockMessage(): void {
        this.message = new Message(
            this.client,
            {
                id: "message-id",
                type: "DEFAULT",
                content: "this is the message content",
                author: this.user,
                webhook_id: null,
                member: this.guildMember,
                pinned: false,
                tts: false,
                nonce: "nonce",
                embeds: [],
                attachments: [],
                edited_timestamp: null,
                reactions: [],
                mentions: [],
                mention_roles: [],
                mention_everyone: [],
                hit: false,
            },
            this.textChannel
        );
    }
}

due to managers being recreated on each call to things like member.roles, I'm unable to stub out add:

const Discord = require('discord.js');
const sinon = require('sinon');

let client = new Discord.Client();
let guild = new Discord.Guild(client, {id: Discord.SnowflakeUtil.generate()});
let user = new Discord.User(client, {id: Discord.SnowflakeUtil.generate()});
let member = new Discord.GuildMember(client, {id: Discord.SnowflakeUtil.generate(), user: {id: user.id}}, guild);
let role = new Discord.Role(client, {id: Discord.SnowflakeUtil.generate()}, guild);

sinon.stub(member.roles, 'add').resolves();

member
  .roles // different GuildMemberRoleManager instance then the one that was stubbed
  .add(role); // Errors with an attempt to make a request to Discord

Am I going about this all wrong?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

BrandonCookeDev picture BrandonCookeDev  路  3Comments

xCuzImPro picture xCuzImPro  路  3Comments

Alipoodle picture Alipoodle  路  3Comments

kvn1351 picture kvn1351  路  3Comments

Dmitry221060 picture Dmitry221060  路  3Comments