First off thanks in advance! Please let me know if you need anything else such as the pytest fixtures or something else. I didn't want to add unnecessary noise to the question. Sorry if I've passed over a way to do this but I've been searching for about 3 days on google, SO, github, and the discord.py docs but I haven't been able to get it working.
I am trying to make a pytest unit test to ensure that a discord bot is sending a message via on_command_error()
when a command is invoked by a user and a CommandInvokeError
occurs. I have achieved this on commands without required arguments using invoke(ctx)
however, invoke()
can only take ctx
as an argument and not something like invoke(ctx, words=10)
. If an argument is passed it raises a TypeError
like so:
TypeError: Command.invoke() got an unexpected keyword argument 'words'
This unit test works:
@pytest.mark.asyncio
async def test_request_leaderboard_no_game(bot: client.WordDebtBot):
ctx = AsyncMock()
game_cmds_cog = bot.get_cog("Core Gameplay Module")
cmd_err_cog = bot.get_cog("Command Error Handler")
game_cmds_cog.game = None
cmd_err_cog.game = None
with pytest.raises(commands.CommandInvokeError) as err:
await game_cmds_cog.leaderboard.invoke(ctx)
await cmd_err_cog.on_command_error(ctx, err.value)
ctx.send.assert_called_with(String() & Regex("Game not loaded.*"))
This unit test fails with the above TypeError
:
@pytest.mark.asyncio
async def test_submit_words_no_game(
bot: client.WordDebtBot, player: game_lib.WordDebtPlayer
):
ctx = AsyncMock()
ctx.author.id = player.user_id
game_cmds_cog = bot.get_cog("Core Gameplay Module")
cmd_err_cog = bot.get_cog("Command Error Handler")
game_cmds_cog.game = None
cmd_err_cog.game = None
with pytest.raises(commands.CommandInvokeError) as err:
await game_cmds_cog.log.invoke(ctx, words=10)
cmd_err_cog.on_command_error(ctx, err.value)
ctx.send.assert_called_with(String() & Regex("Game not loaded.*"))
The log command in the game_commands cog looks like this:
@commands.command(name="log")
async def log(self, ctx, words: int):
new_debt = self.game.submit_words(str(ctx.author.id), words)
self.journal({"command": "log", "words": words, "user": str(ctx.author.id)})
await ctx.send(f"Logged {words:,} words! New debt: {new_debt:,}")
on_command_error()
looks like this in the cmd_err_handler cog:
@commands.Cog.listener()
async def on_command_error(self, ctx, err: commands.CommandError):
...Other if branches...
elif isinstance(err.__cause__, AttributeError) and not self.game:
await ctx.send("Game not loaded....")
...Other if branches...
I have tried catching the AttributeError
raised (due to game being None) if just .log(ctx, word=10)
is used rather than using invoke()
then making a CommandInvokeError(AttributeError)
and calling on_command_error(ctx, CmdInvkErr)
but this gives:
AssertionError: expected call not found.
for ctx.send.assert_called_with(String() & Regex("Game not loaded.*"))
I have also tried doing a similar thing using .callback()
with similar results.
In addition, I've tried using .dispatch()
in an attempt to manually fire an event for on_command_error()
but again I had similar results.
The intent is that I want the unit test to raise an exception and then either manually fire the event for on_command_error()
or have the event fired for it to catch then ensure it sends the proper "Game not loaded...." message. How would I do this for a bot command that has required arguments?
Note: The bot works as intended, but the unit test fails.
There might be a better way to do this and the solution I found may look messy, but I was able to get around the issue by:
Creating a wrapping command around the command being tested.
Passing the desired argument through the wrapper command to the command being tested.
Adding the wrapped command to the cog containing the original command.
Invoking the wrapping command passing in the AsyncMock context.
The solution looks like this:
@pytest.mark.asyncio
async def test_submit_words_no_game(
bot: client.WordDebtBot, player: game_lib.WordDebtPlayer
):
ctx = AsyncMock()
ctx.author.id = player.user_id
game_cmds_cog = bot.get_cog("Core Gameplay Module")
cmd_err_cog = bot.get_cog("Command Error Handler")
game_cmds_cog.game = None
cmd_err_cog.game = None
@bot.command()
async def log_test_10(ctx, words=10):
await game_cmds_cog.log(ctx, words)
game_cmds_cog.log_test_10 = log_test_10
with pytest.raises(commands.CommandInvokeError) as err:
await game_cmds_cog.log_test_10.invoke(ctx)
await cmd_err_cog.on_command_error(ctx, err.value)
ctx.send.assert_called_with(String() & Regex("Game not loaded.*"))
To my mind this is basically using the wrapping command's CommandInvokeError
instance when the sub command raises it's AttributeError
.