diff options
Diffstat (limited to '')
| -rw-r--r-- | tests/helpers.py | 253 | ||||
| -rw-r--r-- | tests/test_helpers.py | 58 | 
2 files changed, 150 insertions, 161 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()) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 62007ff4e..2b58634dd 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -221,7 +221,7 @@ class DiscordMocksTests(unittest.TestCase):      @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest')      def test_the_custom_mock_methods_test(self, subtest_mock):          """The custom method test should raise AssertionError for invalid methods.""" -        class FakeMockBot(helpers.GetChildMockMixin, unittest.mock.MagicMock): +        class FakeMockBot(helpers.CustomMockMixin, unittest.mock.MagicMock):              """Fake MockBot class with invalid attribute/method `release_the_walrus`."""              child_mock_type = unittest.mock.MagicMock @@ -331,9 +331,9 @@ class MockObjectTests(unittest.TestCase):                  self.assertFalse(instance_one != instance_two)                  self.assertTrue(instance_one != instance_three) -    def test_get_child_mock_mixin_accepts_mock_seal(self): -        """The `GetChildMockMixin` should support `unittest.mock.seal`.""" -        class MyMock(helpers.GetChildMockMixin, unittest.mock.MagicMock): +    def test_custom_mock_mixin_accepts_mock_seal(self): +        """The `CustomMockMixin` should support `unittest.mock.seal`.""" +        class MyMock(helpers.CustomMockMixin, unittest.mock.MagicMock):              child_mock_type = unittest.mock.MagicMock              pass @@ -351,6 +351,10 @@ class MockObjectTests(unittest.TestCase):              (helpers.MockMember, "display_name"),              (helpers.MockBot, "owner_id"),              (helpers.MockContext, "command_failed"), +            (helpers.MockMessage, "mention_everyone"), +            (helpers.MockEmoji, 'managed'), +            (helpers.MockPartialEmoji, 'url'), +            (helpers.MockReaction, 'me'),          )          for mock_type, valid_attribute in test_values: @@ -360,6 +364,52 @@ class MockObjectTests(unittest.TestCase):                  attribute = getattr(mock, valid_attribute)                  self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) +    def test_extract_coroutine_methods_from_spec_instance_should_extract_all_and_only_coroutines(self): +        """Test if all coroutine functions are extracted, but not regular methods or attributes.""" +        class CoroutineDonor: +            def __init__(self): +                self.some_attribute = 'alpha' + +            async def first_coroutine(): +                """This coroutine function should be extracted.""" + +            async def second_coroutine(): +                """This coroutine function should be extracted.""" + +            def regular_method(): +                """This regular function should not be extracted.""" + +        class Receiver: +            pass + +        donor = CoroutineDonor() +        receiver = Receiver() + +        helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance(receiver, donor) + +        self.assertIsInstance(receiver.first_coroutine, helpers.AsyncMock) +        self.assertIsInstance(receiver.second_coroutine, helpers.AsyncMock) +        self.assertFalse(hasattr(receiver, 'regular_method')) +        self.assertFalse(hasattr(receiver, 'some_attribute')) + +    @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) +    @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") +    def test_custom_mock_mixin_init_with_spec(self, extract_method_mock): +        """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" +        spec = "pydis" + +        helpers.CustomMockMixin(spec=spec) + +        extract_method_mock.assert_called_once_with(spec) + +    @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) +    @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") +    def test_custom_mock_mixin_init_without_spec(self, extract_method_mock): +        """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" +        helpers.CustomMockMixin() + +        extract_method_mock.assert_not_called() +      def test_async_mock_provides_coroutine_for_dunder_call(self):          """Test if AsyncMock objects have a coroutine for their __call__ method."""          async_mock = helpers.AsyncMock()  |