Search code examples
typescriptjestjsdiscord.js

How can I mock a property of a class?


This is the class I'm trying to test:

CommandFactory.ts

import type {ChatInputCommandInteraction} from 'discord.js';
import type CommandInterface from './interfaces/CommandInterface';
import GameCommand from './commands/GameCommand';
import SettingCommand from './commands/SettingCommand';
import SetupCommand from './commands/SetupCommand';
import commands from '@config/commands';

export default class CommandFactory {
    public getCommand(interaction: ChatInputCommandInteraction): CommandInterface {
        for (const command of commands) {
            if (command.name === interaction.commandName) {
                if (command.classType === 'SettingCommand') {
                    return new SettingCommand(interaction, command);
                }

                if (command.classType === 'GameCommand') {
                    return new GameCommand(interaction, command);
                }

                if (command.classType === 'SetupCommand') {
                    return new SetupCommand(interaction, command);
                }
            }
        }

        throw new Error(`Unexpected '${interaction.commandName}' command.`);
    }
}

And the (not working) test I wrote:

CommandFactory.test.ts

import CommandFactory from '@components/CommandFactory';
import type {ChatInputCommandInteraction} from 'discord.js';
import type {jest} from '@jest/globals';
import {test, expect} from '@jest/globals';
import GameCommand from '@components/commands/GameCommand';

const interaction: jest.MockedObject<ChatInputCommandInteraction> = {
    commandName: 'add-game',
};

test('Instantiates GameCommand', () => {
    const factory = new CommandFactory();

    const command = factory.getCommand(interaction);

    expect(command).toBeInstanceOf(GameCommand);
});

This is the error I receive when running the test:

    tests/components/commands/CommandFactory.test.ts:7:7 - error TS2322: Type '{ commandName: string; }' is not assignable to type 'MockedObject<ChatInputCommandInteraction<CacheType>>'.
      Type '{ commandName: string; }' is missing the following properties from type '{ commandType: ApplicationCommandType.ChatInput; options: MockedObject<Omit<CommandInteractionOptionResolver<CacheType>, "getMessage" | "getFocused">>; ... 50 more ...; valueOf: MockedFunction<...>; }': commandType, options, inGuild, inCachedGuild, and 46 more.

I'm new to Jest and unit testing in general. I want to mock this ChatInputCommandInteraction class and I want to set the value of its property 'commandName' to 'add-game'. Googling got me this far, but I don't really know what to search for.

How do I properly mock an instantiation of a class and alter/set the value of one of its properties?

I'm using Jest and Typescript.


Solution

  • The easy fix you can try, but it depends what else in the dependencies use it is to try:

    const interaction: ChatInputCommandInteraction = { commandName: 'add-game',} as ChatInputCommandInteraction;
    

    Now the long story.

    Your factory class is tightly coupled with commands and specific command implementations. You can try to isolate one or more of them and test only what is the factory's responsibility.

    You can have CommandFactory with a constructor with default values for the commands and the commands classes/constructors like this:

    // interface which tells the common constructor
    type CommandInterfaceConstructor = new (interaction: ChatInputCommandInteraction, command: CommandDefinittion) => CommandInterface
    
    const defaultCommandMapping: Map<string, CommandInterfaceConstructor> = new Map([
        ["SettingCommand", SettingCommand],
        ["GameCommand", GameCommand],
        ["SetupCommand", SetupCommand],
    ])
    
    export default class CommandFactory {
        constructor(
          private commanMap = defaultCommandMapping,
          private configCommands = commands
        ) {}
    }
    

    then you have to change the logic to use the private CommandFactory injected dependencies

    
        public getCommand(interaction: ChatInputCommandInteraction): CommandInterface {
    
            const configCommand = this.configCommands.find(x => x.name === interaction.commandName);
    
            if (configCommand) {
                const Constructor = this.commanMap.get(configCommand.classType);
                if (Constructor) {
                    return new Constructor(interaction, configCommand);
                }
            }
            throw new Error(`Unexpected '${interaction.commandName}' command.`);
        }
    

    How the test would look like?

    class SettingCommandMocked implements CommandInterface {} 
    class GameCommandMocked implements CommandInterface {} 
    class SetupCommandMocked implements CommandInterface {} 
    
    const testMappings = new Map([
        ["SettingCommand", SettingCommandMocked],
        ["GameCommand", GameCommandMocked],
        ["SetupCommand", SetupCommandMocked],
    ])
    
    
    const getInteraction = (commandName: string): ChatInputCommandInteraction => {
        return { commandName } as ChatInputCommandInteraction;
    } 
    
    test('Instantiates GameCommand', () => {
        //setup
        // now you have provided mappings yourself
        const factory = new CommandFactory(testMappings);
    
        //act
        const interaction = getInteraction("add-game");
        const command = factory.getCommand(interaction);
    
        //assert
        expect(command).toBeInstanceOf(GameCommandMocked);
    });