aboutsummaryrefslogtreecommitdiffstats
path: root/tests/helpers.py
diff options
context:
space:
mode:
authorGravatar Sebastiaan Zeeff <[email protected]>2019-10-29 10:04:37 +0100
committerGravatar Sebastiaan Zeeff <[email protected]>2019-10-30 22:21:30 +0100
commit618ba6a523dababde230382e1965ecc89f23aaf5 (patch)
tree52e76628c371b46000bc12afeabd59ae38e33861 /tests/helpers.py
parentChange generation of child mocks (diff)
Enhance custom mock helpers
I have enhanced the custom mocks defined in `tests/helpers.py` in a couple of important ways. 1. Automatically create AsyncMock attributes using `inspect` Our previous approach, hard-coding AsynckMock attributes for all the coroutine function methods defined for the class we are trying to mock is prone to human error and not resilient against changes introduced in updates of the library we are using. Instead, I have now created a helper method in our `CustomMockMixin` (formerly `GetChildMockMixin`) that automatically inspects the spec instance we've passed for `coroutine functions` using the `inspect` module. It then sets the according attributes with instances of the AsyncMock class. There is one caveat: `discord.py` very rarely defines regular methods that return a coroutine object. Since the returned coroutine should still be awaited, these regular methods should also be mocked with an AsyncMock. However, since they are regular methods, `inspect` does not detect them and they have to be added manually. (The only case of this I've found so far is `Client.wait_for`.) 2. Properly set special attributes using `kwargs.get` As we want attributes that point to other discord.py objects to use our custom mocks (.e.g, `Message.author` should use `MockMember`), the `__init__` method of our custom mocks make sure to correctly instantiate these attributes. However, the way we previously did that means we can't instantiate the custom mock with a mock instance we provide, since this special instantiation would overwrite the custom object we'd passed. I've solved this by using `kwargs.get`, with a new mock as the default value. This makes sure we only create a new mock if we didn't pass a custom one: ```py class MockMesseage: def __init__(self, **kwargs): self.author = kwargs.get('author', MockMember()) ``` As you can see, we will only create a new MockMember if we did not pass an `author` argument. 3. Factoring out duplicate lines Since our `CustomMockMixin` is a parent to all of our custom mock types, it makes sense to use it to factor out common code of all of our custom mocks. I've made the following changes: - Set a default child mock type in the mixin. - Create an `__init__` that takes care of the `inspect` of point 1 This means we won't have to repeat this in all of the child classes. 4. Three new Mock types: Emoji, PartialEmoji, and Reaction I have added three more custom mocks: - MockEmoji - MockPartialEmoji - MockReaction
Diffstat (limited to 'tests/helpers.py')
-rw-r--r--tests/helpers.py253
1 files changed, 96 insertions, 157 deletions
diff --git a/tests/helpers.py b/tests/helpers.py
index 9375d0986..673beae3f 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -2,8 +2,9 @@ from __future__ import annotations
import asyncio
import functools
+import inspect
import unittest.mock
-from typing import Iterable, Optional
+from typing import Any, Iterable, Optional
import discord
from discord.ext.commands import Bot, Context
@@ -48,9 +49,16 @@ class ColourMixin:
self.colour = color
-class GetChildMockMixin:
+class CustomMockMixin:
"""Ensures attributes of our mock types will be instantiated with the correct mock type."""
+ child_mock_type = unittest.mock.MagicMock
+
+ def __init__(self, spec: Any = None, **kwargs):
+ super().__init__(spec=spec, **kwargs)
+ if spec:
+ self._extract_coroutine_methods_from_spec_instance(spec)
+
def _get_child_mock(self, **kw):
"""
Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes.
@@ -72,17 +80,20 @@ class GetChildMockMixin:
return klass(**kw)
+ def _extract_coroutine_methods_from_spec_instance(self, source: Any) -> None:
+ """Automatically detect coroutine functions in `source` and set them as AsyncMock attributes."""
+ for name, _method in inspect.getmembers(source, inspect.iscoroutinefunction):
+ setattr(self, name, AsyncMock())
+
# TODO: Remove me in Python 3.8
-class AsyncMock(GetChildMockMixin, unittest.mock.MagicMock):
+class AsyncMock(CustomMockMixin, unittest.mock.MagicMock):
"""
A MagicMock subclass to mock async callables.
Python 3.8 will introduce an AsyncMock class in the standard library that will have some more
features; this stand-in only overwrites the `__call__` method to an async version.
"""
- child_mock_type = unittest.mock.MagicMock
-
async def __call__(self, *args, **kwargs):
return super(AsyncMock, self).__call__(*args, **kwargs)
@@ -110,7 +121,7 @@ guild_data = {
guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock())
-class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin):
+class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin):
"""
A `Mock` subclass to mock `discord.Guild` objects.
@@ -136,9 +147,6 @@ class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin):
For more info, see the `Mocking` section in `tests/README.md`.
"""
-
- child_mock_type = unittest.mock.MagicMock
-
def __init__(
self,
guild_id: int = 1,
@@ -158,39 +166,13 @@ class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin):
if members:
self.members.extend(members)
- # `discord.Guild` coroutines
- self.create_category_channel = AsyncMock()
- self.ban = AsyncMock()
- self.bans = AsyncMock()
- self.create_category = AsyncMock()
- self.create_custom_emoji = AsyncMock()
- self.create_role = AsyncMock()
- self.create_text_channel = AsyncMock()
- self.create_voice_channel = AsyncMock()
- self.delete = AsyncMock()
- self.edit = AsyncMock()
- self.estimate_pruned_members = AsyncMock()
- self.fetch_ban = AsyncMock()
- self.fetch_channels = AsyncMock()
- self.fetch_emoji = AsyncMock()
- self.fetch_emojis = AsyncMock()
- self.fetch_member = AsyncMock()
- self.invites = AsyncMock()
- self.kick = AsyncMock()
- self.leave = AsyncMock()
- self.prune_members = AsyncMock()
- self.unban = AsyncMock()
- self.vanity_invite = AsyncMock()
- self.webhooks = AsyncMock()
- self.widget = AsyncMock()
-
# Create a Role instance to get a realistic Mock of `discord.Role`
role_data = {'name': 'role', 'id': 1}
role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data)
-class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):
+class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):
"""
A Mock subclass to mock `discord.Role` objects.
@@ -208,10 +190,6 @@ class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin
self.position = position
self.mention = f'&{self.name}'
- # 'discord.Role' coroutines
- self.delete = AsyncMock()
- self.edit = AsyncMock()
-
def __lt__(self, other):
"""Simplified position-based comparisons similar to those of `discord.Role`."""
return self.position < other.position
@@ -223,16 +201,13 @@ state_mock = unittest.mock.MagicMock()
member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock)
-class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):
+class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):
"""
A Mock subclass to mock Member objects.
Instances of this class will follow the specifications of `discord.Member` instances. For more
information, see the `MockGuild` docstring.
"""
-
- child_mock_type = unittest.mock.MagicMock
-
def __init__(
self,
name: str = "member",
@@ -251,34 +226,18 @@ class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMix
self.mention = f"@{self.name}"
- # `discord.Member` coroutines
- self.add_roles = AsyncMock()
- self.ban = AsyncMock()
- self.edit = AsyncMock()
- self.fetch_message = AsyncMock()
- self.kick = AsyncMock()
- self.move_to = AsyncMock()
- self.pins = AsyncMock()
- self.remove_roles = AsyncMock()
- self.send = AsyncMock()
- self.trigger_typing = AsyncMock()
- self.unban = AsyncMock()
-
# Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot`
bot_instance = Bot(command_prefix=unittest.mock.MagicMock())
-class MockBot(GetChildMockMixin, unittest.mock.MagicMock):
+class MockBot(CustomMockMixin, unittest.mock.MagicMock):
"""
A MagicMock subclass to mock Bot objects.
Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances.
For more information, see the `MockGuild` docstring.
"""
-
- child_mock_type = unittest.mock.MagicMock
-
def __init__(self, **kwargs) -> None:
super().__init__(spec=bot_instance, **kwargs)
@@ -286,69 +245,12 @@ class MockBot(GetChildMockMixin, unittest.mock.MagicMock):
self.http_session = unittest.mock.MagicMock()
self.api_client = unittest.mock.MagicMock()
- # `discord.ext.commands.Bot` coroutines
- self._before_invoke = AsyncMock()
- self._after_invoke = AsyncMock()
- self.application_info = AsyncMock()
- self.change_presence = AsyncMock()
- self.connect = AsyncMock()
- self.close = AsyncMock()
- self.create_guild = AsyncMock()
- self.delete_invite = AsyncMock()
- self.fetch_channel = AsyncMock()
- self.fetch_guild = AsyncMock()
- self.fetch_guilds = AsyncMock()
- self.fetch_invite = AsyncMock()
- self.fetch_user = AsyncMock()
- self.fetch_user_profile = AsyncMock()
- self.fetch_webhook = AsyncMock()
- self.fetch_widget = AsyncMock()
- self.get_context = AsyncMock()
- self.get_prefix = AsyncMock()
- self.invoke = AsyncMock()
- self.is_owner = AsyncMock()
- self.login = AsyncMock()
- self.logout = AsyncMock()
- self.on_command_error = AsyncMock()
- self.on_error = AsyncMock()
- self.process_commands = AsyncMock()
- self.request_offline_members = AsyncMock()
- self.start = AsyncMock()
- self.wait_until_ready = AsyncMock()
+ # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and
+ # and should therefore be awaited. (The documentation calls it a coroutine as well, which
+ # is technically incorrect, since it's a regular def.)
self.wait_for = AsyncMock()
-# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context`
-context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock())
-
-
-class MockContext(GetChildMockMixin, unittest.mock.MagicMock):
- """
- A MagicMock subclass to mock Context objects.
-
- Instances of this class will follow the specifications of `discord.ext.commands.Context`
- instances. For more information, see the `MockGuild` docstring.
- """
-
- child_mock_type = unittest.mock.MagicMock
-
- def __init__(self, **kwargs) -> None:
- super().__init__(spec=context_instance, **kwargs)
- self.bot = MockBot()
- self.guild = MockGuild()
- self.author = MockMember()
- self.command = unittest.mock.MagicMock()
-
- # `discord.ext.commands.Context` coroutines
- self.fetch_message = AsyncMock()
- self.invoke = AsyncMock()
- self.pins = AsyncMock()
- self.reinvoke = AsyncMock()
- self.send = AsyncMock()
- self.send_help = AsyncMock()
- self.trigger_typing = AsyncMock()
-
-
# Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel`
channel_data = {
'id': 1,
@@ -365,39 +267,20 @@ guild = unittest.mock.MagicMock()
channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data)
-class MockTextChannel(GetChildMockMixin, unittest.mock.Mock, HashableMixin):
+class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
"""
A MagicMock subclass to mock TextChannel objects.
Instances of this class will follow the specifications of `discord.TextChannel` instances. For
more information, see the `MockGuild` docstring.
"""
-
- child_mock_type = unittest.mock.MagicMock
-
def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None:
super().__init__(spec=channel_instance, **kwargs)
self.id = channel_id
self.name = name
- self.guild = MockGuild()
+ self.guild = kwargs.get('guild', MockGuild())
self.mention = f"#{self.name}"
- # `discord.TextChannel` coroutines
- self.clone = AsyncMock()
- self.create_invite = AsyncMock()
- self.create_webhook = AsyncMock()
- self.delete = AsyncMock()
- self.delete_messages = AsyncMock()
- self.edit = AsyncMock()
- self.fetch_message = AsyncMock()
- self.invites = AsyncMock()
- self.pins = AsyncMock()
- self.purge = AsyncMock()
- self.send = AsyncMock()
- self.set_permissions = AsyncMock()
- self.trigger_typing = AsyncMock()
- self.webhooks = AsyncMock()
-
# Create a Message instance to get a realistic MagicMock of `discord.Message`
message_data = {
@@ -421,27 +304,83 @@ channel = unittest.mock.MagicMock()
message_instance = discord.Message(state=state, channel=channel, data=message_data)
-class MockMessage(GetChildMockMixin, unittest.mock.MagicMock):
+# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context`
+context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock())
+
+
+class MockContext(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock Context objects.
+
+ Instances of this class will follow the specifications of `discord.ext.commands.Context`
+ instances. For more information, see the `MockGuild` docstring.
+ """
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec=context_instance, **kwargs)
+ self.bot = kwargs.get('bot', MockBot())
+ self.guild = kwargs.get('guild', MockGuild())
+ self.author = kwargs.get('author', MockMember())
+ self.channel = kwargs.get('channel', MockTextChannel())
+ self.command = kwargs.get('command', unittest.mock.MagicMock())
+
+
+class MockMessage(CustomMockMixin, unittest.mock.MagicMock):
"""
A MagicMock subclass to mock Message objects.
Instances of this class will follow the specifications of `discord.Message` instances. For more
information, see the `MockGuild` docstring.
"""
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec=message_instance, **kwargs)
+ self.author = kwargs.get('author', MockMember())
+ self.channel = kwargs.get('channel', MockTextChannel())
- child_mock_type = unittest.mock.MagicMock
+emoji_data = {'require_colons': True, 'managed': True, 'id': 1, 'name': 'hyperlemon'}
+emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data)
+
+
+class MockEmoji(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock Emoji objects.
+
+ Instances of this class will follow the specifications of `discord.Emoji` instances. For more
+ information, see the `MockGuild` docstring.
+ """
def __init__(self, **kwargs) -> None:
- super().__init__(spec=message_instance, **kwargs)
- self.author = MockMember()
- self.channel = MockTextChannel()
-
- # `discord.Message` coroutines
- self.ack = AsyncMock()
- self.add_reaction = AsyncMock()
- self.clear_reactions = AsyncMock()
- self.delete = AsyncMock()
- self.edit = AsyncMock()
- self.pin = AsyncMock()
- self.remove_reaction = AsyncMock()
- self.unpin = AsyncMock()
+ super().__init__(spec=emoji_instance, **kwargs)
+ self.guild = kwargs.get('guild', MockGuild())
+
+ # Get all coroutine functions and set them as AsyncMock attributes
+ self._extract_coroutine_methods_from_spec_instance(emoji_instance)
+
+
+partial_emoji_instance = discord.PartialEmoji(animated=False, name='guido')
+
+
+class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock PartialEmoji objects.
+
+ Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For
+ more information, see the `MockGuild` docstring.
+ """
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec=partial_emoji_instance, **kwargs)
+
+
+reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji())
+
+
+class MockReaction(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock Reaction objects.
+
+ Instances of this class will follow the specifications of `discord.Reaction` instances. For
+ more information, see the `MockGuild` docstring.
+ """
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec=reaction_instance, **kwargs)
+ self.emoji = kwargs.get('emoji', MockEmoji())
+ self.message = kwargs.get('message', MockMessage())