From 042f5b720c904488c6921d6cdfc558c479786e8d Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 29 Aug 2021 23:24:48 -0400 Subject: [PATCH 01/33] tests: stop skipping passing tests Signed-off-by: onerandomusername --- tests/test_logs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_logs.py b/tests/test_logs.py index c97957fe..b781abd1 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -45,7 +45,6 @@ def test_notice_level(log): @pytest.mark.dependency(depends=["create_logger"]) -@pytest.mark.skip() def test_trace_level(log): """Test trace logging level prints a trace response.""" trace_test_phrase = "Getting in the weeds" From d64f03370a446b3445dceddd1a5ac5cbd7ae9cbd Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 30 Aug 2021 00:16:42 -0400 Subject: [PATCH 02/33] tests: test extension and plugin converters Signed-off-by: onerandomusername --- tests/modmail/extensions/__init__.py | 0 .../extensions/test_extension_manager.py | 29 +++++++++++++++++++ .../modmail/extensions/test_plugin_manager.py | 29 +++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 tests/modmail/extensions/__init__.py create mode 100644 tests/modmail/extensions/test_extension_manager.py create mode 100644 tests/modmail/extensions/test_plugin_manager.py diff --git a/tests/modmail/extensions/__init__.py b/tests/modmail/extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/modmail/extensions/test_extension_manager.py b/tests/modmail/extensions/test_extension_manager.py new file mode 100644 index 00000000..c217e6d2 --- /dev/null +++ b/tests/modmail/extensions/test_extension_manager.py @@ -0,0 +1,29 @@ +from copy import copy + +import pytest + +from modmail.extensions.extension_manager import ExtensionConverter +from modmail.utils.extensions import EXTENSIONS, walk_extensions + + +# load EXTENSIONS +EXTENSIONS = copy(EXTENSIONS) +EXTENSIONS.update(walk_extensions()) + + +class TestExtensionConverter: + """Test the extension converter converts extensions properly.""" + + @pytest.fixture(scope="class", name="converter") + def converter(self) -> ExtensionConverter: + """Fixture method for a ExtensionConverter object.""" + return ExtensionConverter() + + @pytest.mark.asyncio + @pytest.mark.parametrize("extension", [e.rsplit(".", 1)[-1] for e in EXTENSIONS.keys()]) + async def test_conversion_success(self, extension: str, converter: ExtensionConverter) -> None: + """Test all extensions in the list are properly converted.""" + converter.source_list = EXTENSIONS + converted = await converter.convert(None, extension) + + assert converted.endswith(extension) diff --git a/tests/modmail/extensions/test_plugin_manager.py b/tests/modmail/extensions/test_plugin_manager.py new file mode 100644 index 00000000..4e10a41c --- /dev/null +++ b/tests/modmail/extensions/test_plugin_manager.py @@ -0,0 +1,29 @@ +from copy import copy + +import pytest + +from modmail.extensions.plugin_manager import PluginConverter +from modmail.utils.plugins import PLUGINS, walk_plugins + + +# load EXTENSIONS +PLUGINS = copy(PLUGINS) +PLUGINS.update(walk_plugins()) + + +class TestExtensionConverter: + """Test the extension converter converts extensions properly.""" + + @pytest.fixture(scope="class", name="converter") + def converter(self) -> PluginConverter: + """Fixture method for a ExtensionConverter object.""" + return PluginConverter() + + @pytest.mark.asyncio + @pytest.mark.parametrize("extension", [e.rsplit(".", 1)[-1] for e in PLUGINS.keys()]) + async def test_conversion_success(self, extension: str, converter: PluginConverter) -> None: + """Test all extensions in the list are properly converted.""" + converter.source_list = PLUGINS + converted = await converter.convert(None, extension) + + assert converted.endswith(extension) From 945475e794ca151aa108c9865d8c9f2f1e64410a Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 30 Aug 2021 00:17:29 -0400 Subject: [PATCH 03/33] nit: annotate as many variables in tests as possible Signed-off-by: onerandomusername --- tests/conftest.py | 2 +- tests/modmail/utils/test_embeds.py | 9 ++++----- tests/test_bot.py | 7 +++---- tests/test_logs.py | 6 +++--- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5cbb797c..759b9108 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import pytest -def pytest_report_header(config): +def pytest_report_header(config) -> str: """Pytest headers.""" return "package: modmail" diff --git a/tests/modmail/utils/test_embeds.py b/tests/modmail/utils/test_embeds.py index aeb256ed..9de5a069 100644 --- a/tests/modmail/utils/test_embeds.py +++ b/tests/modmail/utils/test_embeds.py @@ -1,12 +1,11 @@ import discord import pytest -from discord import Colour from modmail.utils.embeds import patch_embed @pytest.mark.dependency(name="patch_embed") -def test_patch_embed(): +def test_patch_embed() -> None: """Ensure that the function changes init only after the patch is called.""" from modmail.utils.embeds import __init__ as init from modmail.utils.embeds import original_init @@ -17,7 +16,7 @@ def test_patch_embed(): @pytest.mark.dependency(depends_on="patch_embed") -def test_create_embed(): +def test_create_embed() -> None: """Test creating an embed with patched parameters works properly.""" title = "Test title" description = "Test description" @@ -49,14 +48,14 @@ def test_create_embed(): @pytest.mark.dependency(depends_on="patch_embed") -def test_create_embed_with_extra_params(): +def test_create_embed_with_extra_params() -> None: """Test creating an embed with extra parameters errors properly.""" with pytest.raises(TypeError, match="ooga_booga"): discord.Embed("hello", ooga_booga=3) @pytest.mark.dependency(depends_on="patch_embed") -def test_create_embed_with_description_and_content(): +def test_create_embed_with_description_and_content() -> None: """ Create an embed while providing both description and content parameters. diff --git a/tests/test_bot.py b/tests/test_bot.py index cd220cac..22059ef5 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -4,7 +4,6 @@ - import module - create a bot object """ -import asyncio import pytest @@ -13,7 +12,7 @@ @pytest.mark.dependency(name="create_bot") @pytest.mark.asyncio -async def test_bot_creation(): +async def test_bot_creation() -> None: """Ensure we can make a ModmailBot instance.""" bot = ModmailBot() # cleanup @@ -33,7 +32,7 @@ def bot() -> ModmailBot: @pytest.mark.dependency(depends=["create_bot"]) @pytest.mark.asyncio -async def test_bot_close(bot): +async def test_bot_close(bot: ModmailBot) -> None: """Ensure bot closes without error.""" import contextlib import io @@ -46,6 +45,6 @@ async def test_bot_close(bot): @pytest.mark.dependency(depends=["create_bot"]) -def test_bot_main(): +def test_bot_main() -> None: """Import modmail.__main__.""" from modmail.__main__ import main diff --git a/tests/test_logs.py b/tests/test_logs.py index b781abd1..97fffaf1 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -13,7 +13,7 @@ @pytest.mark.dependency(name="create_logger") -def test_create_logging(): +def test_create_logging() -> None: """Modmail logging is importable and sets root logger correctly.""" log = logging.getLogger(__name__) assert isinstance(log, ModmailLogger) @@ -31,7 +31,7 @@ def log() -> ModmailLogger: @pytest.mark.dependency(depends=["create_logger"]) -def test_notice_level(log): +def test_notice_level(log: ModmailLogger) -> None: """Test notice logging level prints a notice response.""" notice_test_phrase = "Kinda important info" stdout = io.StringIO() @@ -45,7 +45,7 @@ def test_notice_level(log): @pytest.mark.dependency(depends=["create_logger"]) -def test_trace_level(log): +def test_trace_level(log: ModmailLogger) -> None: """Test trace logging level prints a trace response.""" trace_test_phrase = "Getting in the weeds" stdout = io.StringIO() From 5b11fb485ac0d9a3571ee9157913435fcf3a1e2f Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 30 Aug 2021 01:08:18 -0400 Subject: [PATCH 04/33] minor: don't use the global ext/plug lists Signed-off-by: onerandomusername --- tests/modmail/extensions/test_extension_manager.py | 11 +++++++---- tests/modmail/extensions/test_plugin_manager.py | 11 +++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/modmail/extensions/test_extension_manager.py b/tests/modmail/extensions/test_extension_manager.py index c217e6d2..717a6b01 100644 --- a/tests/modmail/extensions/test_extension_manager.py +++ b/tests/modmail/extensions/test_extension_manager.py @@ -3,27 +3,30 @@ import pytest from modmail.extensions.extension_manager import ExtensionConverter -from modmail.utils.extensions import EXTENSIONS, walk_extensions +from modmail.utils.extensions import EXTENSIONS as GLOBAL_EXTENSIONS +from modmail.utils.extensions import walk_extensions # load EXTENSIONS -EXTENSIONS = copy(EXTENSIONS) +EXTENSIONS = copy(GLOBAL_EXTENSIONS) EXTENSIONS.update(walk_extensions()) class TestExtensionConverter: """Test the extension converter converts extensions properly.""" + all_extensions = {x: y for x, y in walk_extensions()} + @pytest.fixture(scope="class", name="converter") def converter(self) -> ExtensionConverter: """Fixture method for a ExtensionConverter object.""" return ExtensionConverter() @pytest.mark.asyncio - @pytest.mark.parametrize("extension", [e.rsplit(".", 1)[-1] for e in EXTENSIONS.keys()]) + @pytest.mark.parametrize("extension", [e.rsplit(".", 1)[-1] for e in all_extensions.keys()]) async def test_conversion_success(self, extension: str, converter: ExtensionConverter) -> None: """Test all extensions in the list are properly converted.""" - converter.source_list = EXTENSIONS + converter.source_list = self.all_extensions converted = await converter.convert(None, extension) assert converted.endswith(extension) diff --git a/tests/modmail/extensions/test_plugin_manager.py b/tests/modmail/extensions/test_plugin_manager.py index 4e10a41c..9a6bdd86 100644 --- a/tests/modmail/extensions/test_plugin_manager.py +++ b/tests/modmail/extensions/test_plugin_manager.py @@ -3,27 +3,30 @@ import pytest from modmail.extensions.plugin_manager import PluginConverter -from modmail.utils.plugins import PLUGINS, walk_plugins +from modmail.utils.plugins import PLUGINS as GLOBAL_PLUGINS +from modmail.utils.plugins import walk_plugins # load EXTENSIONS -PLUGINS = copy(PLUGINS) +PLUGINS = copy(GLOBAL_PLUGINS) PLUGINS.update(walk_plugins()) class TestExtensionConverter: """Test the extension converter converts extensions properly.""" + all_plugins = {x: y for x, y in walk_plugins()} + @pytest.fixture(scope="class", name="converter") def converter(self) -> PluginConverter: """Fixture method for a ExtensionConverter object.""" return PluginConverter() @pytest.mark.asyncio - @pytest.mark.parametrize("extension", [e.rsplit(".", 1)[-1] for e in PLUGINS.keys()]) + @pytest.mark.parametrize("extension", [e.rsplit(".", 1)[-1] for e in all_plugins.keys()]) async def test_conversion_success(self, extension: str, converter: PluginConverter) -> None: """Test all extensions in the list are properly converted.""" - converter.source_list = PLUGINS + converter.source_list = self.all_plugins converted = await converter.convert(None, extension) assert converted.endswith(extension) From a11bf2985b789dc27cab9a14d71df9387ad741cb Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 26 Sep 2021 00:30:24 -0400 Subject: [PATCH 05/33] fix: rename extensions to plugin in Plugins test Signed-off-by: onerandomusername --- tests/modmail/extensions/test_plugin_manager.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/modmail/extensions/test_plugin_manager.py b/tests/modmail/extensions/test_plugin_manager.py index 9a6bdd86..eddae16d 100644 --- a/tests/modmail/extensions/test_plugin_manager.py +++ b/tests/modmail/extensions/test_plugin_manager.py @@ -12,21 +12,21 @@ PLUGINS.update(walk_plugins()) -class TestExtensionConverter: +class TestPluginConverter: """Test the extension converter converts extensions properly.""" all_plugins = {x: y for x, y in walk_plugins()} @pytest.fixture(scope="class", name="converter") def converter(self) -> PluginConverter: - """Fixture method for a ExtensionConverter object.""" + """Fixture method for a PluginConverter object.""" return PluginConverter() @pytest.mark.asyncio - @pytest.mark.parametrize("extension", [e.rsplit(".", 1)[-1] for e in all_plugins.keys()]) - async def test_conversion_success(self, extension: str, converter: PluginConverter) -> None: - """Test all extensions in the list are properly converted.""" + @pytest.mark.parametrize("plugin", [e.rsplit(".", 1)[-1] for e in all_plugins.keys()]) + async def test_conversion_success(self, plugin: str, converter: PluginConverter) -> None: + """Test all plugins in the list are properly converted.""" converter.source_list = self.all_plugins - converted = await converter.convert(None, extension) + converted = await converter.convert(None, plugin) - assert converted.endswith(extension) + assert converted.endswith(plugin) From b4d8775edc3a97944b76e22cdd4af903846a0ca2 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Fri, 15 Oct 2021 23:04:41 -0400 Subject: [PATCH 06/33] tests(mocks): copy pydis/bot mock classes on top of pydis changes, adds discord.Thread mock object and fixes some docstrings. --- modmail/bot.py | 16 +- tests/mocks.py | 657 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_mocks.py | 401 +++++++++++++++++++++++++++ 3 files changed, 1067 insertions(+), 7 deletions(-) create mode 100644 tests/mocks.py create mode 100644 tests/test_mocks.py diff --git a/modmail/bot.py b/modmail/bot.py index e62f1bda..93447437 100644 --- a/modmail/bot.py +++ b/modmail/bot.py @@ -49,14 +49,16 @@ def __init__(self, **kwargs): # allow only user mentions by default. # ! NOTE: This may change in the future to allow roles as well allowed_mentions = AllowedMentions(everyone=False, users=True, roles=False, replied_user=True) + # override passed kwargs if they are None + kwargs["case_insensitive"] = kwargs.get("case_insensitive", True) + # do not let the description be overridden. + kwargs["description"] = "Modmail bot by discord-modmail." + kwargs["status"] = kwargs.get("status", status) + kwargs["activity"] = kwargs.get("activity", activity) + kwargs["allowed_mentions"] = kwargs.get("allowed_mentions", allowed_mentions) + kwargs["command_prefix"] = kwargs.get("command_prefix", prefix) + kwargs["intents"] = kwargs.get("intents", REQUIRED_INTENTS) super().__init__( - case_insensitive=True, - description="Modmail bot by discord-modmail.", - status=status, - activity=activity, - allowed_mentions=allowed_mentions, - command_prefix=prefix, - intents=REQUIRED_INTENTS, **kwargs, ) diff --git a/tests/mocks.py b/tests/mocks.py new file mode 100644 index 00000000..3464d0eb --- /dev/null +++ b/tests/mocks.py @@ -0,0 +1,657 @@ +""" +Helper methods for testing. + +Slight modifications have been made to support our bot. + +Original Source: +https://github.com/python-discord/bot/blob/d183d03fa2939bebaac3da49646449fdd4d00e6c/tests/helpers.py# noqa: E501 + +MIT License + +Copyright (c) 2018 Python Discord + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from __future__ import annotations + +import collections +import itertools +import logging +import unittest.mock +from asyncio import AbstractEventLoop +from typing import TYPE_CHECKING, Iterable, Optional + +import discord +import discord.mixins +from aiohttp import ClientSession +from discord.ext.commands import Context + +import modmail.bot + + +for logger in logging.Logger.manager.loggerDict.values(): + # Set all loggers to CRITICAL by default to prevent screen clutter during testing + + if not isinstance(logger, logging.Logger): + # There might be some logging.PlaceHolder objects in there + continue + + logger.setLevel(logging.CRITICAL) + + +class HashableMixin(discord.mixins.EqualityComparable): + """ + Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. + + Given that most of our features need the created_at function to work, we are typically using + full fake discord ids, and so we still bitshift the id like Dpy does. + + However, given that the hash of 4>>22 and 5>>22 is the same, we check if the number is above 22. + If so, we hash it. This could result in some weird behavior with the hash of + 1<<22 + 1 equaling 1. + """ + + if TYPE_CHECKING: + id: int + + def __hash__(self): + if self.id < 1 << 22: + return self.id + else: + return self.id >> 22 + + +class ColourMixin: + """A mixin for Mocks that provides the aliasing of (accent_)color->(accent_)colour like discord.py.""" + + @property + def color(self) -> discord.Colour: + """Alias of colour.""" + return self.colour + + @color.setter + def color(self, color: discord.Colour) -> None: + self.colour = color + + @property + def accent_color(self) -> discord.Colour: + """Alias of accent_colour.""" + return self.accent_colour + + @accent_color.setter + def accent_color(self, color: discord.Colour) -> None: + self.accent_colour = color + + +class CustomMockMixin: + """ + Provides common functionality for our custom Mock types. + + The `_get_child_mock` method automatically returns an AsyncMock for coroutine methods of the mock + object. As discord.py also uses synchronous methods that nonetheless return coroutine objects, the + class attribute `additional_spec_asyncs` can be overwritten with an iterable containing additional + attribute names that should also mocked with an AsyncMock instead of a regular MagicMock/Mock. The + class method `spec_set` can be overwritten with the object that should be uses as the specification + for the mock. + + Mock/MagicMock subclasses that use this mixin only need to define `__init__` method if they need to + implement custom behavior. + """ + + child_mock_type = unittest.mock.MagicMock + discord_id = itertools.count(0) + spec_set = None + additional_spec_asyncs = None + + def __init__(self, **kwargs): + name = kwargs.pop( + "name", None + ) # `name` has special meaning for Mock classes, so we need to set it manually. + super().__init__(spec_set=self.spec_set, **kwargs) + + if self.additional_spec_asyncs: + self._spec_asyncs.extend(self.additional_spec_asyncs) + + if name: + self.name = name + + def _get_child_mock(self, **kw): + """ + Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes. + + Mock objects automatically create children when you access an attribute or call a method on them. + By default, the class of these children is the type of the parent itself. + However, this would mean that the children created for our custom mock types would also be instances + of that custom mock type. This is not desirable, as attributes of, e.g., a `Bot` object are not + `Bot` objects themselves. The Python docs for `unittest.mock` hint that overwriting this method is the + best way to deal with that. + + This override will look for an attribute called `child_mock_type` and + use that as the type of the child mock. + """ + _new_name = kw.get("_new_name") + if _new_name in self.__dict__["_spec_asyncs"]: + return unittest.mock.AsyncMock(**kw) + + _type = type(self) + if issubclass(_type, unittest.mock.MagicMock) and _new_name in unittest.mock._async_method_magics: + # Any asynchronous magic becomes an AsyncMock + klass = unittest.mock.AsyncMock + else: + klass = self.child_mock_type + + if self._mock_sealed: + attribute = "." + kw["name"] if "name" in kw else "()" + mock_name = self._extract_mock_name() + attribute + raise AttributeError(mock_name) + + return klass(**kw) + + +# Create a guild instance to get a realistic Mock of `discord.Guild` +guild_data = { + "id": 1, + "name": "guild", + "region": "Europe", + "verification_level": 2, + "default_notications": 1, + "afk_timeout": 100, + "icon": "icon.png", + "banner": "banner.png", + "mfa_level": 1, + "splash": "splash.png", + "system_channel_id": 464033278631084042, + "description": "mocking is fun", + "max_presences": 10_000, + "max_members": 100_000, + "preferred_locale": "UTC", + "owner_id": 1, + "afk_channel_id": 464033278631084042, +} +guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) + + +class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): + """ + A `Mock` subclass to mock `discord.Guild` objects. + + A MockGuild instance will follow the specifications of a `discord.Guild` instance. This means + that if the code you're testing tries to access an attribute or method that normally does not + exist for a `discord.Guild` object this will raise an `AttributeError`. This is to make sure our + tests fail if the code we're testing uses a `discord.Guild` object in the wrong way. + + One restriction of that is that if the code tries to access an attribute that normally does not + exist for `discord.Guild` instance but was added dynamically, this will raise an exception with + the mocked object. To get around that, you can set the non-standard attribute explicitly for the + instance of `MockGuild`: + + >>> guild = MockGuild() + >>> guild.attribute_that_normally_does_not_exist = unittest.mock.MagicMock() + + In addition to attribute simulation, mocked guild object will pass an `isinstance` check against + `discord.Guild`: + + >>> guild = MockGuild() + >>> isinstance(guild, discord.Guild) + True + + For more info, see the `Mocking` section in `tests/README.md`. + """ + + spec_set = guild_instance + + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id), "members": []} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + self.roles = [MockRole(name="@everyone", position=1, id=0)] + if roles: + self.roles.extend(roles) + + +# 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(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock `discord.Role` objects. + + Instances of this class will follow the specifications of `discord.Role` instances. For more + information, see the `MockGuild` docstring. + """ + + spec_set = role_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = { + "id": next(self.discord_id), + "name": "role", + "position": 1, + "colour": discord.Colour(0xDEADBF), + "permissions": discord.Permissions(), + } + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + if isinstance(self.colour, int): + self.colour = discord.Colour(self.colour) + + if isinstance(self.permissions, int): + self.permissions = discord.Permissions(self.permissions) + + if "mention" not in kwargs: + self.mention = f"&{self.name}" + + def __lt__(self, other): + """Simplified position-based comparisons similar to those of `discord.Role`.""" + return self.position < other.position + + def __ge__(self, other): + """Simplified position-based comparisons similar to those of `discord.Role`.""" + return self.position >= other.position + + +# Create a Member instance to get a realistic Mock of `discord.Member` +member_data = {"user": "lemon", "roles": [1]} +state_mock = unittest.mock.MagicMock() +member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) + + +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. + """ + + spec_set = member_instance + + def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: + default_kwargs = {"name": "member", "id": next(self.discord_id), "bot": False, "pending": False} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + self.roles = [MockRole(name="@everyone", position=1, id=0)] + if roles: + self.roles.extend(roles) + self.top_role = max(self.roles) + + if "mention" not in kwargs: + self.mention = f"@{self.name}" + + +# Create a User instance to get a realistic Mock of `discord.User` +_user_data_mock = collections.defaultdict(unittest.mock.MagicMock, {"accent_color": 0}) +user_instance = discord.User( + data=unittest.mock.MagicMock(get=unittest.mock.Mock(side_effect=_user_data_mock.get)), + state=unittest.mock.MagicMock(), +) + + +class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock User objects. + + Instances of this class will follow the specifications of `discord.User` instances. For more + information, see the `MockGuild` docstring. + """ + + spec_set = user_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"name": "user", "id": next(self.discord_id), "bot": False} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + if "mention" not in kwargs: + self.mention = f"@{self.name}" + + +def _get_mock_loop() -> unittest.mock.Mock: + """Return a mocked asyncio.AbstractEventLoop.""" + loop = unittest.mock.create_autospec(spec=AbstractEventLoop, spec_set=True) + + # Since calling `create_task` on our MockBot does not actually schedule the coroutine object + # as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object + # to prevent "has not been awaited"-warnings. + def mock_create_task(coroutine, **kwargs): + coroutine.close() + return unittest.mock.Mock() + + loop.create_task.side_effect = mock_create_task + + return loop + + +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. + """ + + spec_set = modmail.bot.ModmailBot( + command_prefix=unittest.mock.MagicMock(), + loop=_get_mock_loop(), + ) + additional_spec_asyncs = ("wait_for", "redis_ready") + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + self.loop = _get_mock_loop() + self.http_session = unittest.mock.create_autospec(spec=ClientSession, spec_set=True) + + +# Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` +channel_data = { + "id": 1, + "type": "TextChannel", + "name": "channel", + "parent_id": 1234567890, + "topic": "topic", + "position": 1, + "nsfw": False, + "last_message_id": 1, +} +state = unittest.mock.MagicMock() +guild = unittest.mock.MagicMock() +text_channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) + +channel_data["type"] = "VoiceChannel" +voice_channel_instance = discord.VoiceChannel(state=state, guild=guild, data=channel_data) + + +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. + """ + + spec_set = text_channel_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id), "name": "channel", "guild": MockGuild()} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + if "mention" not in kwargs: + self.mention = f"#{self.name}" + + +class MockVoiceChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): + """ + A MagicMock subclass to mock VoiceChannel objects. + + Instances of this class will follow the specifications of `discord.VoiceChannel` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = voice_channel_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id), "name": "channel", "guild": MockGuild()} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + if "mention" not in kwargs: + self.mention = f"#{self.name}" + + +# Create data for the DMChannel instance +state = unittest.mock.MagicMock() +me = unittest.mock.MagicMock() +dm_channel_data = {"id": 1, "recipients": [unittest.mock.MagicMock()]} +dm_channel_instance = discord.DMChannel(me=me, state=state, data=dm_channel_data) + + +class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): + """ + A MagicMock subclass to mock DMChannel objects. + + Instances of this class will follow the specifications of `discord.DMChannel` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = dm_channel_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id), "recipient": MockUser(), "me": MockUser()} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + +# Create CategoryChannel instance to get a realistic MagicMock of `discord.CategoryChannel` +category_channel_data = { + "id": 1, + "type": discord.ChannelType.category, + "name": "category", + "position": 1, +} + +state = unittest.mock.MagicMock() +guild = unittest.mock.MagicMock() +category_channel_instance = discord.CategoryChannel(state=state, guild=guild, data=category_channel_data) + + +class MockCategoryChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): + """ + A MagicMock subclass to mock CategoryChannel objects. + + Instances of this class will follow the specifications of `discord.CategoryChannel` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = category_channel_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id)} + super().__init__(**collections.ChainMap(default_kwargs, kwargs)) + + +# Create a thread instance to get a realistic MagicMock of `discord.Thread` +thread_metadata = { + "archived": False, + "archiver_id": None, + "auto_archive_duration": 1440, + "archive_timestamp": "2021-10-17T20:35:48.058121+00:00", +} +thread_data = { + "id": "898070829617799179", + "parent_id": "898070393619902544", + "owner_id": "717983911824588862", + "name": "user-0005", + "type": discord.ChannelType.public_thread, + "last_message_id": None, + "message_count": 1, + "member_count": 2, + "thread_metadata": thread_metadata, +} + +state = unittest.mock.MagicMock() +guild = unittest.mock.MagicMock() +thread_instance = discord.Thread(state=state, guild=guild, data=thread_data) + + +class MockThread(CustomMockMixin, unittest.mock.Mock, HashableMixin): + """ + A MagicMock subclass to mock Thread objects. + + Instances of this class will follow the specifications of `discord.Thread` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = thread_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id)} + super().__init__(**collections.ChainMap(default_kwargs, kwargs)) + + +# Create a Message instance to get a realistic MagicMock of `discord.Message` +message_data = { + "id": 1, + "webhook_id": 898069816622067752, + "attachments": [], + "embeds": [], + "application": "Discord Modmail", + "activity": "mocking", + "channel": unittest.mock.MagicMock(), + "edited_timestamp": "2019-10-14T15:33:48+00:00", + "type": "message", + "pinned": False, + "mention_everyone": False, + "tts": None, + "content": "content", + "nonce": None, +} +state = unittest.mock.MagicMock() +channel = unittest.mock.MagicMock() +message_instance = discord.Message(state=state, channel=channel, data=message_data) + + +# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` +context_instance = Context(message=unittest.mock.MagicMock(), prefix="$", bot=MockBot(), view=None) +context_instance.invoked_from_error_handler = None + + +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. + """ + + spec_set = context_instance + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.me = kwargs.get("me", MockMember()) + 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.message = kwargs.get("message", MockMessage()) + self.invoked_from_error_handler = kwargs.get("invoked_from_error_handler", False) + + +attachment_instance = discord.Attachment(data=unittest.mock.MagicMock(id=1), state=unittest.mock.MagicMock()) + + +class MockAttachment(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Attachment objects. + + Instances of this class will follow the specifications of `discord.Attachment` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = attachment_instance + + +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. + """ + + spec_set = message_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"attachments": []} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + self.author = kwargs.get("author", MockMember()) + self.channel = kwargs.get("channel", MockTextChannel()) + + +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. + """ + + spec_set = emoji_instance + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.guild = kwargs.get("guild", MockGuild()) + + +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. + """ + + spec_set = partial_emoji_instance + + +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. + """ + + spec_set = reaction_instance + + def __init__(self, **kwargs) -> None: + _users = kwargs.pop("users", []) + super().__init__(**kwargs) + self.emoji = kwargs.get("emoji", MockEmoji()) + self.message = kwargs.get("message", MockMessage()) + + user_iterator = unittest.mock.AsyncMock() + user_iterator.__aiter__.return_value = _users + self.users.return_value = user_iterator + + self.__str__.return_value = str(self.emoji) + + +webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), session=unittest.mock.MagicMock()) + + +class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Webhook objects using an AsyncWebhookAdapter. + + Instances of this class will follow the specifications of `discord.Webhook` instances. For + more information, see the `MockGuild` docstring. + """ + + spec_set = webhook_instance + additional_spec_asyncs = ("send", "edit", "delete", "execute") diff --git a/tests/test_mocks.py b/tests/test_mocks.py new file mode 100644 index 00000000..b50c6c98 --- /dev/null +++ b/tests/test_mocks.py @@ -0,0 +1,401 @@ +""" +Meta test file for tests/mocks.py. + +Original Source: +https://github.com/python-discord/bot/blob/d183d03fa2939bebaac3da49646449fdd4d00e6c/tests/test_helpers.py # noqa: E501 + +MIT License + +Copyright (c) 2018 Python Discord + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import asyncio +import unittest +import unittest.mock + +import discord + +from tests import mocks as test_mocks + + +class DiscordMocksTests(unittest.TestCase): + """Tests for our specialized discord.py mocks.""" + + def test_mock_role_default_initialization(self): + """Test if the default initialization of MockRole results in the correct object.""" + role = test_mocks.MockRole() + + # The `spec` argument makes sure `isistance` checks with `discord.Role` pass + self.assertIsInstance(role, discord.Role) + + self.assertEqual(role.name, "role") + self.assertEqual(role.position, 1) + self.assertEqual(role.mention, "&role") + + def test_mock_role_alternative_arguments(self): + """Test if MockRole initializes with the arguments provided.""" + role = test_mocks.MockRole( + name="Admins", + id=90210, + position=10, + ) + + self.assertEqual(role.name, "Admins") + self.assertEqual(role.id, 90210) + self.assertEqual(role.position, 10) + self.assertEqual(role.mention, "&Admins") + + def test_mock_role_accepts_dynamic_arguments(self): + """Test if MockRole accepts and sets abitrary keyword arguments.""" + role = test_mocks.MockRole( + guild="Dino Man", + hoist=True, + ) + + self.assertEqual(role.guild, "Dino Man") + self.assertTrue(role.hoist) + + def test_mock_role_uses_position_for_less_than_greater_than(self): + """Test if `<` and `>` comparisons for MockRole are based on its position attribute.""" + role_one = test_mocks.MockRole(position=1) + role_two = test_mocks.MockRole(position=2) + role_three = test_mocks.MockRole(position=3) + + self.assertLess(role_one, role_two) + self.assertLess(role_one, role_three) + self.assertLess(role_two, role_three) + self.assertGreater(role_three, role_two) + self.assertGreater(role_three, role_one) + self.assertGreater(role_two, role_one) + + def test_mock_member_default_initialization(self): + """Test if the default initialization of Mockmember results in the correct object.""" + member = test_mocks.MockMember() + + # The `spec` argument makes sure `isistance` checks with `discord.Member` pass + self.assertIsInstance(member, discord.Member) + + self.assertEqual(member.name, "member") + self.assertListEqual(member.roles, [test_mocks.MockRole(name="@everyone", position=1, id=0)]) + self.assertEqual(member.mention, "@member") + + def test_mock_member_alternative_arguments(self): + """Test if MockMember initializes with the arguments provided.""" + core_developer = test_mocks.MockRole(name="Core Developer", position=2) + member = test_mocks.MockMember(name="Mark", id=12345, roles=[core_developer]) + + self.assertEqual(member.name, "Mark") + self.assertEqual(member.id, 12345) + self.assertListEqual( + member.roles, [test_mocks.MockRole(name="@everyone", position=1, id=0), core_developer] + ) + self.assertEqual(member.mention, "@Mark") + + def test_mock_member_accepts_dynamic_arguments(self): + """Test if MockMember accepts and sets abitrary keyword arguments.""" + member = test_mocks.MockMember( + nick="Dino Man", + colour=discord.Colour.default(), + ) + + self.assertEqual(member.nick, "Dino Man") + self.assertEqual(member.colour, discord.Colour.default()) + + def test_mock_guild_default_initialization(self): + """Test if the default initialization of Mockguild results in the correct object.""" + guild = test_mocks.MockGuild() + + # The `spec` argument makes sure `isistance` checks with `discord.Guild` pass + self.assertIsInstance(guild, discord.Guild) + + self.assertListEqual(guild.roles, [test_mocks.MockRole(name="@everyone", position=1, id=0)]) + self.assertListEqual(guild.members, []) + + def test_mock_guild_alternative_arguments(self): + """Test if MockGuild initializes with the arguments provided.""" + core_developer = test_mocks.MockRole(name="Core Developer", position=2) + guild = test_mocks.MockGuild( + roles=[core_developer], + members=[test_mocks.MockMember(id=54321)], + ) + + self.assertListEqual( + guild.roles, [test_mocks.MockRole(name="@everyone", position=1, id=0), core_developer] + ) + self.assertListEqual(guild.members, [test_mocks.MockMember(id=54321)]) + + def test_mock_guild_accepts_dynamic_arguments(self): + """Test if MockGuild accepts and sets abitrary keyword arguments.""" + guild = test_mocks.MockGuild( + emojis=(":hyperjoseph:", ":pensive_ela:"), + premium_subscription_count=15, + ) + + self.assertTupleEqual(guild.emojis, (":hyperjoseph:", ":pensive_ela:")) + self.assertEqual(guild.premium_subscription_count, 15) + + def test_mock_bot_default_initialization(self): + """Tests if MockBot initializes with the correct values.""" + bot = test_mocks.MockBot() + + # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Bot` pass + self.assertIsInstance(bot, discord.ext.commands.Bot) + + def test_mock_context_default_initialization(self): + """Tests if MockContext initializes with the correct values.""" + context = test_mocks.MockContext() + + # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Context` pass + self.assertIsInstance(context, discord.ext.commands.Context) + + self.assertIsInstance(context.bot, test_mocks.MockBot) + self.assertIsInstance(context.guild, test_mocks.MockGuild) + self.assertIsInstance(context.author, test_mocks.MockMember) + + def test_mocks_allows_access_to_attributes_part_of_spec(self): + """Accessing attributes that are valid for the objects they mock should succeed.""" + mocks = ( + (test_mocks.MockGuild(), "name"), + (test_mocks.MockRole(), "hoist"), + (test_mocks.MockMember(), "display_name"), + (test_mocks.MockBot(), "user"), + (test_mocks.MockContext(), "invoked_with"), + (test_mocks.MockTextChannel(), "last_message"), + (test_mocks.MockMessage(), "mention_everyone"), + ) + + for mock, valid_attribute in mocks: + with self.subTest(mock=mock): + try: + getattr(mock, valid_attribute) + except AttributeError: + msg = f"accessing valid attribute `{valid_attribute}` raised an AttributeError" + self.fail(msg) + + @unittest.mock.patch(f"{__name__}.DiscordMocksTests.subTest") + @unittest.mock.patch(f"{__name__}.getattr") + def test_mock_allows_access_to_attributes_test(self, mock_getattr, mock_subtest): + """The valid attribute test should raise an AssertionError after an AttributeError.""" + mock_getattr.side_effect = AttributeError + + msg = "accessing valid attribute `name` raised an AttributeError" + with self.assertRaises(AssertionError, msg=msg): + self.test_mocks_allows_access_to_attributes_part_of_spec() + + def test_mocks_rejects_access_to_attributes_not_part_of_spec(self): + """Accessing attributes that are invalid for the objects they mock should fail.""" + mocks = ( + test_mocks.MockGuild(), + test_mocks.MockRole(), + test_mocks.MockMember(), + test_mocks.MockBot(), + test_mocks.MockContext(), + test_mocks.MockTextChannel(), + test_mocks.MockMessage(), + ) + + for mock in mocks: + with self.subTest(mock=mock): + with self.assertRaises(AttributeError): + mock.the_cake_is_a_lie + + def test_mocks_use_mention_when_provided_as_kwarg(self): + """The mock should use the passed `mention` instead of the default one if present.""" + test_cases = ( + (test_mocks.MockRole, "role mention"), + (test_mocks.MockMember, "member mention"), + (test_mocks.MockTextChannel, "channel mention"), + ) + + for mock_type, mention in test_cases: + with self.subTest(mock_type=mock_type, mention=mention): + mock = mock_type(mention=mention) + self.assertEqual(mock.mention, mention) + + def test_create_test_on_mock_bot_closes_passed_coroutine(self): + """`bot.loop.create_task` should close the passed coroutine object to prevent warnings.""" + + async def dementati(): + """Dummy coroutine for testing purposes.""" + + coroutine_object = dementati() + + bot = test_mocks.MockBot() + bot.loop.create_task(coroutine_object) + with self.assertRaises(RuntimeError, msg="cannot reuse already awaited coroutine"): + asyncio.run(coroutine_object) + + def test_user_mock_uses_explicitly_passed_mention_attribute(self): + """Ensure MockUser uses an explictly passed value for user.mention.""" + user = test_mocks.MockUser(mention="hello") + self.assertEqual(user.mention, "hello") + + +class MockObjectTests(unittest.TestCase): + """Tests the mock objects and mixins we've defined.""" + + @classmethod + def setUpClass(cls): + """Called by unittest before running the test methods.""" + cls.hashable_mocks = (test_mocks.MockRole, test_mocks.MockMember, test_mocks.MockGuild) + + def test_colour_mixin(self): + """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" + + class MockHemlock(unittest.mock.MagicMock, test_mocks.ColourMixin): + pass + + hemlock = MockHemlock() + hemlock.color = 1 + self.assertEqual(hemlock.colour, 1) + self.assertEqual(hemlock.colour, hemlock.color) + + def test_hashable_mixin_hash_returns_id(self): + """Test the HashableMixing uses the id attribute for hashing.""" + + class MockScragly(unittest.mock.Mock, test_mocks.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 + self.assertEqual(hash(scragly), scragly.id) + + def test_hashable_mixin_hash_returns_id_bitshift(self): + """Test the HashableMixing uses the id attribute for hashing when above 1<<22.""" + + class MockScragly(unittest.mock.Mock, test_mocks.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 << 22 + self.assertEqual(hash(scragly), scragly.id >> 22) + + def test_hashable_mixin_uses_id_for_equality_comparison(self): + """Test the HashableMixing uses the id attribute for equal comparison.""" + + class MockScragly(test_mocks.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 + eevee = MockScragly() + eevee.id = 10 + python = MockScragly() + python.id = 20 + + self.assertTrue(scragly == eevee) + self.assertFalse(scragly == python) + + def test_hashable_mixin_uses_id_for_nonequality_comparison(self): + """Test if the HashableMixing uses the id attribute for non-equal comparison.""" + + class MockScragly(test_mocks.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 + eevee = MockScragly() + eevee.id = 10 + python = MockScragly() + python.id = 20 + + self.assertTrue(scragly != python) + self.assertFalse(scragly != eevee) + + def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self): + """Test if the MagicMock subclasses that implement the HashableMixin use id for hash.""" + for mock in self.hashable_mocks: + with self.subTest(mock_class=mock): + instance = test_mocks.MockRole(id=100) + self.assertEqual(hash(instance), instance.id) + + def test_mock_class_with_hashable_mixin_uses_id_for_equality(self): + """Test if MagicMocks that implement the HashableMixin use id for equality comparisons.""" + for mock_class in self.hashable_mocks: + with self.subTest(mock_class=mock_class): + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + self.assertTrue(instance_one == instance_two) + self.assertFalse(instance_one == instance_three) + + def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self): + """Test if MagicMocks that implement HashableMixin use id for nonequality comparisons.""" + for mock_class in self.hashable_mocks: + with self.subTest(mock_class=mock_class): + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() + + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 + + self.assertFalse(instance_one != instance_two) + self.assertTrue(instance_one != instance_three) + + def test_custom_mock_mixin_accepts_mock_seal(self): + """The `CustomMockMixin` should support `unittest.mock.seal`.""" + + class MyMock(test_mocks.CustomMockMixin, unittest.mock.MagicMock): + + child_mock_type = unittest.mock.MagicMock + pass + + mock = MyMock() + unittest.mock.seal(mock) + with self.assertRaises(AttributeError, msg="MyMock.shirayuki"): + mock.shirayuki = "hello!" + + def test_spec_propagation_of_mock_subclasses(self): + """Test if the `spec` does not propagate to attributes of the mock object.""" + test_values = ( + (test_mocks.MockGuild, "region"), + (test_mocks.MockRole, "mentionable"), + (test_mocks.MockMember, "display_name"), + (test_mocks.MockBot, "owner_id"), + (test_mocks.MockContext, "command_failed"), + (test_mocks.MockMessage, "mention_everyone"), + (test_mocks.MockEmoji, "managed"), + (test_mocks.MockPartialEmoji, "url"), + (test_mocks.MockReaction, "me"), + ) + + for mock_type, valid_attribute in test_values: + with self.subTest(mock_type=mock_type, attribute=valid_attribute): + mock = mock_type() + self.assertTrue(isinstance(mock, mock_type)) + attribute = getattr(mock, valid_attribute) + self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) + + def test_custom_mock_mixin_mocks_async_magic_methods_with_async_mock(self): + """The CustomMockMixin should mock async magic methods with an AsyncMock.""" + + class MyMock(test_mocks.CustomMockMixin, unittest.mock.MagicMock): + pass + + mock = MyMock() + self.assertIsInstance(mock.__aenter__, unittest.mock.AsyncMock) From 59d7aaebdaba8eb60ae4b9dffe51510e9baa0da4 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 17 Oct 2021 18:40:24 -0400 Subject: [PATCH 07/33] chore: use less "from x " imports --- tests/mocks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index 3464d0eb..469dab4e 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -30,16 +30,16 @@ """ from __future__ import annotations +import asyncio import collections import itertools import logging import unittest.mock -from asyncio import AbstractEventLoop from typing import TYPE_CHECKING, Iterable, Optional +import aiohttp import discord import discord.mixins -from aiohttp import ClientSession from discord.ext.commands import Context import modmail.bot @@ -325,7 +325,7 @@ def __init__(self, **kwargs) -> None: def _get_mock_loop() -> unittest.mock.Mock: """Return a mocked asyncio.AbstractEventLoop.""" - loop = unittest.mock.create_autospec(spec=AbstractEventLoop, spec_set=True) + loop = unittest.mock.create_autospec(spec=asyncio.AbstractEventLoop, spec_set=True) # Since calling `create_task` on our MockBot does not actually schedule the coroutine object # as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object @@ -357,7 +357,7 @@ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.loop = _get_mock_loop() - self.http_session = unittest.mock.create_autospec(spec=ClientSession, spec_set=True) + self.http_session = unittest.mock.create_autospec(spec=aiohttp.ClientSession, spec_set=True) # Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` From d7566e4ba5537b3d46facb06a455feca3b51511b Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 18 Oct 2021 00:19:21 -0400 Subject: [PATCH 08/33] chore: don't create a real bot instance as a fixture, create a mocked bot --- tests/test_bot.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/test_bot.py b/tests/test_bot.py index 22059ef5..1fe77535 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -8,6 +8,7 @@ import pytest from modmail.bot import ModmailBot +from tests import mocks @pytest.mark.dependency(name="create_bot") @@ -26,7 +27,7 @@ def bot() -> ModmailBot: ModmailBot instance. """ - bot: ModmailBot = ModmailBot() + bot: ModmailBot = mocks.MockBot() return bot @@ -42,9 +43,3 @@ async def test_bot_close(bot: ModmailBot) -> None: await bot.close() resp = stdout.getvalue() assert resp == "" - - -@pytest.mark.dependency(depends=["create_bot"]) -def test_bot_main() -> None: - """Import modmail.__main__.""" - from modmail.__main__ import main From cfe7dd04fb54c1594dc92da248817d5eee934874 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 18 Oct 2021 00:20:22 -0400 Subject: [PATCH 09/33] minor: fix small regex issue with error_handler --- modmail/extensions/utils/error_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modmail/extensions/utils/error_handler.py b/modmail/extensions/utils/error_handler.py index 02b059f2..cedd8f1b 100644 --- a/modmail/extensions/utils/error_handler.py +++ b/modmail/extensions/utils/error_handler.py @@ -18,7 +18,7 @@ ERROR_COLOUR = discord.Colour.red() -ERROR_TITLE_REGEX = re.compile(r"(?<=[a-zA-Z])([A-Z])(?=[a-z])") +ERROR_TITLE_REGEX = re.compile(r"((?<=[a-z])[A-Z]|(?<=[a-zA-Z])[A-Z](?=[a-z]))") ANY_DEV_MODE = BOT_MODE & (BotModes.DEVELOP.value + BotModes.PLUGIN_DEV.value) From e66092ed637b3e381f2a269cf9b1d95c34d9ce3f Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Wed, 20 Oct 2021 16:13:15 -0400 Subject: [PATCH 10/33] deps: update testing dependencies --- poetry.lock | 106 ++++++++++++++++++++----------------------------- pyproject.toml | 4 +- 2 files changed, 45 insertions(+), 65 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6bf14cad..351e76cf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -238,17 +238,17 @@ cron = ["capturer (>=2.4)"] [[package]] name = "coverage" -version = "5.5" +version = "6.0.2" description = "Code coverage measurement for Python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +python-versions = ">=3.6" [package.dependencies] -toml = {version = "*", optional = true, markers = "extra == \"toml\""} +tomli = {version = "*", optional = true, markers = "extra == \"toml\""} [package.extras] -toml = ["toml"] +toml = ["tomli"] [[package]] name = "discord.py" @@ -907,16 +907,15 @@ testing = ["coverage", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "2.12.1" +version = "3.0.0" description = "Pytest plugin for measuring coverage." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" [package.dependencies] -coverage = ">=5.2.1" +coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" -toml = "*" [package.extras] testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] @@ -1207,7 +1206,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "13d426f2143ed7a3fb374ff734603e7dd6e16bf259834232f9fb6c056f11f2bb" +content-hash = "5549883a46cb4aa2d993a219229ca89255dce3f6b10fbc5b86cc86c1f15893f8" [metadata.files] aiodns = [ @@ -1427,58 +1426,39 @@ coloredlogs = [ {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"}, ] coverage = [ - {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, - {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, - {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, - {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, - {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, - {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, - {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, - {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, - {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, - {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, - {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, - {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, - {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, - {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, - {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, - {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, - {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, - {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, - {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, - {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, - {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, - {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, - {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, - {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, + {file = "coverage-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1549e1d08ce38259de2bc3e9a0d5f3642ff4a8f500ffc1b2df73fd621a6cdfc0"}, + {file = "coverage-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcae10fccb27ca2a5f456bf64d84110a5a74144be3136a5e598f9d9fb48c0caa"}, + {file = "coverage-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:53a294dc53cfb39c74758edaa6305193fb4258a30b1f6af24b360a6c8bd0ffa7"}, + {file = "coverage-6.0.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8251b37be1f2cd9c0e5ccd9ae0380909c24d2a5ed2162a41fcdbafaf59a85ebd"}, + {file = "coverage-6.0.2-cp310-cp310-win32.whl", hash = "sha256:db42baa892cba723326284490283a68d4de516bfb5aaba369b4e3b2787a778b7"}, + {file = "coverage-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:bbffde2a68398682623d9dd8c0ca3f46fda074709b26fcf08ae7a4c431a6ab2d"}, + {file = "coverage-6.0.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:60e51a3dd55540bec686d7fff61b05048ca31e804c1f32cbb44533e6372d9cc3"}, + {file = "coverage-6.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a6a9409223a27d5ef3cca57dd7cd4dfcb64aadf2fad5c3b787830ac9223e01a"}, + {file = "coverage-6.0.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4b34ae4f51bbfa5f96b758b55a163d502be3dcb24f505d0227858c2b3f94f5b9"}, + {file = "coverage-6.0.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3bbda1b550e70fa6ac40533d3f23acd4f4e9cb4e6e77251ce77fdf41b3309fb2"}, + {file = "coverage-6.0.2-cp36-cp36m-win32.whl", hash = "sha256:4e28d2a195c533b58fc94a12826f4431726d8eb029ac21d874345f943530c122"}, + {file = "coverage-6.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:a82d79586a0a4f5fd1cf153e647464ced402938fbccb3ffc358c7babd4da1dd9"}, + {file = "coverage-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3be1206dc09fb6298de3fce70593e27436862331a85daee36270b6d0e1c251c4"}, + {file = "coverage-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9cd3828bbe1a40070c11fe16a51df733fd2f0cb0d745fb83b7b5c1f05967df7"}, + {file = "coverage-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d036dc1ed8e1388e995833c62325df3f996675779541f682677efc6af71e96cc"}, + {file = "coverage-6.0.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04560539c19ec26995ecfb3d9307ff154fbb9a172cb57e3b3cfc4ced673103d1"}, + {file = "coverage-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:e4fb7ced4d9dec77d6cf533acfbf8e1415fe799430366affb18d69ee8a3c6330"}, + {file = "coverage-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:77b1da5767ed2f44611bc9bc019bc93c03fa495728ec389759b6e9e5039ac6b1"}, + {file = "coverage-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:61b598cbdbaae22d9e34e3f675997194342f866bb1d781da5d0be54783dce1ff"}, + {file = "coverage-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36e9040a43d2017f2787b28d365a4bb33fcd792c7ff46a047a04094dc0e2a30d"}, + {file = "coverage-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9f1627e162e3864a596486774876415a7410021f4b67fd2d9efdf93ade681afc"}, + {file = "coverage-6.0.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e7a0b42db2a47ecb488cde14e0f6c7679a2c5a9f44814393b162ff6397fcdfbb"}, + {file = "coverage-6.0.2-cp38-cp38-win32.whl", hash = "sha256:a1b73c7c4d2a42b9d37dd43199c5711d91424ff3c6c22681bc132db4a4afec6f"}, + {file = "coverage-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:1db67c497688fd4ba85b373b37cc52c50d437fd7267520ecd77bddbd89ea22c9"}, + {file = "coverage-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f2f184bf38e74f152eed7f87e345b51f3ab0b703842f447c22efe35e59942c24"}, + {file = "coverage-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd1cf1deb3d5544bd942356364a2fdc8959bad2b6cf6eb17f47d301ea34ae822"}, + {file = "coverage-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ad9b8c1206ae41d46ec7380b78ba735ebb77758a650643e841dd3894966c31d0"}, + {file = "coverage-6.0.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:381d773d896cc7f8ba4ff3b92dee4ed740fb88dfe33b6e42efc5e8ab6dfa1cfe"}, + {file = "coverage-6.0.2-cp39-cp39-win32.whl", hash = "sha256:424c44f65e8be58b54e2b0bd1515e434b940679624b1b72726147cfc6a9fc7ce"}, + {file = "coverage-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:abbff240f77347d17306d3201e14431519bf64495648ca5a49571f988f88dee9"}, + {file = "coverage-6.0.2-pp36-none-any.whl", hash = "sha256:7092eab374346121805fb637572483270324407bf150c30a3b161fc0c4ca5164"}, + {file = "coverage-6.0.2-pp37-none-any.whl", hash = "sha256:30922626ce6f7a5a30bdba984ad21021529d3d05a68b4f71ea3b16bda35b8895"}, + {file = "coverage-6.0.2.tar.gz", hash = "sha256:6807947a09510dc31fa86f43595bf3a14017cd60bf633cc746d52141bfa6b149"}, ] "discord.py" = [] distlib = [ @@ -1868,8 +1848,8 @@ pytest-asyncio = [ {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, ] pytest-cov = [ - {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, - {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, + {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, + {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, ] pytest-dependency = [ {file = "pytest-dependency-0.5.1.tar.gz", hash = "sha256:c2a892906192663f85030a6ab91304e508e546cddfe557d692d61ec57a1d946b"}, diff --git a/pyproject.toml b/pyproject.toml index 2a7a2de7..d32d6502 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,10 +45,10 @@ isort = "^5.9.2" pep8-naming = "~=0.11" # testing codecov = "^2.1.11" -coverage = { extras = ["toml"], version = "^5.5" } +coverage = { extras = ["toml"], version = "^6.0.2" } pytest = "^6.2.4" pytest-asyncio = "^0.15.1" -pytest-cov = "^2.12.1" +pytest-cov = "^3.0.0" pytest-dependency = "^0.5.1" pytest-sugar = "^0.9.4" pytest-xdist = { version = "^2.3.0", extras = ["psutil"] } From ce1ef378aad249899cd1c80e01956a166a2bce49 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Wed, 20 Oct 2021 18:22:58 -0400 Subject: [PATCH 11/33] tests: rewrite test_mocks to use pytest instead of unittest --- tests/test_mocks.py | 317 +++++++++++++++++++++----------------------- 1 file changed, 152 insertions(+), 165 deletions(-) diff --git a/tests/test_mocks.py b/tests/test_mocks.py index b50c6c98..8212bf9f 100644 --- a/tests/test_mocks.py +++ b/tests/test_mocks.py @@ -4,6 +4,9 @@ Original Source: https://github.com/python-discord/bot/blob/d183d03fa2939bebaac3da49646449fdd4d00e6c/tests/test_helpers.py # noqa: E501 +NOTE: THIS FILE WAS REWRITTEN TO USE PYTEST + + MIT License Copyright (c) 2018 Python Discord @@ -28,27 +31,28 @@ """ import asyncio -import unittest import unittest.mock import discord +import discord.ext.commands +import pytest from tests import mocks as test_mocks -class DiscordMocksTests(unittest.TestCase): +class TestDiscordMocks: """Tests for our specialized discord.py mocks.""" def test_mock_role_default_initialization(self): """Test if the default initialization of MockRole results in the correct object.""" role = test_mocks.MockRole() - # The `spec` argument makes sure `isistance` checks with `discord.Role` pass - self.assertIsInstance(role, discord.Role) + # The `spec` argument makes sure `isinstance` checks with `discord.Role` pass + assert isinstance(role, discord.Role) - self.assertEqual(role.name, "role") - self.assertEqual(role.position, 1) - self.assertEqual(role.mention, "&role") + assert role.name == "role" + assert role.position == 1 + assert role.mention == "&role" def test_mock_role_alternative_arguments(self): """Test if MockRole initializes with the arguments provided.""" @@ -58,10 +62,10 @@ def test_mock_role_alternative_arguments(self): position=10, ) - self.assertEqual(role.name, "Admins") - self.assertEqual(role.id, 90210) - self.assertEqual(role.position, 10) - self.assertEqual(role.mention, "&Admins") + assert role.name == "Admins" + assert role.id == 90210 + assert role.position == 10 + assert role.mention == "&Admins" def test_mock_role_accepts_dynamic_arguments(self): """Test if MockRole accepts and sets abitrary keyword arguments.""" @@ -70,8 +74,8 @@ def test_mock_role_accepts_dynamic_arguments(self): hoist=True, ) - self.assertEqual(role.guild, "Dino Man") - self.assertTrue(role.hoist) + assert role.guild == "Dino Man" + assert role.hoist def test_mock_role_uses_position_for_less_than_greater_than(self): """Test if `<` and `>` comparisons for MockRole are based on its position attribute.""" @@ -79,35 +83,33 @@ def test_mock_role_uses_position_for_less_than_greater_than(self): role_two = test_mocks.MockRole(position=2) role_three = test_mocks.MockRole(position=3) - self.assertLess(role_one, role_two) - self.assertLess(role_one, role_three) - self.assertLess(role_two, role_three) - self.assertGreater(role_three, role_two) - self.assertGreater(role_three, role_one) - self.assertGreater(role_two, role_one) + assert role_one < role_two + assert role_one < role_three + assert role_two < role_three + assert role_three > role_two + assert role_three > role_one + assert role_two > role_one def test_mock_member_default_initialization(self): """Test if the default initialization of Mockmember results in the correct object.""" member = test_mocks.MockMember() - # The `spec` argument makes sure `isistance` checks with `discord.Member` pass - self.assertIsInstance(member, discord.Member) + # The `spec` argument makes sure `isinstance` checks with `discord.Member` pass + assert isinstance(member, discord.Member) - self.assertEqual(member.name, "member") - self.assertListEqual(member.roles, [test_mocks.MockRole(name="@everyone", position=1, id=0)]) - self.assertEqual(member.mention, "@member") + assert member.name == "member" + assert member.roles == [test_mocks.MockRole(name="@everyone", position=1, id=0)] + assert member.mention == "@member" def test_mock_member_alternative_arguments(self): """Test if MockMember initializes with the arguments provided.""" core_developer = test_mocks.MockRole(name="Core Developer", position=2) member = test_mocks.MockMember(name="Mark", id=12345, roles=[core_developer]) - self.assertEqual(member.name, "Mark") - self.assertEqual(member.id, 12345) - self.assertListEqual( - member.roles, [test_mocks.MockRole(name="@everyone", position=1, id=0), core_developer] - ) - self.assertEqual(member.mention, "@Mark") + assert member.name == "Mark" + assert member.id == 12345 + assert member.roles == [test_mocks.MockRole(name="@everyone", position=1, id=0), core_developer] + assert member.mention == "@Mark" def test_mock_member_accepts_dynamic_arguments(self): """Test if MockMember accepts and sets abitrary keyword arguments.""" @@ -116,18 +118,18 @@ def test_mock_member_accepts_dynamic_arguments(self): colour=discord.Colour.default(), ) - self.assertEqual(member.nick, "Dino Man") - self.assertEqual(member.colour, discord.Colour.default()) + assert member.nick == "Dino Man" + assert member.colour == discord.Colour.default() def test_mock_guild_default_initialization(self): """Test if the default initialization of Mockguild results in the correct object.""" guild = test_mocks.MockGuild() # The `spec` argument makes sure `isistance` checks with `discord.Guild` pass - self.assertIsInstance(guild, discord.Guild) + assert isinstance(guild, discord.Guild) - self.assertListEqual(guild.roles, [test_mocks.MockRole(name="@everyone", position=1, id=0)]) - self.assertListEqual(guild.members, []) + assert guild.roles == [test_mocks.MockRole(name="@everyone", position=1, id=0)] + assert guild.members == [] def test_mock_guild_alternative_arguments(self): """Test if MockGuild initializes with the arguments provided.""" @@ -137,10 +139,8 @@ def test_mock_guild_alternative_arguments(self): members=[test_mocks.MockMember(id=54321)], ) - self.assertListEqual( - guild.roles, [test_mocks.MockRole(name="@everyone", position=1, id=0), core_developer] - ) - self.assertListEqual(guild.members, [test_mocks.MockMember(id=54321)]) + assert guild.roles == [test_mocks.MockRole(name="@everyone", position=1, id=0), core_developer] + assert guild.members == [test_mocks.MockMember(id=54321)] def test_mock_guild_accepts_dynamic_arguments(self): """Test if MockGuild accepts and sets abitrary keyword arguments.""" @@ -149,113 +149,101 @@ def test_mock_guild_accepts_dynamic_arguments(self): premium_subscription_count=15, ) - self.assertTupleEqual(guild.emojis, (":hyperjoseph:", ":pensive_ela:")) - self.assertEqual(guild.premium_subscription_count, 15) + assert guild.emojis == (":hyperjoseph:", ":pensive_ela:") + assert guild.premium_subscription_count == 15 def test_mock_bot_default_initialization(self): """Tests if MockBot initializes with the correct values.""" bot = test_mocks.MockBot() - # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Bot` pass - self.assertIsInstance(bot, discord.ext.commands.Bot) + # The `spec` argument makes sure `isinstance` checks with `discord.ext.commands.Bot` pass + assert isinstance(bot, discord.ext.commands.Bot) def test_mock_context_default_initialization(self): """Tests if MockContext initializes with the correct values.""" context = test_mocks.MockContext() - # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Context` pass - self.assertIsInstance(context, discord.ext.commands.Context) - - self.assertIsInstance(context.bot, test_mocks.MockBot) - self.assertIsInstance(context.guild, test_mocks.MockGuild) - self.assertIsInstance(context.author, test_mocks.MockMember) - - def test_mocks_allows_access_to_attributes_part_of_spec(self): + # The `spec` argument makes sure `isinstance` checks with `discord.ext.commands.Context` pass + assert isinstance(context, discord.ext.commands.Context) + + assert isinstance(context.bot, test_mocks.MockBot) + assert isinstance(context.guild, test_mocks.MockGuild) + assert isinstance(context.author, test_mocks.MockMember) + assert isinstance(context.message, test_mocks.MockMessage) + + @pytest.mark.parametrize( + ["mock", "valid_attribute"], + [ + [test_mocks.MockGuild(), "name"], + [test_mocks.MockRole(), "hoist"], + [test_mocks.MockMember(), "display_name"], + [test_mocks.MockBot(), "user"], + [test_mocks.MockContext(), "invoked_with"], + [test_mocks.MockTextChannel(), "last_message"], + [test_mocks.MockMessage(), "mention_everyone"], + ], + ) + def test_mocks_allows_access_to_attributes_part_of_spec(self, mock, valid_attribute: str): """Accessing attributes that are valid for the objects they mock should succeed.""" - mocks = ( - (test_mocks.MockGuild(), "name"), - (test_mocks.MockRole(), "hoist"), - (test_mocks.MockMember(), "display_name"), - (test_mocks.MockBot(), "user"), - (test_mocks.MockContext(), "invoked_with"), - (test_mocks.MockTextChannel(), "last_message"), - (test_mocks.MockMessage(), "mention_everyone"), - ) - - for mock, valid_attribute in mocks: - with self.subTest(mock=mock): - try: - getattr(mock, valid_attribute) - except AttributeError: - msg = f"accessing valid attribute `{valid_attribute}` raised an AttributeError" - self.fail(msg) - - @unittest.mock.patch(f"{__name__}.DiscordMocksTests.subTest") - @unittest.mock.patch(f"{__name__}.getattr") - def test_mock_allows_access_to_attributes_test(self, mock_getattr, mock_subtest): - """The valid attribute test should raise an AssertionError after an AttributeError.""" - mock_getattr.side_effect = AttributeError - - msg = "accessing valid attribute `name` raised an AttributeError" - with self.assertRaises(AssertionError, msg=msg): - self.test_mocks_allows_access_to_attributes_part_of_spec() - - def test_mocks_rejects_access_to_attributes_not_part_of_spec(self): + try: + getattr(mock, valid_attribute) + except AttributeError: + msg = f"accessing valid attribute `{valid_attribute}` raised an AttributeError" + pytest.fail(msg) + + @pytest.mark.parametrize( + ["mock"], + [ + [test_mocks.MockGuild()], + [test_mocks.MockRole()], + [test_mocks.MockMember()], + [test_mocks.MockBot()], + [test_mocks.MockContext()], + [test_mocks.MockTextChannel()], + [test_mocks.MockMessage()], + ], + ) + def test_mocks_rejects_access_to_attributes_not_part_of_spec(self, mock): """Accessing attributes that are invalid for the objects they mock should fail.""" - mocks = ( - test_mocks.MockGuild(), - test_mocks.MockRole(), - test_mocks.MockMember(), - test_mocks.MockBot(), - test_mocks.MockContext(), - test_mocks.MockTextChannel(), - test_mocks.MockMessage(), - ) - - for mock in mocks: - with self.subTest(mock=mock): - with self.assertRaises(AttributeError): - mock.the_cake_is_a_lie - - def test_mocks_use_mention_when_provided_as_kwarg(self): + with pytest.raises(AttributeError): + mock.the_cake_is_a_lie + + @pytest.mark.parametrize( + ["mock_type", "provided_mention"], + [ + [test_mocks.MockRole, "role mention"], + [test_mocks.MockMember, "member mention"], + [test_mocks.MockTextChannel, "channel mention"], + [test_mocks.MockUser, "user mention"], + ], + ) + def test_mocks_use_mention_when_provided_as_kwarg(self, mock_type, provided_mention: str): """The mock should use the passed `mention` instead of the default one if present.""" - test_cases = ( - (test_mocks.MockRole, "role mention"), - (test_mocks.MockMember, "member mention"), - (test_mocks.MockTextChannel, "channel mention"), - ) - - for mock_type, mention in test_cases: - with self.subTest(mock_type=mock_type, mention=mention): - mock = mock_type(mention=mention) - self.assertEqual(mock.mention, mention) + mock = mock_type(mention=provided_mention) + assert mock.mention == provided_mention def test_create_test_on_mock_bot_closes_passed_coroutine(self): """`bot.loop.create_task` should close the passed coroutine object to prevent warnings.""" async def dementati(): """Dummy coroutine for testing purposes.""" + pass coroutine_object = dementati() bot = test_mocks.MockBot() bot.loop.create_task(coroutine_object) - with self.assertRaises(RuntimeError, msg="cannot reuse already awaited coroutine"): + with pytest.raises(RuntimeError) as error: asyncio.run(coroutine_object) + assert error.match("cannot reuse already awaited coroutine") - def test_user_mock_uses_explicitly_passed_mention_attribute(self): - """Ensure MockUser uses an explictly passed value for user.mention.""" - user = test_mocks.MockUser(mention="hello") - self.assertEqual(user.mention, "hello") +hashable_mocks = (test_mocks.MockRole, test_mocks.MockMember, test_mocks.MockGuild) +print([[x] for x in hashable_mocks]) -class MockObjectTests(unittest.TestCase): - """Tests the mock objects and mixins we've defined.""" - @classmethod - def setUpClass(cls): - """Called by unittest before running the test methods.""" - cls.hashable_mocks = (test_mocks.MockRole, test_mocks.MockMember, test_mocks.MockGuild) +class TestMockObjects: + """Tests the mock objects and mixins we've defined.""" def test_colour_mixin(self): """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" @@ -265,8 +253,8 @@ class MockHemlock(unittest.mock.MagicMock, test_mocks.ColourMixin): hemlock = MockHemlock() hemlock.color = 1 - self.assertEqual(hemlock.colour, 1) - self.assertEqual(hemlock.colour, hemlock.color) + assert hemlock.colour == 1 + assert hemlock.colour == hemlock.color def test_hashable_mixin_hash_returns_id(self): """Test the HashableMixing uses the id attribute for hashing.""" @@ -276,7 +264,7 @@ class MockScragly(unittest.mock.Mock, test_mocks.HashableMixin): scragly = MockScragly() scragly.id = 10 - self.assertEqual(hash(scragly), scragly.id) + assert hash(scragly) == scragly.id def test_hashable_mixin_hash_returns_id_bitshift(self): """Test the HashableMixing uses the id attribute for hashing when above 1<<22.""" @@ -286,7 +274,7 @@ class MockScragly(unittest.mock.Mock, test_mocks.HashableMixin): scragly = MockScragly() scragly.id = 10 << 22 - self.assertEqual(hash(scragly), scragly.id >> 22) + assert hash(scragly) == scragly.id >> 22 def test_hashable_mixin_uses_id_for_equality_comparison(self): """Test the HashableMixing uses the id attribute for equal comparison.""" @@ -301,8 +289,8 @@ class MockScragly(test_mocks.HashableMixin): python = MockScragly() python.id = 20 - self.assertTrue(scragly == eevee) - self.assertFalse(scragly == python) + assert scragly == eevee + assert (scragly == python) is False def test_hashable_mixin_uses_id_for_nonequality_comparison(self): """Test if the HashableMixing uses the id attribute for non-equal comparison.""" @@ -317,45 +305,42 @@ class MockScragly(test_mocks.HashableMixin): python = MockScragly() python.id = 20 - self.assertTrue(scragly != python) - self.assertFalse(scragly != eevee) + assert scragly != python + assert (scragly != eevee) is False - def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self): + @pytest.mark.parametrize(["mock_cls"], [[x] for x in hashable_mocks]) + def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self, mock_cls): """Test if the MagicMock subclasses that implement the HashableMixin use id for hash.""" - for mock in self.hashable_mocks: - with self.subTest(mock_class=mock): - instance = test_mocks.MockRole(id=100) - self.assertEqual(hash(instance), instance.id) + instance = mock_cls(id=100) + assert hash(instance) == instance.id - def test_mock_class_with_hashable_mixin_uses_id_for_equality(self): + @pytest.mark.parametrize(["mock_class"], [[x] for x in hashable_mocks]) + def test_mock_class_with_hashable_mixin_uses_id_for_equality(self, mock_class): """Test if MagicMocks that implement the HashableMixin use id for equality comparisons.""" - for mock_class in self.hashable_mocks: - with self.subTest(mock_class=mock_class): - instance_one = mock_class() - instance_two = mock_class() - instance_three = mock_class() + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() - instance_one.id = 10 - instance_two.id = 10 - instance_three.id = 20 + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 - self.assertTrue(instance_one == instance_two) - self.assertFalse(instance_one == instance_three) + assert instance_one == instance_two + assert (instance_one == instance_three) is False - def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self): + @pytest.mark.parametrize(["mock_class"], [[x] for x in hashable_mocks]) + def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self, mock_class): """Test if MagicMocks that implement HashableMixin use id for nonequality comparisons.""" - for mock_class in self.hashable_mocks: - with self.subTest(mock_class=mock_class): - instance_one = mock_class() - instance_two = mock_class() - instance_three = mock_class() + instance_one = mock_class() + instance_two = mock_class() + instance_three = mock_class() - instance_one.id = 10 - instance_two.id = 10 - instance_three.id = 20 + instance_one.id = 10 + instance_two.id = 10 + instance_three.id = 20 - self.assertFalse(instance_one != instance_two) - self.assertTrue(instance_one != instance_three) + assert instance_one != instance_three + assert (instance_one != instance_two) is False def test_custom_mock_mixin_accepts_mock_seal(self): """The `CustomMockMixin` should support `unittest.mock.seal`.""" @@ -367,12 +352,14 @@ class MyMock(test_mocks.CustomMockMixin, unittest.mock.MagicMock): mock = MyMock() unittest.mock.seal(mock) - with self.assertRaises(AttributeError, msg="MyMock.shirayuki"): + with pytest.raises(AttributeError) as error: mock.shirayuki = "hello!" - def test_spec_propagation_of_mock_subclasses(self): - """Test if the `spec` does not propagate to attributes of the mock object.""" - test_values = ( + assert error.match("shirayuki") + + @pytest.mark.parametrize( + ["mock_type", "valid_attribute"], + [ (test_mocks.MockGuild, "region"), (test_mocks.MockRole, "mentionable"), (test_mocks.MockMember, "display_name"), @@ -382,14 +369,14 @@ def test_spec_propagation_of_mock_subclasses(self): (test_mocks.MockEmoji, "managed"), (test_mocks.MockPartialEmoji, "url"), (test_mocks.MockReaction, "me"), - ) - - for mock_type, valid_attribute in test_values: - with self.subTest(mock_type=mock_type, attribute=valid_attribute): - mock = mock_type() - self.assertTrue(isinstance(mock, mock_type)) - attribute = getattr(mock, valid_attribute) - self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) + ], + ) + def test_spec_propagation_of_mock_subclasses(self, mock_type, valid_attribute: str): + """Test if the `spec` does not propagate to attributes of the mock object.""" + mock = mock_type() + assert isinstance(mock, mock_type) + attribute = getattr(mock, valid_attribute) + assert isinstance(attribute, mock_type.child_mock_type) def test_custom_mock_mixin_mocks_async_magic_methods_with_async_mock(self): """The CustomMockMixin should mock async magic methods with an AsyncMock.""" @@ -398,4 +385,4 @@ class MyMock(test_mocks.CustomMockMixin, unittest.mock.MagicMock): pass mock = MyMock() - self.assertIsInstance(mock.__aenter__, unittest.mock.AsyncMock) + assert isinstance(mock.__aenter__, unittest.mock.AsyncMock) From 9c390b3d78ad807ed66adc80e903975bfc608a49 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Wed, 20 Oct 2021 18:31:14 -0400 Subject: [PATCH 12/33] fix: make mock context objects more accurate previously, channel, message, and guild were all mocked independently. however, multiple aspects of the context method and the message channel should be the same as the message's channel additionally, the attributes of MockContext have been typed, to make writing tests easier. --- tests/mocks.py | 17 +++++++++++------ tests/test_mocks.py | 4 ++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index 469dab4e..e2cee005 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -34,6 +34,7 @@ import collections import itertools import logging +import typing import unittest.mock from typing import TYPE_CHECKING, Iterable, Optional @@ -543,12 +544,16 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) - self.me = kwargs.get("me", MockMember()) - 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.message = kwargs.get("message", MockMessage()) + self.me: typing.Union[MockMember, MockUser] = kwargs.get("me", MockMember()) + self.bot: MockBot = kwargs.get("bot", MockBot()) + self.guild: typing.Optional[MockGuild] = kwargs.get("guild", MockGuild()) + self.author: typing.Union[MockMember, MockUser] = kwargs.get("author", MockMember()) + self.channel: typing.Union[MockTextChannel, MockThread, MockDMChannel] = kwargs.get( + "channel", MockTextChannel(guild=self.guild) + ) + self.message: MockMessage = kwargs.get( + "message", MockMessage(author=self.author, channel=self.channel) + ) self.invoked_from_error_handler = kwargs.get("invoked_from_error_handler", False) diff --git a/tests/test_mocks.py b/tests/test_mocks.py index 8212bf9f..4c4da4d0 100644 --- a/tests/test_mocks.py +++ b/tests/test_mocks.py @@ -171,6 +171,10 @@ def test_mock_context_default_initialization(self): assert isinstance(context.author, test_mocks.MockMember) assert isinstance(context.message, test_mocks.MockMessage) + # ensure that the mocks are the same attributes, like discord.py + assert context.message.channel is context.channel + assert context.channel.guild is context.guild + @pytest.mark.parametrize( ["mock", "valid_attribute"], [ From 4c5fab002f9ae1e795f504f15f8fef557a0089b8 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Wed, 20 Oct 2021 21:51:11 -0400 Subject: [PATCH 13/33] tests(mocks): add MockClientUser --- tests/mocks.py | 42 ++++++++++++++++++++++++++++++++++++++---- tests/test_mocks.py | 12 ++++++++++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index e2cee005..1ef9c167 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -63,12 +63,12 @@ class HashableMixin(discord.mixins.EqualityComparable): Given that most of our features need the created_at function to work, we are typically using full fake discord ids, and so we still bitshift the id like Dpy does. - However, given that the hash of 4>>22 and 5>>22 is the same, we check if the number is above 22. + However, given that the hash of 4>>22 and 5>>22 is the same, we check if the number is above 1<<22. If so, we hash it. This could result in some weird behavior with the hash of 1<<22 + 1 equaling 1. """ - if TYPE_CHECKING: + if TYPE_CHECKING: # pragma: nocover id: int def __hash__(self): @@ -324,6 +324,32 @@ def __init__(self, **kwargs) -> None: self.mention = f"@{self.name}" +# Create a User instance to get a realistic Mock of `discord.ClientUser` +_user_data_mock = collections.defaultdict(unittest.mock.MagicMock, {"accent_color": 0}) +clientuser_instance = discord.ClientUser( + data=unittest.mock.MagicMock(get=unittest.mock.Mock(side_effect=_user_data_mock.get)), + state=unittest.mock.MagicMock(), +) + + +class MockClientUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): + """ + A Mock subclass to mock ClientUser objects. + + Instances of this class will follow the specifications of `discord.ClientUser` instances. For more + information, see the `MockGuild` docstring. + """ + + spec_set = clientuser_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"name": "user", "id": next(self.discord_id), "bot": True} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + if "mention" not in kwargs: + self.mention = f"@{self.name}" + + def _get_mock_loop() -> unittest.mock.Mock: """Return a mocked asyncio.AbstractEventLoop.""" loop = unittest.mock.create_autospec(spec=asyncio.AbstractEventLoop, spec_set=True) @@ -356,6 +382,7 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) + self.user = MockClientUser() self.loop = _get_mock_loop() self.http_session = unittest.mock.create_autospec(spec=aiohttp.ClientSession, spec_set=True) @@ -544,9 +571,10 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) - self.me: typing.Union[MockMember, MockUser] = kwargs.get("me", MockMember()) self.bot: MockBot = kwargs.get("bot", MockBot()) - self.guild: typing.Optional[MockGuild] = kwargs.get("guild", MockGuild()) + self.guild: typing.Optional[MockGuild] = kwargs.get( + "guild", MockGuild(me=MockMember(id=self.bot.user.id, bot=True)) + ) self.author: typing.Union[MockMember, MockUser] = kwargs.get("author", MockMember()) self.channel: typing.Union[MockTextChannel, MockThread, MockDMChannel] = kwargs.get( "channel", MockTextChannel(guild=self.guild) @@ -556,6 +584,12 @@ def __init__(self, **kwargs) -> None: ) self.invoked_from_error_handler = kwargs.get("invoked_from_error_handler", False) + @property + def me(self) -> typing.Union[MockMember, MockClientUser]: + """Similar to MockGuild.me except will return the class MockClientUser if guild is None.""" + # bot.user will never be None at this point. + return self.guild.me if self.guild is not None else self.bot.user + attachment_instance = discord.Attachment(data=unittest.mock.MagicMock(id=1), state=unittest.mock.MagicMock()) diff --git a/tests/test_mocks.py b/tests/test_mocks.py index 4c4da4d0..768215e8 100644 --- a/tests/test_mocks.py +++ b/tests/test_mocks.py @@ -175,6 +175,10 @@ def test_mock_context_default_initialization(self): assert context.message.channel is context.channel assert context.channel.guild is context.guild + # ensure the me instance is of the right type and shtuff. + assert isinstance(context.me, test_mocks.MockMember) + assert context.me is context.guild.me + @pytest.mark.parametrize( ["mock", "valid_attribute"], [ @@ -191,7 +195,7 @@ def test_mocks_allows_access_to_attributes_part_of_spec(self, mock, valid_attrib """Accessing attributes that are valid for the objects they mock should succeed.""" try: getattr(mock, valid_attribute) - except AttributeError: + except AttributeError: # pragma: nocover msg = f"accessing valid attribute `{valid_attribute}` raised an AttributeError" pytest.fail(msg) @@ -229,7 +233,7 @@ def test_mocks_use_mention_when_provided_as_kwarg(self, mock_type, provided_ment def test_create_test_on_mock_bot_closes_passed_coroutine(self): """`bot.loop.create_task` should close the passed coroutine object to prevent warnings.""" - async def dementati(): + async def dementati(): # pragma: nocover """Dummy coroutine for testing purposes.""" pass @@ -260,6 +264,10 @@ class MockHemlock(unittest.mock.MagicMock, test_mocks.ColourMixin): assert hemlock.colour == 1 assert hemlock.colour == hemlock.color + hemlock.accent_color = 123 + assert hemlock.accent_colour == 123 + assert hemlock.accent_colour == hemlock.accent_color + def test_hashable_mixin_hash_returns_id(self): """Test the HashableMixing uses the id attribute for hashing.""" From 2c7de885ee1f247a74eb9fcedba21dcba67f67ae Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Wed, 20 Oct 2021 21:53:08 -0400 Subject: [PATCH 14/33] tests: start scanning tests coverage --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d32d6502..ab59efdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ build-backend = "poetry.core.masonry.api" [tool.coverage.run] branch = true -source_pkgs = ["modmail"] +source_pkgs = ['modmail', 'tests'] omit = ["modmail/plugins/**.*"] [tool.pytest.ini_options] From 27fff2d2bab5593830fa7a9dff74ec77eafab6da Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sat, 23 Oct 2021 01:53:31 -0400 Subject: [PATCH 15/33] fix: restructure tests to have modmail tests in modmail folder --- tests/{ => modmail}/test_bot.py | 0 tests/{ => modmail}/test_logs.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/{ => modmail}/test_bot.py (100%) rename tests/{ => modmail}/test_logs.py (100%) diff --git a/tests/test_bot.py b/tests/modmail/test_bot.py similarity index 100% rename from tests/test_bot.py rename to tests/modmail/test_bot.py diff --git a/tests/test_logs.py b/tests/modmail/test_logs.py similarity index 100% rename from tests/test_logs.py rename to tests/modmail/test_logs.py From 77ec504daf9eabb884cb68f691fef8ea2b422f5d Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 24 Oct 2021 01:26:05 -0400 Subject: [PATCH 16/33] fix: remove logger changes from tests --- tests/mocks.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index 1ef9c167..59bdac23 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -46,16 +46,6 @@ import modmail.bot -for logger in logging.Logger.manager.loggerDict.values(): - # Set all loggers to CRITICAL by default to prevent screen clutter during testing - - if not isinstance(logger, logging.Logger): - # There might be some logging.PlaceHolder objects in there - continue - - logger.setLevel(logging.CRITICAL) - - class HashableMixin(discord.mixins.EqualityComparable): """ Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. From 2cf6df5259aeb84aad05ea8c8f140e9b6c93195a Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 24 Oct 2021 01:43:59 -0400 Subject: [PATCH 17/33] chore: generate realistic snowflakes --- tests/mocks.py | 65 ++++++++++++++++++++++++++++----------------- tests/test_mocks.py | 17 +++--------- 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index 59bdac23..ab466f37 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -39,33 +39,43 @@ from typing import TYPE_CHECKING, Iterable, Optional import aiohttp +import arrow import discord import discord.mixins from discord.ext.commands import Context +from discord.utils import time_snowflake import modmail.bot +_snowflake_count = itertools.count(1) + + +def generate_realistic_id() -> int: + """Generate realistic id, based from the current time.""" + return discord.utils.time_snowflake(arrow.utcnow()) + next(_snowflake_count) + + +class GenerateID: + """Class to be able to use next() to generate new ids.""" + + def __next__(self) -> int: + return generate_realistic_id() + + class HashableMixin(discord.mixins.EqualityComparable): """ Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. Given that most of our features need the created_at function to work, we are typically using full fake discord ids, and so we still bitshift the id like Dpy does. - - However, given that the hash of 4>>22 and 5>>22 is the same, we check if the number is above 1<<22. - If so, we hash it. This could result in some weird behavior with the hash of - 1<<22 + 1 equaling 1. """ if TYPE_CHECKING: # pragma: nocover id: int def __hash__(self): - if self.id < 1 << 22: - return self.id - else: - return self.id >> 22 + return self.id >> 22 class ColourMixin: @@ -106,7 +116,7 @@ class method `spec_set` can be overwritten with the object that should be uses a """ child_mock_type = unittest.mock.MagicMock - discord_id = itertools.count(0) + discord_id = GenerateID() spec_set = None additional_spec_asyncs = None @@ -157,7 +167,7 @@ def _get_child_mock(self, **kw): # Create a guild instance to get a realistic Mock of `discord.Guild` guild_data = { - "id": 1, + "id": generate_realistic_id(), "name": "guild", "region": "Europe", "verification_level": 2, @@ -167,13 +177,13 @@ def _get_child_mock(self, **kw): "banner": "banner.png", "mfa_level": 1, "splash": "splash.png", - "system_channel_id": 464033278631084042, + "system_channel_id": generate_realistic_id(), "description": "mocking is fun", "max_presences": 10_000, "max_members": 100_000, "preferred_locale": "UTC", "owner_id": 1, - "afk_channel_id": 464033278631084042, + "afk_channel_id": generate_realistic_id(), } guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) @@ -217,7 +227,7 @@ def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None # Create a Role instance to get a realistic Mock of `discord.Role` -role_data = {"name": "role", "id": 1} +role_data = {"name": "role", "id": generate_realistic_id()} role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) @@ -380,14 +390,14 @@ def __init__(self, **kwargs) -> None: # Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` channel_data = { - "id": 1, + "id": generate_realistic_id(), "type": "TextChannel", "name": "channel", - "parent_id": 1234567890, + "parent_id": generate_realistic_id(), "topic": "topic", "position": 1, "nsfw": False, - "last_message_id": 1, + "last_message_id": generate_realistic_id(), } state = unittest.mock.MagicMock() guild = unittest.mock.MagicMock() @@ -436,7 +446,10 @@ def __init__(self, **kwargs) -> None: # Create data for the DMChannel instance state = unittest.mock.MagicMock() me = unittest.mock.MagicMock() -dm_channel_data = {"id": 1, "recipients": [unittest.mock.MagicMock()]} +dm_channel_data = { + "id": generate_realistic_id(), + "recipients": [unittest.mock.MagicMock()], +} dm_channel_instance = discord.DMChannel(me=me, state=state, data=dm_channel_data) @@ -457,7 +470,7 @@ def __init__(self, **kwargs) -> None: # Create CategoryChannel instance to get a realistic MagicMock of `discord.CategoryChannel` category_channel_data = { - "id": 1, + "id": generate_realistic_id(), "type": discord.ChannelType.category, "name": "category", "position": 1, @@ -491,9 +504,9 @@ def __init__(self, **kwargs) -> None: "archive_timestamp": "2021-10-17T20:35:48.058121+00:00", } thread_data = { - "id": "898070829617799179", - "parent_id": "898070393619902544", - "owner_id": "717983911824588862", + "id": generate_realistic_id(), + "parent_id": generate_realistic_id(), + "owner_id": generate_realistic_id(), "name": "user-0005", "type": discord.ChannelType.public_thread, "last_message_id": None, @@ -524,8 +537,8 @@ def __init__(self, **kwargs) -> None: # Create a Message instance to get a realistic MagicMock of `discord.Message` message_data = { - "id": 1, - "webhook_id": 898069816622067752, + "id": generate_realistic_id(), + "webhook_id": generate_realistic_id(), "attachments": [], "embeds": [], "application": "Discord Modmail", @@ -581,7 +594,9 @@ def me(self) -> typing.Union[MockMember, MockClientUser]: return self.guild.me if self.guild is not None else self.bot.user -attachment_instance = discord.Attachment(data=unittest.mock.MagicMock(id=1), state=unittest.mock.MagicMock()) +attachment_instance = discord.Attachment( + data=unittest.mock.MagicMock(id=generate_realistic_id()), state=unittest.mock.MagicMock() +) class MockAttachment(CustomMockMixin, unittest.mock.MagicMock): @@ -612,7 +627,7 @@ def __init__(self, **kwargs) -> None: self.channel = kwargs.get("channel", MockTextChannel()) -emoji_data = {"require_colons": True, "managed": True, "id": 1, "name": "hyperlemon"} +emoji_data = {"require_colons": True, "managed": True, "id": generate_realistic_id(), "name": "hyperlemon"} emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data) diff --git a/tests/test_mocks.py b/tests/test_mocks.py index 768215e8..99ef9223 100644 --- a/tests/test_mocks.py +++ b/tests/test_mocks.py @@ -33,6 +33,7 @@ import asyncio import unittest.mock +import arrow import discord import discord.ext.commands import pytest @@ -271,16 +272,6 @@ class MockHemlock(unittest.mock.MagicMock, test_mocks.ColourMixin): def test_hashable_mixin_hash_returns_id(self): """Test the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, test_mocks.HashableMixin): - pass - - scragly = MockScragly() - scragly.id = 10 - assert hash(scragly) == scragly.id - - def test_hashable_mixin_hash_returns_id_bitshift(self): - """Test the HashableMixing uses the id attribute for hashing when above 1<<22.""" - class MockScragly(unittest.mock.Mock, test_mocks.HashableMixin): pass @@ -322,9 +313,9 @@ class MockScragly(test_mocks.HashableMixin): @pytest.mark.parametrize(["mock_cls"], [[x] for x in hashable_mocks]) def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self, mock_cls): - """Test if the MagicMock subclasses that implement the HashableMixin use id for hash.""" - instance = mock_cls(id=100) - assert hash(instance) == instance.id + """Test if the MagicMock subclasses that implement the HashableMixin use id bitshift for hash.""" + instance = mock_cls(id=100 << 22) + assert hash(instance) == instance.id >> 22 @pytest.mark.parametrize(["mock_class"], [[x] for x in hashable_mocks]) def test_mock_class_with_hashable_mixin_uses_id_for_equality(self, mock_class): From cb9f6b914dac85c379085660209c63baabf42034 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 24 Oct 2021 01:52:04 -0400 Subject: [PATCH 18/33] chore: don't use the same variable name --- tests/mocks.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index ab466f37..30d89ecc 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -271,8 +271,7 @@ def __ge__(self, other): # Create a Member instance to get a realistic Mock of `discord.Member` member_data = {"user": "lemon", "roles": [1]} -state_mock = unittest.mock.MagicMock() -member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) +member_instance = discord.Member(data=member_data, guild=guild_instance, state=unittest.mock.MagicMock()) class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): @@ -399,12 +398,14 @@ def __init__(self, **kwargs) -> None: "nsfw": False, "last_message_id": generate_realistic_id(), } -state = unittest.mock.MagicMock() -guild = unittest.mock.MagicMock() -text_channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) +text_channel_instance = discord.TextChannel( + state=unittest.mock.MagicMock(), guild=unittest.mock.MagicMock(), data=channel_data +) channel_data["type"] = "VoiceChannel" -voice_channel_instance = discord.VoiceChannel(state=state, guild=guild, data=channel_data) +voice_channel_instance = discord.VoiceChannel( + state=unittest.mock.MagicMock(), guild=unittest.mock.MagicMock(), data=channel_data +) class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): @@ -444,13 +445,13 @@ def __init__(self, **kwargs) -> None: # Create data for the DMChannel instance -state = unittest.mock.MagicMock() -me = unittest.mock.MagicMock() dm_channel_data = { "id": generate_realistic_id(), "recipients": [unittest.mock.MagicMock()], } -dm_channel_instance = discord.DMChannel(me=me, state=state, data=dm_channel_data) +dm_channel_instance = discord.DMChannel( + me=unittest.mock.MagicMock(), state=unittest.mock.MagicMock(), data=dm_channel_data +) class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): @@ -476,9 +477,9 @@ def __init__(self, **kwargs) -> None: "position": 1, } -state = unittest.mock.MagicMock() -guild = unittest.mock.MagicMock() -category_channel_instance = discord.CategoryChannel(state=state, guild=guild, data=category_channel_data) +category_channel_instance = discord.CategoryChannel( + state=unittest.mock.MagicMock(), guild=unittest.mock.MagicMock(), data=category_channel_data +) class MockCategoryChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): @@ -515,9 +516,9 @@ def __init__(self, **kwargs) -> None: "thread_metadata": thread_metadata, } -state = unittest.mock.MagicMock() -guild = unittest.mock.MagicMock() -thread_instance = discord.Thread(state=state, guild=guild, data=thread_data) +thread_instance = discord.Thread( + state=unittest.mock.MagicMock(), guild=unittest.mock.MagicMock(), data=thread_data +) class MockThread(CustomMockMixin, unittest.mock.Mock, HashableMixin): @@ -552,9 +553,9 @@ def __init__(self, **kwargs) -> None: "content": "content", "nonce": None, } -state = unittest.mock.MagicMock() -channel = unittest.mock.MagicMock() -message_instance = discord.Message(state=state, channel=channel, data=message_data) +message_instance = discord.Message( + state=unittest.mock.MagicMock(), channel=unittest.mock.MagicMock(), data=message_data +) # Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` From f6802fefbeec75765eae694750d72d17ce5e219e Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 24 Oct 2021 01:53:33 -0400 Subject: [PATCH 19/33] chore: use SCREAMING_SNAKE_CASE --- tests/test_mocks.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_mocks.py b/tests/test_mocks.py index 99ef9223..ca760112 100644 --- a/tests/test_mocks.py +++ b/tests/test_mocks.py @@ -247,8 +247,7 @@ async def dementati(): # pragma: nocover assert error.match("cannot reuse already awaited coroutine") -hashable_mocks = (test_mocks.MockRole, test_mocks.MockMember, test_mocks.MockGuild) -print([[x] for x in hashable_mocks]) +HASHABLE_MOCKS = (test_mocks.MockRole, test_mocks.MockMember, test_mocks.MockGuild) class TestMockObjects: @@ -311,13 +310,13 @@ class MockScragly(test_mocks.HashableMixin): assert scragly != python assert (scragly != eevee) is False - @pytest.mark.parametrize(["mock_cls"], [[x] for x in hashable_mocks]) + @pytest.mark.parametrize(["mock_cls"], [[x] for x in HASHABLE_MOCKS]) def test_mock_class_with_hashable_mixin_uses_id_for_hashing(self, mock_cls): """Test if the MagicMock subclasses that implement the HashableMixin use id bitshift for hash.""" instance = mock_cls(id=100 << 22) assert hash(instance) == instance.id >> 22 - @pytest.mark.parametrize(["mock_class"], [[x] for x in hashable_mocks]) + @pytest.mark.parametrize(["mock_class"], [[x] for x in HASHABLE_MOCKS]) def test_mock_class_with_hashable_mixin_uses_id_for_equality(self, mock_class): """Test if MagicMocks that implement the HashableMixin use id for equality comparisons.""" instance_one = mock_class() @@ -331,7 +330,7 @@ def test_mock_class_with_hashable_mixin_uses_id_for_equality(self, mock_class): assert instance_one == instance_two assert (instance_one == instance_three) is False - @pytest.mark.parametrize(["mock_class"], [[x] for x in hashable_mocks]) + @pytest.mark.parametrize(["mock_class"], [[x] for x in HASHABLE_MOCKS]) def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self, mock_class): """Test if MagicMocks that implement HashableMixin use id for nonequality comparisons.""" instance_one = mock_class() From 3f1e72548cf69c52cd1a809be81846d67e99333e Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 24 Oct 2021 20:30:23 -0400 Subject: [PATCH 20/33] chore: remove unnecessary imports, fix typo, add missing commas --- tests/mocks.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index 30d89ecc..74b1cf5f 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -33,7 +33,6 @@ import asyncio import collections import itertools -import logging import typing import unittest.mock from typing import TYPE_CHECKING, Iterable, Optional @@ -43,7 +42,6 @@ import discord import discord.mixins from discord.ext.commands import Context -from discord.utils import time_snowflake import modmail.bot @@ -150,8 +148,7 @@ def _get_child_mock(self, **kw): if _new_name in self.__dict__["_spec_asyncs"]: return unittest.mock.AsyncMock(**kw) - _type = type(self) - if issubclass(_type, unittest.mock.MagicMock) and _new_name in unittest.mock._async_method_magics: + if isinstance(self, unittest.mock.MagicMock) and _new_name in unittest.mock._async_method_magics: # Any asynchronous magic becomes an AsyncMock klass = unittest.mock.AsyncMock else: @@ -171,7 +168,7 @@ def _get_child_mock(self, **kw): "name": "guild", "region": "Europe", "verification_level": 2, - "default_notications": 1, + "default_notifications": 1, "afk_timeout": 100, "icon": "icon.png", "banner": "banner.png", @@ -212,7 +209,6 @@ class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): >>> isinstance(guild, discord.Guild) True - For more info, see the `Mocking` section in `tests/README.md`. """ spec_set = guild_instance @@ -227,7 +223,10 @@ def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None # Create a Role instance to get a realistic Mock of `discord.Role` -role_data = {"name": "role", "id": generate_realistic_id()} +role_data = { + "name": "role", + "id": generate_realistic_id(), +} role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) @@ -270,7 +269,10 @@ def __ge__(self, other): # Create a Member instance to get a realistic Mock of `discord.Member` -member_data = {"user": "lemon", "roles": [1]} +member_data = { + "user": "lemon", + "roles": [1], +} member_instance = discord.Member(data=member_data, guild=guild_instance, state=unittest.mock.MagicMock()) From 7b35353a2f4600b0f99d0c8f69b198974e55d90a Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 25 Oct 2021 01:50:12 -0400 Subject: [PATCH 21/33] tests: mock methods of mocked objects to return mocked counterparts --- tests/mocks.py | 195 ++++++++++++++++++++++++++++++++++++++++++-- tests/test_mocks.py | 47 +++++++++++ 2 files changed, 237 insertions(+), 5 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index 74b1cf5f..accfa6d3 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -3,6 +3,9 @@ Slight modifications have been made to support our bot. +Additional modifications have been made to mocked class method side_effects +in order to return the proper mock type, if it exists. + Original Source: https://github.com/python-discord/bot/blob/d183d03fa2939bebaac3da49646449fdd4d00e6c/tests/helpers.py# noqa: E501 @@ -40,14 +43,39 @@ import aiohttp import arrow import discord +import discord.ext.commands import discord.mixins -from discord.ext.commands import Context import modmail.bot _snowflake_count = itertools.count(1) +__all__ = [ + "generate_realistic_id", + "HashableMixin", + "CustomMockMixin", + "ColourMixin", + "MockAsyncWebhook", + "MockAttachment", + "MockBot", + "MockCategoryChannel", + "MockContext", + "MockClientUser", + "MockDMChannel", + "MockEmoji", + "MockGuild", + "MockMember", + "MockMessage", + "MockPartialEmoji", + "MockReaction", + "MockRole", + "MockTextChannel", + "MockThread", + "MockUser", + "MockVoiceChannel", +] + def generate_realistic_id() -> int: """Generate realistic id, based from the current time.""" @@ -98,6 +126,98 @@ def accent_color(self, color: discord.Colour) -> None: self.accent_colour = color +def generate_mock_attachment(*args, **kwargs): + return MockAttachment() + + +def generate_mock_bot(*args, **kwargs): + return MockBot() + + +def generate_mock_category_channel(*args, **kwargs): + return MockCategoryChannel() + + +def generate_mock_context(*args, **kwargs): + return MockContext() + + +def generate_mock_client_user(*args, **kwargs): + return MockClientUser() + + +def generate_mock_dm_channel(*args, **kwargs): + return MockDMChannel() + + +def generate_mock_emoji(*args, **kwargs): + return MockEmoji() + + +def generate_mock_guild(*args, **kwargs): + return MockGuild() + + +def generate_mock_member(*args, **kwargs): + return MockMember() + + +def generate_mock_message(content=unittest.mock.DEFAULT, *args, **kwargs): + return MockMessage(content=content) + + +def generate_mock_partial_emoji(*args, **kwargs): + return MockPartialEmoji() + + +def generate_mock_reaction(*args, **kwargs): + return MockReaction() + + +def generate_mock_role(*args, **kwargs): + return MockRole() + + +def generate_mock_text_channel(*args, **kwargs): + return MockTextChannel() + + +def generate_mock_thread(*args, **kwargs): + return MockThread() + + +def generate_mock_user(*args, **kwargs): + return MockUser() + + +def generate_mock_voice_channel(*args, **kwargs): + return MockVoiceChannel() + + +# all of the classes here can be created from a mock object +# the key is the object, and the method is a factory method for creating a new instance +# some of the factories can factory take their input and pass it to the mock object. +COPYABLE_MOCKS = { + discord.Attachment: generate_mock_attachment, + discord.ext.commands.Bot: generate_mock_bot, + discord.CategoryChannel: generate_mock_category_channel, + discord.ext.commands.Context: generate_mock_context, + discord.ClientUser: generate_mock_client_user, + discord.DMChannel: generate_mock_dm_channel, + discord.Emoji: generate_mock_emoji, + discord.Guild: generate_mock_guild, + discord.Member: generate_mock_member, + discord.Message: generate_mock_message, + discord.PartialEmoji: generate_mock_partial_emoji, + discord.Reaction: generate_mock_reaction, + discord.Role: generate_mock_role, + discord.TextChannel: generate_mock_text_channel, + discord.Thread: generate_mock_thread, + discord.User: generate_mock_user, + discord.VoiceChannel: generate_mock_voice_channel, +} + + class CustomMockMixin: """ Provides common functionality for our custom Mock types. @@ -130,6 +250,66 @@ def __init__(self, **kwargs): if name: self.name = name + # make all of the methods return the proper mock type + # configure mock + mock_config = {} + for attr in dir(self.spec_set): + if attr.startswith("__") and attr.endswith("__"): + # ignore all magic attributes + continue + try: + attr = getattr(self.spec_set, attr) + except AttributeError: + continue + # we only want functions, so we can properly mock their return + if not callable(attr): + continue + + if isinstance(attr, (unittest.mock.Mock, unittest.mock.AsyncMock)): + # skip all mocks + continue + + try: + hints = typing.get_type_hints(attr) + except NameError: + hints = attr.__annotations__ + + # fix not typed methods + # this list can be added to as methods are discovered + if attr.__name__ == "send": + hints["return"] = discord.Message + elif self.__class__ == discord.Message and attr.__name__ == "edit": + # set up message editing to return the same object + mock_config[f"{attr.__name__}.return_value"] = self + continue + + if hints.get("return") is None: + continue + + klass = hints["return"] + + if isinstance(klass, str): + klass_name = klass + elif hasattr(klass, "__name__"): + klass_name = klass.__name__ + else: + continue + + method = None + for cls in COPYABLE_MOCKS: + if klass_name == cls.__name__: + method = COPYABLE_MOCKS[cls] + break + + if not method: + continue + + # print(self.__class__, attr.__name__, cls) + + mock_config[f"{attr.__name__}.side_effect"] = method + + self.configure_mock(**mock_config) + def _get_child_mock(self, **kw): """ Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes. @@ -318,8 +498,10 @@ class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): spec_set = user_instance def __init__(self, **kwargs) -> None: - default_kwargs = {"name": "user", "id": next(self.discord_id), "bot": False} - super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + kwargs["name"] = kwargs.get("name", "user") + kwargs["id"] = kwargs.get("id", next(self.discord_id)) + kwargs["bot"] = kwargs.get("bot", False) + super().__init__(**kwargs) if "mention" not in kwargs: self.mention = f"@{self.name}" @@ -561,11 +743,13 @@ def __init__(self, **kwargs) -> None: # Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` -context_instance = Context(message=unittest.mock.MagicMock(), prefix="$", bot=MockBot(), view=None) +context_instance = discord.ext.commands.Context( + message=unittest.mock.MagicMock(), prefix="$", bot=MockBot(), view=None +) context_instance.invoked_from_error_handler = None -class MockContext(CustomMockMixin, unittest.mock.MagicMock): +class MockContext(CustomMockMixin, unittest.mock.NonCallableMagicMock): """ A MagicMock subclass to mock Context objects. @@ -625,6 +809,7 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: default_kwargs = {"attachments": []} + kwargs["id"] = kwargs.get("id", generate_realistic_id()) super().__init__(**collections.ChainMap(kwargs, default_kwargs)) self.author = kwargs.get("author", MockMember()) self.channel = kwargs.get("channel", MockTextChannel()) diff --git a/tests/test_mocks.py b/tests/test_mocks.py index ca760112..60621f18 100644 --- a/tests/test_mocks.py +++ b/tests/test_mocks.py @@ -388,3 +388,50 @@ class MyMock(test_mocks.CustomMockMixin, unittest.mock.MagicMock): mock = MyMock() assert isinstance(mock.__aenter__, unittest.mock.AsyncMock) + + +class TestReturnTypes: + """ + Our mocks are designed to automatically return the correct objects based on certain methods. + + Eg, ctx.send should return a message object. + """ + + @pytest.mark.asyncio + async def test_message_edit_returns_self(self): + """Message editing edits the message in place. We should be returning the message.""" + msg = test_mocks.MockMessage() + + new_msg = await msg.edit() + + assert isinstance(new_msg, discord.Message) + + assert msg is new_msg + + @pytest.mark.asyncio + async def test_channel_send_returns_message(self): + """Ensure that channel objects return mocked messages when sending messages.""" + channel = test_mocks.MockTextChannel() + + msg = await channel.send("hi") + + print(type(msg)) + assert isinstance(msg, discord.Message) + + @pytest.mark.asyncio + async def test_message_thread_create_returns_thread(self): + """Thread create methods should return a MockThread.""" + msg = test_mocks.MockMessage() + + thread = await msg.create_thread() + + assert isinstance(thread, discord.Thread) + + @pytest.mark.asyncio + async def test_channel_thread_create_returns_thread(self): + """Thread create methods should return a MockThread.""" + channel = test_mocks.MockTextChannel() + + thread = await channel.create_thread() + + assert isinstance(thread, discord.Thread) From 49297e3c84923923aaa305868ec65733d03ae559 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 26 Oct 2021 00:31:04 -0400 Subject: [PATCH 22/33] fix: make mocks not callable if their counterpart isn't --- tests/mocks.py | 39 ++++++++++++++++++++++----------------- tests/test_mocks.py | 11 +++++++++++ 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index accfa6d3..3490118d 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -126,6 +126,10 @@ def accent_color(self, color: discord.Colour) -> None: self.accent_colour = color +def generate_mock_webhook(*args, **kwargs): + return MockAsyncWebhook() + + def generate_mock_attachment(*args, **kwargs): return MockAttachment() @@ -215,6 +219,7 @@ def generate_mock_voice_channel(*args, **kwargs): discord.Thread: generate_mock_thread, discord.User: generate_mock_user, discord.VoiceChannel: generate_mock_voice_channel, + discord.Webhook: generate_mock_webhook, } @@ -365,7 +370,7 @@ def _get_child_mock(self, **kw): guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) -class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): +class MockGuild(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): """ A `Mock` subclass to mock `discord.Guild` objects. @@ -410,7 +415,7 @@ def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) -class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockRole(CustomMockMixin, unittest.mock.NonCallableMock, ColourMixin, HashableMixin): """ A Mock subclass to mock `discord.Role` objects. @@ -456,7 +461,7 @@ def __ge__(self, other): member_instance = discord.Member(data=member_data, guild=guild_instance, state=unittest.mock.MagicMock()) -class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockMember(CustomMockMixin, unittest.mock.NonCallableMock, ColourMixin, HashableMixin): """ A Mock subclass to mock Member objects. @@ -487,7 +492,7 @@ def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None ) -class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockUser(CustomMockMixin, unittest.mock.NonCallableMock, ColourMixin, HashableMixin): """ A Mock subclass to mock User objects. @@ -515,7 +520,7 @@ def __init__(self, **kwargs) -> None: ) -class MockClientUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockClientUser(CustomMockMixin, unittest.mock.NonCallableMock, ColourMixin, HashableMixin): """ A Mock subclass to mock ClientUser objects. @@ -549,7 +554,7 @@ def mock_create_task(coroutine, **kwargs): return loop -class MockBot(CustomMockMixin, unittest.mock.MagicMock): +class MockBot(CustomMockMixin, unittest.mock.NonCallableMock): """ A MagicMock subclass to mock Bot objects. @@ -592,7 +597,7 @@ def __init__(self, **kwargs) -> None: ) -class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): +class MockTextChannel(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): """ A MagicMock subclass to mock TextChannel objects. @@ -610,7 +615,7 @@ def __init__(self, **kwargs) -> None: self.mention = f"#{self.name}" -class MockVoiceChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): +class MockVoiceChannel(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): """ A MagicMock subclass to mock VoiceChannel objects. @@ -638,7 +643,7 @@ def __init__(self, **kwargs) -> None: ) -class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): +class MockDMChannel(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): """ A MagicMock subclass to mock DMChannel objects. @@ -666,7 +671,7 @@ def __init__(self, **kwargs) -> None: ) -class MockCategoryChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): +class MockCategoryChannel(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): """ A MagicMock subclass to mock CategoryChannel objects. @@ -705,7 +710,7 @@ def __init__(self, **kwargs) -> None: ) -class MockThread(CustomMockMixin, unittest.mock.Mock, HashableMixin): +class MockThread(CustomMockMixin, unittest.mock.NonCallableMock, HashableMixin): """ A MagicMock subclass to mock Thread objects. @@ -786,7 +791,7 @@ def me(self) -> typing.Union[MockMember, MockClientUser]: ) -class MockAttachment(CustomMockMixin, unittest.mock.MagicMock): +class MockAttachment(CustomMockMixin, unittest.mock.NonCallableMagicMock): """ A MagicMock subclass to mock Attachment objects. @@ -797,7 +802,7 @@ class MockAttachment(CustomMockMixin, unittest.mock.MagicMock): spec_set = attachment_instance -class MockMessage(CustomMockMixin, unittest.mock.MagicMock): +class MockMessage(CustomMockMixin, unittest.mock.NonCallableMagicMock): """ A MagicMock subclass to mock Message objects. @@ -819,7 +824,7 @@ def __init__(self, **kwargs) -> None: emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data) -class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): +class MockEmoji(CustomMockMixin, unittest.mock.NonCallableMagicMock): """ A MagicMock subclass to mock Emoji objects. @@ -837,7 +842,7 @@ def __init__(self, **kwargs) -> None: partial_emoji_instance = discord.PartialEmoji(animated=False, name="guido") -class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): +class MockPartialEmoji(CustomMockMixin, unittest.mock.NonCallableMagicMock): """ A MagicMock subclass to mock PartialEmoji objects. @@ -851,7 +856,7 @@ class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): reaction_instance = discord.Reaction(message=MockMessage(), data={"me": True}, emoji=MockEmoji()) -class MockReaction(CustomMockMixin, unittest.mock.MagicMock): +class MockReaction(CustomMockMixin, unittest.mock.NonCallableMagicMock): """ A MagicMock subclass to mock Reaction objects. @@ -877,7 +882,7 @@ def __init__(self, **kwargs) -> None: webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), session=unittest.mock.MagicMock()) -class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock): +class MockAsyncWebhook(CustomMockMixin, unittest.mock.NonCallableMagicMock): """ A MagicMock subclass to mock Webhook objects using an AsyncWebhookAdapter. diff --git a/tests/test_mocks.py b/tests/test_mocks.py index 60621f18..b61c774d 100644 --- a/tests/test_mocks.py +++ b/tests/test_mocks.py @@ -435,3 +435,14 @@ async def test_channel_thread_create_returns_thread(self): thread = await channel.create_thread() assert isinstance(thread, discord.Thread) + + +class TestMocksNotCallable: + """All discord.py mocks are not callable objects, so the mocks should not be either .""" + + @pytest.mark.parametrize("factory", test_mocks.COPYABLE_MOCKS.values()) + def test_not_callable(self, factory): + """Assert all mocks aren't callable.""" + instance = factory() + with pytest.raises(TypeError, match=f"'{type(instance).__name__}' object is not callable"): + instance() From 4f76928438364b4e810bbd190db498be2e5912ac Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 26 Oct 2021 04:06:21 -0400 Subject: [PATCH 23/33] minor: don't use so many functions when a lambda would suffice --- tests/mocks.py | 102 +++++++++---------------------------------------- 1 file changed, 17 insertions(+), 85 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index 3490118d..aa6a9162 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -126,100 +126,32 @@ def accent_color(self, color: discord.Colour) -> None: self.accent_colour = color -def generate_mock_webhook(*args, **kwargs): - return MockAsyncWebhook() - - -def generate_mock_attachment(*args, **kwargs): - return MockAttachment() - - -def generate_mock_bot(*args, **kwargs): - return MockBot() - - -def generate_mock_category_channel(*args, **kwargs): - return MockCategoryChannel() - - -def generate_mock_context(*args, **kwargs): - return MockContext() - - -def generate_mock_client_user(*args, **kwargs): - return MockClientUser() - - -def generate_mock_dm_channel(*args, **kwargs): - return MockDMChannel() - - -def generate_mock_emoji(*args, **kwargs): - return MockEmoji() - - -def generate_mock_guild(*args, **kwargs): - return MockGuild() - - -def generate_mock_member(*args, **kwargs): - return MockMember() - - def generate_mock_message(content=unittest.mock.DEFAULT, *args, **kwargs): return MockMessage(content=content) -def generate_mock_partial_emoji(*args, **kwargs): - return MockPartialEmoji() - - -def generate_mock_reaction(*args, **kwargs): - return MockReaction() - - -def generate_mock_role(*args, **kwargs): - return MockRole() - - -def generate_mock_text_channel(*args, **kwargs): - return MockTextChannel() - - -def generate_mock_thread(*args, **kwargs): - return MockThread() - - -def generate_mock_user(*args, **kwargs): - return MockUser() - - -def generate_mock_voice_channel(*args, **kwargs): - return MockVoiceChannel() - - # all of the classes here can be created from a mock object # the key is the object, and the method is a factory method for creating a new instance # some of the factories can factory take their input and pass it to the mock object. COPYABLE_MOCKS = { - discord.Attachment: generate_mock_attachment, - discord.ext.commands.Bot: generate_mock_bot, - discord.CategoryChannel: generate_mock_category_channel, - discord.ext.commands.Context: generate_mock_context, - discord.ClientUser: generate_mock_client_user, - discord.DMChannel: generate_mock_dm_channel, - discord.Emoji: generate_mock_emoji, - discord.Guild: generate_mock_guild, - discord.Member: generate_mock_member, + discord.Attachment: lambda *args, **kwargs: MockAttachment(), + discord.ext.commands.Bot: lambda *args, **kwargs: MockBot(), + discord.CategoryChannel: lambda *args, **kwargs: MockCategoryChannel(), + discord.ext.commands.Context: lambda *args, **kwargs: MockContext(), + discord.ClientUser: lambda *args, **kwargs: MockClientUser(), + discord.DMChannel: lambda *args, **kwargs: MockDMChannel(), + discord.Emoji: lambda *args, **kwargs: MockEmoji(), + discord.Guild: lambda *args, **kwargs: MockGuild(), + discord.Member: lambda *args, **kwargs: MockMember(), discord.Message: generate_mock_message, - discord.PartialEmoji: generate_mock_partial_emoji, - discord.Reaction: generate_mock_reaction, - discord.Role: generate_mock_role, - discord.TextChannel: generate_mock_text_channel, - discord.Thread: generate_mock_thread, - discord.User: generate_mock_user, - discord.VoiceChannel: generate_mock_voice_channel, - discord.Webhook: generate_mock_webhook, + discord.PartialEmoji: lambda *args, **kwargs: MockPartialEmoji(), + discord.Reaction: lambda *args, **kwargs: MockReaction(), + discord.Role: lambda *args, **kwargs: MockRole(), + discord.TextChannel: lambda *args, **kwargs: MockTextChannel(), + discord.Thread: lambda *args, **kwargs: MockThread(), + discord.User: lambda *args, **kwargs: MockUser(), + discord.VoiceChannel: lambda *args, **kwargs: MockVoiceChannel(), + discord.Webhook: lambda *args, **kwargs: MockAsyncWebhook(), } From 04d16c0fe6c97154719065ff28222bee95ce3982 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 26 Oct 2021 18:06:14 -0400 Subject: [PATCH 24/33] chore: don't make unnecessary 'as' import --- tests/test_mocks.py | 142 ++++++++++++++++++++++---------------------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/tests/test_mocks.py b/tests/test_mocks.py index b61c774d..28911707 100644 --- a/tests/test_mocks.py +++ b/tests/test_mocks.py @@ -38,7 +38,7 @@ import discord.ext.commands import pytest -from tests import mocks as test_mocks +from tests import mocks class TestDiscordMocks: @@ -46,7 +46,7 @@ class TestDiscordMocks: def test_mock_role_default_initialization(self): """Test if the default initialization of MockRole results in the correct object.""" - role = test_mocks.MockRole() + role = mocks.MockRole() # The `spec` argument makes sure `isinstance` checks with `discord.Role` pass assert isinstance(role, discord.Role) @@ -57,7 +57,7 @@ def test_mock_role_default_initialization(self): def test_mock_role_alternative_arguments(self): """Test if MockRole initializes with the arguments provided.""" - role = test_mocks.MockRole( + role = mocks.MockRole( name="Admins", id=90210, position=10, @@ -70,7 +70,7 @@ def test_mock_role_alternative_arguments(self): def test_mock_role_accepts_dynamic_arguments(self): """Test if MockRole accepts and sets abitrary keyword arguments.""" - role = test_mocks.MockRole( + role = mocks.MockRole( guild="Dino Man", hoist=True, ) @@ -80,9 +80,9 @@ def test_mock_role_accepts_dynamic_arguments(self): def test_mock_role_uses_position_for_less_than_greater_than(self): """Test if `<` and `>` comparisons for MockRole are based on its position attribute.""" - role_one = test_mocks.MockRole(position=1) - role_two = test_mocks.MockRole(position=2) - role_three = test_mocks.MockRole(position=3) + role_one = mocks.MockRole(position=1) + role_two = mocks.MockRole(position=2) + role_three = mocks.MockRole(position=3) assert role_one < role_two assert role_one < role_three @@ -93,28 +93,28 @@ def test_mock_role_uses_position_for_less_than_greater_than(self): def test_mock_member_default_initialization(self): """Test if the default initialization of Mockmember results in the correct object.""" - member = test_mocks.MockMember() + member = mocks.MockMember() # The `spec` argument makes sure `isinstance` checks with `discord.Member` pass assert isinstance(member, discord.Member) assert member.name == "member" - assert member.roles == [test_mocks.MockRole(name="@everyone", position=1, id=0)] + assert member.roles == [mocks.MockRole(name="@everyone", position=1, id=0)] assert member.mention == "@member" def test_mock_member_alternative_arguments(self): """Test if MockMember initializes with the arguments provided.""" - core_developer = test_mocks.MockRole(name="Core Developer", position=2) - member = test_mocks.MockMember(name="Mark", id=12345, roles=[core_developer]) + core_developer = mocks.MockRole(name="Core Developer", position=2) + member = mocks.MockMember(name="Mark", id=12345, roles=[core_developer]) assert member.name == "Mark" assert member.id == 12345 - assert member.roles == [test_mocks.MockRole(name="@everyone", position=1, id=0), core_developer] + assert member.roles == [mocks.MockRole(name="@everyone", position=1, id=0), core_developer] assert member.mention == "@Mark" def test_mock_member_accepts_dynamic_arguments(self): """Test if MockMember accepts and sets abitrary keyword arguments.""" - member = test_mocks.MockMember( + member = mocks.MockMember( nick="Dino Man", colour=discord.Colour.default(), ) @@ -124,28 +124,28 @@ def test_mock_member_accepts_dynamic_arguments(self): def test_mock_guild_default_initialization(self): """Test if the default initialization of Mockguild results in the correct object.""" - guild = test_mocks.MockGuild() + guild = mocks.MockGuild() # The `spec` argument makes sure `isistance` checks with `discord.Guild` pass assert isinstance(guild, discord.Guild) - assert guild.roles == [test_mocks.MockRole(name="@everyone", position=1, id=0)] + assert guild.roles == [mocks.MockRole(name="@everyone", position=1, id=0)] assert guild.members == [] def test_mock_guild_alternative_arguments(self): """Test if MockGuild initializes with the arguments provided.""" - core_developer = test_mocks.MockRole(name="Core Developer", position=2) - guild = test_mocks.MockGuild( + core_developer = mocks.MockRole(name="Core Developer", position=2) + guild = mocks.MockGuild( roles=[core_developer], - members=[test_mocks.MockMember(id=54321)], + members=[mocks.MockMember(id=54321)], ) - assert guild.roles == [test_mocks.MockRole(name="@everyone", position=1, id=0), core_developer] - assert guild.members == [test_mocks.MockMember(id=54321)] + assert guild.roles == [mocks.MockRole(name="@everyone", position=1, id=0), core_developer] + assert guild.members == [mocks.MockMember(id=54321)] def test_mock_guild_accepts_dynamic_arguments(self): """Test if MockGuild accepts and sets abitrary keyword arguments.""" - guild = test_mocks.MockGuild( + guild = mocks.MockGuild( emojis=(":hyperjoseph:", ":pensive_ela:"), premium_subscription_count=15, ) @@ -155,44 +155,44 @@ def test_mock_guild_accepts_dynamic_arguments(self): def test_mock_bot_default_initialization(self): """Tests if MockBot initializes with the correct values.""" - bot = test_mocks.MockBot() + bot = mocks.MockBot() # The `spec` argument makes sure `isinstance` checks with `discord.ext.commands.Bot` pass assert isinstance(bot, discord.ext.commands.Bot) def test_mock_context_default_initialization(self): """Tests if MockContext initializes with the correct values.""" - context = test_mocks.MockContext() + context = mocks.MockContext() # The `spec` argument makes sure `isinstance` checks with `discord.ext.commands.Context` pass assert isinstance(context, discord.ext.commands.Context) - assert isinstance(context.bot, test_mocks.MockBot) - assert isinstance(context.guild, test_mocks.MockGuild) - assert isinstance(context.author, test_mocks.MockMember) - assert isinstance(context.message, test_mocks.MockMessage) + assert isinstance(context.bot, mocks.MockBot) + assert isinstance(context.guild, mocks.MockGuild) + assert isinstance(context.author, mocks.MockMember) + assert isinstance(context.message, mocks.MockMessage) # ensure that the mocks are the same attributes, like discord.py assert context.message.channel is context.channel assert context.channel.guild is context.guild # ensure the me instance is of the right type and shtuff. - assert isinstance(context.me, test_mocks.MockMember) + assert isinstance(context.me, mocks.MockMember) assert context.me is context.guild.me @pytest.mark.parametrize( ["mock", "valid_attribute"], [ - [test_mocks.MockGuild(), "name"], - [test_mocks.MockRole(), "hoist"], - [test_mocks.MockMember(), "display_name"], - [test_mocks.MockBot(), "user"], - [test_mocks.MockContext(), "invoked_with"], - [test_mocks.MockTextChannel(), "last_message"], - [test_mocks.MockMessage(), "mention_everyone"], + [mocks.MockGuild(), "name"], + [mocks.MockRole(), "hoist"], + [mocks.MockMember(), "display_name"], + [mocks.MockBot(), "user"], + [mocks.MockContext(), "invoked_with"], + [mocks.MockTextChannel(), "last_message"], + [mocks.MockMessage(), "mention_everyone"], ], ) - def test_mocks_allows_access_to_attributes_part_of_spec(self, mock, valid_attribute: str): + def mocks_allows_access_to_attributes_part_of_spec(self, mock, valid_attribute: str): """Accessing attributes that are valid for the objects they mock should succeed.""" try: getattr(mock, valid_attribute) @@ -203,16 +203,16 @@ def test_mocks_allows_access_to_attributes_part_of_spec(self, mock, valid_attrib @pytest.mark.parametrize( ["mock"], [ - [test_mocks.MockGuild()], - [test_mocks.MockRole()], - [test_mocks.MockMember()], - [test_mocks.MockBot()], - [test_mocks.MockContext()], - [test_mocks.MockTextChannel()], - [test_mocks.MockMessage()], + [mocks.MockGuild()], + [mocks.MockRole()], + [mocks.MockMember()], + [mocks.MockBot()], + [mocks.MockContext()], + [mocks.MockTextChannel()], + [mocks.MockMessage()], ], ) - def test_mocks_rejects_access_to_attributes_not_part_of_spec(self, mock): + def mocks_rejects_access_to_attributes_not_part_of_spec(self, mock): """Accessing attributes that are invalid for the objects they mock should fail.""" with pytest.raises(AttributeError): mock.the_cake_is_a_lie @@ -220,13 +220,13 @@ def test_mocks_rejects_access_to_attributes_not_part_of_spec(self, mock): @pytest.mark.parametrize( ["mock_type", "provided_mention"], [ - [test_mocks.MockRole, "role mention"], - [test_mocks.MockMember, "member mention"], - [test_mocks.MockTextChannel, "channel mention"], - [test_mocks.MockUser, "user mention"], + [mocks.MockRole, "role mention"], + [mocks.MockMember, "member mention"], + [mocks.MockTextChannel, "channel mention"], + [mocks.MockUser, "user mention"], ], ) - def test_mocks_use_mention_when_provided_as_kwarg(self, mock_type, provided_mention: str): + def mocks_use_mention_when_provided_as_kwarg(self, mock_type, provided_mention: str): """The mock should use the passed `mention` instead of the default one if present.""" mock = mock_type(mention=provided_mention) assert mock.mention == provided_mention @@ -240,14 +240,14 @@ async def dementati(): # pragma: nocover coroutine_object = dementati() - bot = test_mocks.MockBot() + bot = mocks.MockBot() bot.loop.create_task(coroutine_object) with pytest.raises(RuntimeError) as error: asyncio.run(coroutine_object) assert error.match("cannot reuse already awaited coroutine") -HASHABLE_MOCKS = (test_mocks.MockRole, test_mocks.MockMember, test_mocks.MockGuild) +HASHABLE_MOCKS = (mocks.MockRole, mocks.MockMember, mocks.MockGuild) class TestMockObjects: @@ -256,7 +256,7 @@ class TestMockObjects: def test_colour_mixin(self): """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" - class MockHemlock(unittest.mock.MagicMock, test_mocks.ColourMixin): + class MockHemlock(unittest.mock.MagicMock, mocks.ColourMixin): pass hemlock = MockHemlock() @@ -271,7 +271,7 @@ class MockHemlock(unittest.mock.MagicMock, test_mocks.ColourMixin): def test_hashable_mixin_hash_returns_id(self): """Test the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, test_mocks.HashableMixin): + class MockScragly(unittest.mock.Mock, mocks.HashableMixin): pass scragly = MockScragly() @@ -281,7 +281,7 @@ class MockScragly(unittest.mock.Mock, test_mocks.HashableMixin): def test_hashable_mixin_uses_id_for_equality_comparison(self): """Test the HashableMixing uses the id attribute for equal comparison.""" - class MockScragly(test_mocks.HashableMixin): + class MockScragly(mocks.HashableMixin): pass scragly = MockScragly() @@ -297,7 +297,7 @@ class MockScragly(test_mocks.HashableMixin): def test_hashable_mixin_uses_id_for_nonequality_comparison(self): """Test if the HashableMixing uses the id attribute for non-equal comparison.""" - class MockScragly(test_mocks.HashableMixin): + class MockScragly(mocks.HashableMixin): pass scragly = MockScragly() @@ -347,7 +347,7 @@ def test_mock_class_with_hashable_mixin_uses_id_for_nonequality(self, mock_class def test_custom_mock_mixin_accepts_mock_seal(self): """The `CustomMockMixin` should support `unittest.mock.seal`.""" - class MyMock(test_mocks.CustomMockMixin, unittest.mock.MagicMock): + class MyMock(mocks.CustomMockMixin, unittest.mock.MagicMock): child_mock_type = unittest.mock.MagicMock pass @@ -362,15 +362,15 @@ class MyMock(test_mocks.CustomMockMixin, unittest.mock.MagicMock): @pytest.mark.parametrize( ["mock_type", "valid_attribute"], [ - (test_mocks.MockGuild, "region"), - (test_mocks.MockRole, "mentionable"), - (test_mocks.MockMember, "display_name"), - (test_mocks.MockBot, "owner_id"), - (test_mocks.MockContext, "command_failed"), - (test_mocks.MockMessage, "mention_everyone"), - (test_mocks.MockEmoji, "managed"), - (test_mocks.MockPartialEmoji, "url"), - (test_mocks.MockReaction, "me"), + (mocks.MockGuild, "region"), + (mocks.MockRole, "mentionable"), + (mocks.MockMember, "display_name"), + (mocks.MockBot, "owner_id"), + (mocks.MockContext, "command_failed"), + (mocks.MockMessage, "mention_everyone"), + (mocks.MockEmoji, "managed"), + (mocks.MockPartialEmoji, "url"), + (mocks.MockReaction, "me"), ], ) def test_spec_propagation_of_mock_subclasses(self, mock_type, valid_attribute: str): @@ -383,7 +383,7 @@ def test_spec_propagation_of_mock_subclasses(self, mock_type, valid_attribute: s def test_custom_mock_mixin_mocks_async_magic_methods_with_async_mock(self): """The CustomMockMixin should mock async magic methods with an AsyncMock.""" - class MyMock(test_mocks.CustomMockMixin, unittest.mock.MagicMock): + class MyMock(mocks.CustomMockMixin, unittest.mock.MagicMock): pass mock = MyMock() @@ -400,7 +400,7 @@ class TestReturnTypes: @pytest.mark.asyncio async def test_message_edit_returns_self(self): """Message editing edits the message in place. We should be returning the message.""" - msg = test_mocks.MockMessage() + msg = mocks.MockMessage() new_msg = await msg.edit() @@ -411,7 +411,7 @@ async def test_message_edit_returns_self(self): @pytest.mark.asyncio async def test_channel_send_returns_message(self): """Ensure that channel objects return mocked messages when sending messages.""" - channel = test_mocks.MockTextChannel() + channel = mocks.MockTextChannel() msg = await channel.send("hi") @@ -421,7 +421,7 @@ async def test_channel_send_returns_message(self): @pytest.mark.asyncio async def test_message_thread_create_returns_thread(self): """Thread create methods should return a MockThread.""" - msg = test_mocks.MockMessage() + msg = mocks.MockMessage() thread = await msg.create_thread() @@ -430,7 +430,7 @@ async def test_message_thread_create_returns_thread(self): @pytest.mark.asyncio async def test_channel_thread_create_returns_thread(self): """Thread create methods should return a MockThread.""" - channel = test_mocks.MockTextChannel() + channel = mocks.MockTextChannel() thread = await channel.create_thread() @@ -440,7 +440,7 @@ async def test_channel_thread_create_returns_thread(self): class TestMocksNotCallable: """All discord.py mocks are not callable objects, so the mocks should not be either .""" - @pytest.mark.parametrize("factory", test_mocks.COPYABLE_MOCKS.values()) + @pytest.mark.parametrize("factory", mocks.COPYABLE_MOCKS.values()) def test_not_callable(self, factory): """Assert all mocks aren't callable.""" instance = factory() From 33122cfde1b951a1f611ed1b2bf1fe519269ad16 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 26 Oct 2021 18:25:48 -0400 Subject: [PATCH 25/33] chore: allow generating snowflakes from a provided time --- tests/mocks.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index aa6a9162..4f587896 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -35,6 +35,7 @@ import asyncio import collections +import datetime import itertools import typing import unittest.mock @@ -77,9 +78,15 @@ ] -def generate_realistic_id() -> int: - """Generate realistic id, based from the current time.""" - return discord.utils.time_snowflake(arrow.utcnow()) + next(_snowflake_count) +def generate_realistic_id(time: typing.Union[arrow.Arrow, datetime.datetime] = None, /) -> int: + """ + Generate realistic id, based from the current time. + + If a time is provided, this will use that time for the time generation. + """ + if time is None: + time = arrow.utcnow() + return discord.utils.time_snowflake(time) + next(_snowflake_count) class GenerateID: From 8db04c45300b3576789be1e736bae9851b802af0 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 26 Oct 2021 21:46:23 -0400 Subject: [PATCH 26/33] cleanup: use pytest parameterize to dedupe some tests --- tests/mocks.py | 2 +- tests/test_mocks.py | 195 ++++++++++++++++++++++---------------------- 2 files changed, 100 insertions(+), 97 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index 4f587896..8d85e926 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -565,7 +565,7 @@ class MockVoiceChannel(CustomMockMixin, unittest.mock.NonCallableMock, HashableM spec_set = voice_channel_instance def __init__(self, **kwargs) -> None: - default_kwargs = {"id": next(self.discord_id), "name": "channel", "guild": MockGuild()} + default_kwargs = {"id": next(self.discord_id), "name": "voice_channel", "guild": MockGuild()} super().__init__(**collections.ChainMap(kwargs, default_kwargs)) if "mention" not in kwargs: diff --git a/tests/test_mocks.py b/tests/test_mocks.py index 28911707..1648f1a3 100644 --- a/tests/test_mocks.py +++ b/tests/test_mocks.py @@ -31,6 +31,7 @@ """ import asyncio +import typing import unittest.mock import arrow @@ -44,39 +45,73 @@ class TestDiscordMocks: """Tests for our specialized discord.py mocks.""" - def test_mock_role_default_initialization(self): - """Test if the default initialization of MockRole results in the correct object.""" - role = mocks.MockRole() - - # The `spec` argument makes sure `isinstance` checks with `discord.Role` pass - assert isinstance(role, discord.Role) - - assert role.name == "role" - assert role.position == 1 - assert role.mention == "&role" + @pytest.mark.parametrize( + ["mock_class", "counterpart", "mock_args"], + [ + [mocks.MockRole, discord.Role, {"name": "role", "position": 1, "mention": "&role"}], + [ + mocks.MockMember, + discord.Member, + { + "name": "member", + "roles": [mocks.MockRole(name="@everyone", position=1, id=0)], + "mention": "@member", + }, + ], + [ + mocks.MockGuild, + discord.Guild, + {"roles": [mocks.MockRole(name="@everyone", position=1, id=0)], "members": []}, + ], + [mocks.MockBot, discord.ext.commands.Bot, {}], + ], + ) + def test_mock_obj_default_initialization( + self, mock_class: typing.Any, counterpart: typing.Any, mock_args: dict + ): + """Test if the default initialization of a mock object results in the correct object.""" + obj = mock_class() - def test_mock_role_alternative_arguments(self): - """Test if MockRole initializes with the arguments provided.""" - role = mocks.MockRole( - name="Admins", - id=90210, - position=10, - ) + # The `spec` argument makes sure `isinstance` checks with mocks pass + assert isinstance(obj, counterpart) - assert role.name == "Admins" - assert role.id == 90210 - assert role.position == 10 - assert role.mention == "&Admins" + for k, v in mock_args.items(): + assert getattr(obj, k) == v - def test_mock_role_accepts_dynamic_arguments(self): - """Test if MockRole accepts and sets abitrary keyword arguments.""" - role = mocks.MockRole( - guild="Dino Man", - hoist=True, - ) + @pytest.mark.parametrize( + ["mock_class", "mock_args", "extra_mock_args"], + [ + [ + mocks.MockRole, + { + "name": "Admins", + "position": 10, + "id": mocks.generate_realistic_id(arrow.get(1517133142)), + }, + {"mention": "&Admins"}, + ], + [ + mocks.MockMember, + {"name": "arl", "id": mocks.generate_realistic_id(arrow.get(1620350090))}, + {"mention": "@arl"}, + ], + [ + mocks.MockGuild, + {"members": []}, + {"roles": [mocks.MockRole(name="@everyone", position=1, id=0)]}, + ], + [mocks.MockVoiceChannel, {}, {"mention": "#voice_channel"}], + ], + ) + def test_mock_obj_initialization_with_args( + self, mock_class: typing.Any, mock_args: dict, extra_mock_args: dict + ): + """Test if an initialization of a mock object with keywords results in the correct object.""" + obj = mock_class(**mock_args) - assert role.guild == "Dino Man" - assert role.hoist + mock_args.update(extra_mock_args) + for k, v in mock_args.items(): + assert v == getattr(obj, k) def test_mock_role_uses_position_for_less_than_greater_than(self): """Test if `<` and `>` comparisons for MockRole are based on its position attribute.""" @@ -91,47 +126,6 @@ def test_mock_role_uses_position_for_less_than_greater_than(self): assert role_three > role_one assert role_two > role_one - def test_mock_member_default_initialization(self): - """Test if the default initialization of Mockmember results in the correct object.""" - member = mocks.MockMember() - - # The `spec` argument makes sure `isinstance` checks with `discord.Member` pass - assert isinstance(member, discord.Member) - - assert member.name == "member" - assert member.roles == [mocks.MockRole(name="@everyone", position=1, id=0)] - assert member.mention == "@member" - - def test_mock_member_alternative_arguments(self): - """Test if MockMember initializes with the arguments provided.""" - core_developer = mocks.MockRole(name="Core Developer", position=2) - member = mocks.MockMember(name="Mark", id=12345, roles=[core_developer]) - - assert member.name == "Mark" - assert member.id == 12345 - assert member.roles == [mocks.MockRole(name="@everyone", position=1, id=0), core_developer] - assert member.mention == "@Mark" - - def test_mock_member_accepts_dynamic_arguments(self): - """Test if MockMember accepts and sets abitrary keyword arguments.""" - member = mocks.MockMember( - nick="Dino Man", - colour=discord.Colour.default(), - ) - - assert member.nick == "Dino Man" - assert member.colour == discord.Colour.default() - - def test_mock_guild_default_initialization(self): - """Test if the default initialization of Mockguild results in the correct object.""" - guild = mocks.MockGuild() - - # The `spec` argument makes sure `isistance` checks with `discord.Guild` pass - assert isinstance(guild, discord.Guild) - - assert guild.roles == [mocks.MockRole(name="@everyone", position=1, id=0)] - assert guild.members == [] - def test_mock_guild_alternative_arguments(self): """Test if MockGuild initializes with the arguments provided.""" core_developer = mocks.MockRole(name="Core Developer", position=2) @@ -153,13 +147,6 @@ def test_mock_guild_accepts_dynamic_arguments(self): assert guild.emojis == (":hyperjoseph:", ":pensive_ela:") assert guild.premium_subscription_count == 15 - def test_mock_bot_default_initialization(self): - """Tests if MockBot initializes with the correct values.""" - bot = mocks.MockBot() - - # The `spec` argument makes sure `isinstance` checks with `discord.ext.commands.Bot` pass - assert isinstance(bot, discord.ext.commands.Bot) - def test_mock_context_default_initialization(self): """Tests if MockContext initializes with the correct values.""" context = mocks.MockContext() @@ -183,17 +170,18 @@ def test_mock_context_default_initialization(self): @pytest.mark.parametrize( ["mock", "valid_attribute"], [ - [mocks.MockGuild(), "name"], - [mocks.MockRole(), "hoist"], - [mocks.MockMember(), "display_name"], - [mocks.MockBot(), "user"], - [mocks.MockContext(), "invoked_with"], - [mocks.MockTextChannel(), "last_message"], - [mocks.MockMessage(), "mention_everyone"], + [mocks.MockGuild, "name"], + [mocks.MockRole, "hoist"], + [mocks.MockMember, "display_name"], + [mocks.MockBot, "user"], + [mocks.MockContext, "invoked_with"], + [mocks.MockTextChannel, "last_message"], + [mocks.MockMessage, "mention_everyone"], ], ) - def mocks_allows_access_to_attributes_part_of_spec(self, mock, valid_attribute: str): + def test_mocks_allows_access_to_attributes_part_of_spec(self, mock, valid_attribute: str): """Accessing attributes that are valid for the objects they mock should succeed.""" + mock = mock() try: getattr(mock, valid_attribute) except AttributeError: # pragma: nocover @@ -203,30 +191,39 @@ def mocks_allows_access_to_attributes_part_of_spec(self, mock, valid_attribute: @pytest.mark.parametrize( ["mock"], [ - [mocks.MockGuild()], - [mocks.MockRole()], - [mocks.MockMember()], - [mocks.MockBot()], - [mocks.MockContext()], - [mocks.MockTextChannel()], - [mocks.MockMessage()], + [mocks.MockBot], + [mocks.MockCategoryChannel], + [mocks.MockContext], + [mocks.MockClientUser], + [mocks.MockDMChannel], + [mocks.MockGuild], + [mocks.MockMember], + [mocks.MockMessage], + [mocks.MockRole], + [mocks.MockTextChannel], + [mocks.MockThread], + [mocks.MockUser], + [mocks.MockVoiceChannel], ], ) - def mocks_rejects_access_to_attributes_not_part_of_spec(self, mock): + def test_mocks_rejects_access_to_attributes_not_part_of_spec(self, mock): """Accessing attributes that are invalid for the objects they mock should fail.""" + mock = mock() with pytest.raises(AttributeError): mock.the_cake_is_a_lie @pytest.mark.parametrize( ["mock_type", "provided_mention"], [ - [mocks.MockRole, "role mention"], + [mocks.MockClientUser, "client_user mention"], [mocks.MockMember, "member mention"], + [mocks.MockRole, "role mention"], [mocks.MockTextChannel, "channel mention"], + [mocks.MockThread, "thread mention"], [mocks.MockUser, "user mention"], ], ) - def mocks_use_mention_when_provided_as_kwarg(self, mock_type, provided_mention: str): + def test_mocks_use_mention_when_provided_as_kwarg(self, mock_type, provided_mention: str): """The mock should use the passed `mention` instead of the default one if present.""" mock = mock_type(mention=provided_mention) assert mock.mention == provided_mention @@ -247,7 +244,13 @@ async def dementati(): # pragma: nocover assert error.match("cannot reuse already awaited coroutine") -HASHABLE_MOCKS = (mocks.MockRole, mocks.MockMember, mocks.MockGuild) +HASHABLE_MOCKS = ( + mocks.MockRole, + mocks.MockMember, + mocks.MockGuild, + mocks.MockTextChannel, + mocks.MockVoiceChannel, +) class TestMockObjects: From 6f92e86ffb982a08437f4c4d70cddad722234451 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 26 Oct 2021 22:09:56 -0400 Subject: [PATCH 27/33] chore: remove typos, add comments, general nitpicks --- tests/mocks.py | 1 + tests/test_mocks.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index 8d85e926..d13cd32d 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -278,6 +278,7 @@ def _get_child_mock(self, **kw): else: klass = self.child_mock_type + # if the mock is sealed, that means that new mocks cannot be created when accessing attributes if self._mock_sealed: attribute = "." + kw["name"] if "name" in kw else "()" mock_name = self._extract_mock_name() + attribute diff --git a/tests/test_mocks.py b/tests/test_mocks.py index 1648f1a3..04a8b952 100644 --- a/tests/test_mocks.py +++ b/tests/test_mocks.py @@ -138,7 +138,7 @@ def test_mock_guild_alternative_arguments(self): assert guild.members == [mocks.MockMember(id=54321)] def test_mock_guild_accepts_dynamic_arguments(self): - """Test if MockGuild accepts and sets abitrary keyword arguments.""" + """Test if MockGuild accepts and sets arbitrary keyword arguments.""" guild = mocks.MockGuild( emojis=(":hyperjoseph:", ":pensive_ela:"), premium_subscription_count=15, @@ -163,7 +163,7 @@ def test_mock_context_default_initialization(self): assert context.message.channel is context.channel assert context.channel.guild is context.guild - # ensure the me instance is of the right type and shtuff. + # ensure the me instance is of the right type and is shared among mock attributes. assert isinstance(context.me, mocks.MockMember) assert context.me is context.guild.me @@ -297,7 +297,7 @@ class MockScragly(mocks.HashableMixin): assert scragly == eevee assert (scragly == python) is False - def test_hashable_mixin_uses_id_for_nonequality_comparison(self): + def test_hashable_mixin_uses_id_for_inequality_comparison(self): """Test if the HashableMixing uses the id attribute for non-equal comparison.""" class MockScragly(mocks.HashableMixin): From aa3b4de92961c69e12cf875fab215c17b448d484 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 26 Oct 2021 22:19:27 -0400 Subject: [PATCH 28/33] tests: don't always test the mocks The mock file is pretty big, and due to that has a lot of tests The mocks should not need to be edited often, only when the dependency they are mocking is updated. This is currently never, as it is unmaintained. This means that we don't need to run the tests for the mocks all the time. If an upgrade of our dependencies is taken, then the test file should be ran. However, it does not need to run every time the test suite is ran. This should lead to a faster total test suite when running the suite. --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ab59efdc..5281ba95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,13 +63,13 @@ build-backend = "poetry.core.masonry.api" [tool.coverage.run] branch = true -source_pkgs = ['modmail', 'tests'] +source_pkgs = ['modmail', 'tests.modmail'] omit = ["modmail/plugins/**.*"] [tool.pytest.ini_options] addopts = "--cov --cov-report=" minversion = "6.0" -testpaths = ["tests"] +testpaths = ["tests/modmail"] [tool.black] line-length = 110 @@ -89,3 +89,4 @@ lint = { cmd = "pre-commit run --all-files", help = "Checks all files for CI err precommit = { cmd = "pre-commit install --install-hooks", help = "Installs the precommit hook" } report = { cmd = "coverage report", help = "Show coverage report from previously run tests." } test = { cmd = "pytest -n auto --dist loadfile", help = "Runs tests and save results to a coverage report" } +test_mocks = { cmd = 'pytest tests/test_mocks.py', help = 'Runs the tests on the mock files. They are excluded from the main test suite.' } From 3d6b29d3a6247a813f99a64ea4a8620fb955e722 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Wed, 27 Oct 2021 04:02:46 -0400 Subject: [PATCH 29/33] fix: return types not always returning the correct type --- tests/mocks.py | 12 ++++------ tests/test_mocks.py | 58 ++++++++++++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/tests/mocks.py b/tests/mocks.py index d13cd32d..fed6b01d 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -57,7 +57,6 @@ "HashableMixin", "CustomMockMixin", "ColourMixin", - "MockAsyncWebhook", "MockAttachment", "MockBot", "MockCategoryChannel", @@ -75,6 +74,7 @@ "MockThread", "MockUser", "MockVoiceChannel", + "MockWebhook", ] @@ -158,7 +158,7 @@ def generate_mock_message(content=unittest.mock.DEFAULT, *args, **kwargs): discord.Thread: lambda *args, **kwargs: MockThread(), discord.User: lambda *args, **kwargs: MockUser(), discord.VoiceChannel: lambda *args, **kwargs: MockVoiceChannel(), - discord.Webhook: lambda *args, **kwargs: MockAsyncWebhook(), + discord.Webhook: lambda *args, **kwargs: MockWebhook(), } @@ -222,10 +222,8 @@ def __init__(self, **kwargs): # this list can be added to as methods are discovered if attr.__name__ == "send": hints["return"] = discord.Message - elif self.__class__ == discord.Message and attr.__name__ == "edit": - # set up message editing to return the same object - mock_config[f"{attr.__name__}.return_value"] = self - continue + elif attr.__name__ == "edit": + hints["return"] = type(self.spec_set) if hints.get("return") is None: continue @@ -822,7 +820,7 @@ def __init__(self, **kwargs) -> None: webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), session=unittest.mock.MagicMock()) -class MockAsyncWebhook(CustomMockMixin, unittest.mock.NonCallableMagicMock): +class MockWebhook(CustomMockMixin, unittest.mock.NonCallableMagicMock): """ A MagicMock subclass to mock Webhook objects using an AsyncWebhookAdapter. diff --git a/tests/test_mocks.py b/tests/test_mocks.py index 04a8b952..e1b56329 100644 --- a/tests/test_mocks.py +++ b/tests/test_mocks.py @@ -400,42 +400,56 @@ class TestReturnTypes: Eg, ctx.send should return a message object. """ + @pytest.mark.parametrize( + "mock_cls", + [ + mocks.MockClientUser, + mocks.MockGuild, + mocks.MockMember, + mocks.MockMessage, + mocks.MockTextChannel, + mocks.MockVoiceChannel, + mocks.MockWebhook, + ], + ) @pytest.mark.asyncio - async def test_message_edit_returns_self(self): - """Message editing edits the message in place. We should be returning the message.""" - msg = mocks.MockMessage() - - new_msg = await msg.edit() + async def test_edit_returns_same_class(self, mock_cls): + """Edit methods return a new instance of the same type.""" + mock = mock_cls() - assert isinstance(new_msg, discord.Message) + new_mock = await mock.edit() - assert msg is new_msg + assert isinstance(new_mock, type(mock_cls.spec_set)) + @pytest.mark.parametrize( + "mock_cls", + [ + mocks.MockMember, + mocks.MockTextChannel, + mocks.MockThread, + mocks.MockUser, + ], + ) @pytest.mark.asyncio - async def test_channel_send_returns_message(self): + async def test_messageable_send_returns_message(self, mock_cls): """Ensure that channel objects return mocked messages when sending messages.""" - channel = mocks.MockTextChannel() + messageable = mock_cls() - msg = await channel.send("hi") + msg = await messageable.send("hi") print(type(msg)) assert isinstance(msg, discord.Message) + @pytest.mark.parametrize( + "mock_cls", + [mocks.MockMessage, mocks.MockTextChannel], + ) @pytest.mark.asyncio - async def test_message_thread_create_returns_thread(self): - """Thread create methods should return a MockThread.""" - msg = mocks.MockMessage() - - thread = await msg.create_thread() - - assert isinstance(thread, discord.Thread) - - @pytest.mark.asyncio - async def test_channel_thread_create_returns_thread(self): + async def test_thread_create_returns_thread(self, mock_cls): """Thread create methods should return a MockThread.""" - channel = mocks.MockTextChannel() + mock = mock_cls() - thread = await channel.create_thread() + thread = await mock.create_thread() assert isinstance(thread, discord.Thread) From 8164591166e53594dc961bd4b74340acdeaea954 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Mon, 18 Oct 2021 00:26:24 -0400 Subject: [PATCH 30/33] tests: add some error handler tests Signed-off-by: onerandomusername --- tests/modmail/extensions/utils/__init__.py | 0 .../extensions/utils/test_error_handler.py | 174 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 tests/modmail/extensions/utils/__init__.py create mode 100644 tests/modmail/extensions/utils/test_error_handler.py diff --git a/tests/modmail/extensions/utils/__init__.py b/tests/modmail/extensions/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/modmail/extensions/utils/test_error_handler.py b/tests/modmail/extensions/utils/test_error_handler.py new file mode 100644 index 00000000..8dbfc676 --- /dev/null +++ b/tests/modmail/extensions/utils/test_error_handler.py @@ -0,0 +1,174 @@ +import inspect +import typing +import unittest.mock + +import discord +import pytest +from discord.ext import commands + +from modmail.extensions.utils import error_handler +from modmail.extensions.utils.error_handler import ErrorHandler +from tests import mocks + + +@pytest.fixture +def cog(): + """Pytest fixture for error_handler.""" + return ErrorHandler(mocks.MockBot()) + + +@pytest.fixture +def ctx(): + """Pytest fixture for MockContext.""" + return mocks.MockContext(channel=mocks.MockTextChannel()) + + +def test_error_embed(): + """Test the error embed method creates the correct embed.""" + title = "Something very drastic went very wrong!" + message = "seven southern seas are ready to collapse." + embed = ErrorHandler.error_embed(title=title, message=message) + + assert embed.title == title + assert embed.description == message + assert embed.colour == error_handler.ERROR_COLOUR + + +@pytest.mark.parametrize( + ["exception_or_str", "expected_str"], + [ + [commands.NSFWChannelRequired(mocks.MockTextChannel()), "NSFW Channel Required"], + [commands.CommandNotFound(), "Command Not Found"], + ["someWEIrdName", "some WE Ird Name"], + ], +) +def test_get_title_from_name(exception_or_str: typing.Union[Exception, str], expected_str: str): + """Test the regex works properly for the title from name.""" + result = ErrorHandler.get_title_from_name(exception_or_str) + assert expected_str == result + + +@pytest.mark.parametrize( + ["error", "title", "description"], + [ + [ + commands.UserInputError("some interesting information."), + "User Input Error", + "some interesting information.", + ], + [ + commands.MissingRequiredArgument(inspect.Parameter("SomethingSpecial", kind=1)), + "Missing Required Argument", + "SomethingSpecial is a required argument that is missing.", + ], + [ + commands.GuildNotFound("ImportantGuild"), + "Guild Not Found", + 'Guild "ImportantGuild" not found.', + ], + ], +) +@pytest.mark.asyncio +async def test_handle_user_input_error( + cog: ErrorHandler, ctx: mocks.MockContext, error: commands.UserInputError, title: str, description: str +): + """Test user input errors are handled properly. Does not test with BadUnionArgument.""" + embed = await cog.handle_user_input_error(ctx=ctx, error=error, reset_cooldown=False) + + assert title == embed.title + assert description == embed.description + + +@pytest.mark.asyncio +async def test_handle_bot_missing_perms(cog: ErrorHandler): + """ + + Test error_handler.handle_bot_missing_perms. + + There are some cases here where the bot is unable to send messages, and that should be clear. + """ + ... + + +@pytest.mark.asyncio +async def test_handle_check_failure(cog: ErrorHandler): + """ + Test check failures. + + In some cases, this method should result in calling a bot_missing_perms method + because the bot cannot send messages. + """ + ... + + +@pytest.mark.asyncio +async def test_on_command_error(cog: ErrorHandler): + """Test the general command error method.""" + ... + + +class TestErrorHandler: + """ + Test class for the error handler. The problem here is a lot of the errors need to be raised. + + Thankfully, most of them do not have extra attributes that we use, and can be easily faked. + """ + + errors = { + commands.CommandError: [ + commands.ConversionError, + { + commands.UserInputError: [ + commands.MissingRequiredArgument, + commands.TooManyArguments, + { + commands.BadArgument: [ + commands.MessageNotFound, + commands.MemberNotFound, + commands.GuildNotFound, + commands.UserNotFound, + commands.ChannelNotFound, + commands.ChannelNotReadable, + commands.BadColourArgument, + commands.RoleNotFound, + commands.BadInviteArgument, + commands.EmojiNotFound, + commands.GuildStickerNotFound, + commands.PartialEmojiConversionFailure, + commands.BadBoolArgument, + commands.ThreadNotFound, + ] + }, + commands.BadUnionArgument, + commands.BadLiteralArgument, + { + commands.ArgumentParsingError: [ + commands.UnexpectedQuoteError, + commands.InvalidEndOfQuotedStringError, + commands.ExpectedClosingQuoteError, + ] + }, + ] + }, + commands.CommandNotFound, + { + commands.CheckFailure: [ + commands.CheckAnyFailure, + commands.PrivateMessageOnly, + commands.NoPrivateMessage, + commands.NotOwner, + commands.MissingPermissions, + commands.BotMissingPermissions, + commands.MissingRole, + commands.BotMissingRole, + commands.MissingAnyRole, + commands.BotMissingAnyRole, + commands.NSFWChannelRequired, + ] + }, + commands.DisabledCommand, + commands.CommandInvokeError, + commands.CommandOnCooldown, + commands.MaxConcurrencyReached, + ] + } From e41125fdf3dfb5c475a779ac371e7c66c2a1b9f5 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 19 Oct 2021 18:28:28 -0400 Subject: [PATCH 31/33] feat: add timestamp util Signed-off-by: onerandomusername --- modmail/utils/time.py | 39 ++++++++++++++++++++++++++++++++ tests/modmail/utils/test_time.py | 25 ++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 modmail/utils/time.py create mode 100644 tests/modmail/utils/test_time.py diff --git a/modmail/utils/time.py b/modmail/utils/time.py new file mode 100644 index 00000000..b073574a --- /dev/null +++ b/modmail/utils/time.py @@ -0,0 +1,39 @@ +import datetime +import enum +import typing + +import arrow + + +class TimeStampEnum(enum.Enum): + """ + Timestamp modes for discord. + + Full docs on this format are viewable here: + https://discord.com/developers/docs/reference#message-formatting + """ + + SHORT_TIME = "t" + LONG_TIME = "T" + SHORT_DATE = "d" + LONG_DATE = "D" + SHORT_DATE_TIME = "f" + LONG_DATE_TIME = "F" + RELATIVE_TIME = "R" + + # DEFAULT + DEFAULT = SHORT_DATE_TIME + + +TypeTimes = typing.Union[arrow.Arrow, datetime.datetime] + + +def get_discord_formatted_timestamp( + timestamp: TypeTimes, format: TimeStampEnum = TimeStampEnum.DEFAULT +) -> str: + """ + Return a discord formatted timestamp from a datetime compatiable datatype. + + `format` must be an enum member of TimeStampEnum. Default style is SHORT_DATE_TIME + """ + return f"" diff --git a/tests/modmail/utils/test_time.py b/tests/modmail/utils/test_time.py new file mode 100644 index 00000000..6eb70613 --- /dev/null +++ b/tests/modmail/utils/test_time.py @@ -0,0 +1,25 @@ +import arrow +import pytest + +from modmail.utils import time as utils_time +from modmail.utils.time import TimeStampEnum + + +@pytest.mark.parametrize( + ["timestamp", "expected", "mode"], + [ + [arrow.get(1634593650), "", TimeStampEnum.SHORT_DATE_TIME], + [arrow.get(1), "", TimeStampEnum.SHORT_DATE_TIME], + [arrow.get(12356941), "", TimeStampEnum.RELATIVE_TIME], + ], +) +def test_timestamp(timestamp, expected: str, mode: utils_time.TimeStampEnum): + """Test the timestamp is of the proper form.""" + fmtted_timestamp = utils_time.get_discord_formatted_timestamp(timestamp, mode) + assert expected == fmtted_timestamp + + +def test_enum_default(): + """Ensure that the default mode is of the correct mode, and works properly.""" + assert TimeStampEnum.DEFAULT.name == TimeStampEnum.SHORT_DATE_TIME.name + assert TimeStampEnum.DEFAULT.value == TimeStampEnum.SHORT_DATE_TIME.value From 62fa485b167274d9d8eb23ade4112ed11269ca25 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Wed, 20 Oct 2021 00:20:05 -0400 Subject: [PATCH 32/33] nit: add comments for timestamp modes Signed-off-by: onerandomusername --- modmail/utils/time.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/modmail/utils/time.py b/modmail/utils/time.py index b073574a..429609d4 100644 --- a/modmail/utils/time.py +++ b/modmail/utils/time.py @@ -13,15 +13,17 @@ class TimeStampEnum(enum.Enum): https://discord.com/developers/docs/reference#message-formatting """ - SHORT_TIME = "t" - LONG_TIME = "T" - SHORT_DATE = "d" - LONG_DATE = "D" - SHORT_DATE_TIME = "f" - LONG_DATE_TIME = "F" - RELATIVE_TIME = "R" - - # DEFAULT + # fmt: off + SHORT_TIME = "t" # 16:20 + LONG_TIME = "T" # 16:20:30 + SHORT_DATE = "d" # 20/04/2021 + LONG_DATE = "D" # 20 April 2021 + SHORT_DATE_TIME = "f" # 20 April 2021 16:20 + LONG_DATE_TIME = "F" # Tuesday, 20 April 2021 16:20 + RELATIVE_TIME = "R" # 2 months ago + + # fmt: on + # DEFAULT alised to the default, so for all purposes, it behaves like SHORT_DATE_TIME, including the name DEFAULT = SHORT_DATE_TIME From b721e3a78d293ca794b0ab9aed8de139c280bc1a Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Wed, 20 Oct 2021 00:30:13 -0400 Subject: [PATCH 33/33] tests: add test for datetime instead of arrow object Signed-off-by: onerandomusername --- tests/modmail/utils/test_time.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/modmail/utils/test_time.py b/tests/modmail/utils/test_time.py index 6eb70613..53682643 100644 --- a/tests/modmail/utils/test_time.py +++ b/tests/modmail/utils/test_time.py @@ -9,8 +9,9 @@ ["timestamp", "expected", "mode"], [ [arrow.get(1634593650), "", TimeStampEnum.SHORT_DATE_TIME], - [arrow.get(1), "", TimeStampEnum.SHORT_DATE_TIME], + [arrow.get(1), "", TimeStampEnum.DEFAULT], [arrow.get(12356941), "", TimeStampEnum.RELATIVE_TIME], + [arrow.get(8675309).datetime, "", TimeStampEnum.LONG_DATE], ], ) def test_timestamp(timestamp, expected: str, mode: utils_time.TimeStampEnum):