From c4213744c18be23e3e4484f126ae0b2d0eba4437 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 2 Oct 2019 16:59:03 +0200 Subject: Migrate pytest to unittest After a discussion in the core developers channel, we have decided to migrate from `pytest` to `unittest` as the testing framework. This commit sets up the repository to use `unittest` and migrates the first couple of tests files to the new framework. What I have done to migrate to `unitest`: - Removed all `pytest` test files, since they are incompatible. - Removed `pytest`-related dependencies from the Pipfile. - Added `coverage.py` to the Pipfile dev-packages and relocked. - Added convenience scripts to Pipfile for running the test suite. - Adjust to `azure-pipelines.yml` to use `coverage.py` and `unittest`. - Migrated four test files from `pytest` to `unittest` format. In addition, I've added five helper Mock subclasses in `helpers.py` and created a `TestCase` subclass in `base.py` to add an assertion that asserts that no log records were logged within the context of the context manager. Obviously, these new utility functions and classes are fully tested in their respective `test_` files. Finally, I've started with an introductory guide for writing tests for our bot in `README.md`. --- tests/helpers.py | 247 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 236 insertions(+), 11 deletions(-) (limited to 'tests/helpers.py') diff --git a/tests/helpers.py b/tests/helpers.py index 2908294f7..64fc04afe 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,23 +1,18 @@ +from __future__ import annotations + import asyncio import functools -from unittest.mock import MagicMock - - -__all__ = ('AsyncMock', 'async_test') +import unittest.mock +from typing import Iterable, Optional - -# TODO: Remove me on 3.8 -class AsyncMock(MagicMock): - async def __call__(self, *args, **kwargs): - return super(AsyncMock, self).__call__(*args, **kwargs) +import discord +from discord.ext.commands import Bot, Context def async_test(wrapped): """ Run a test case via asyncio. - Example: - >>> @async_test ... async def lemon_wins(): ... assert True @@ -27,3 +22,233 @@ def async_test(wrapped): def wrapper(*args, **kwargs): return asyncio.run(wrapped(*args, **kwargs)) return wrapper + + +# TODO: Remove me in Python 3.8 +class AsyncMock(unittest.mock.MagicMock): + """ + A MagicMock subclass to mock async callables. + + Python 3.8 will introduce an AsyncMock class in the standard library that will have some more + features; this stand-in only overwrites the `__call__` method to an async version. + """ + + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +class HashableMixin(discord.mixins.EqualityComparable): + """ + Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. + + Note: discord.py`s `Hashable` mixin bit-shifts `self.id` (`>> 22`); to prevent hash-collisions + for the relative small `id` integers we generally use in tests, this bit-shift is omitted. + """ + + def __hash__(self): + return self.id + + +class ColourMixin: + """A mixin of Mocks that provides the aliasing of color->colour like discord.py does.""" + + @property + def color(self) -> discord.Colour: + return self.colour + + @color.setter + def color(self, color: discord.Colour) -> None: + self.colour = color + + +class AttributeMock: + """Ensures attributes of our mock types will be instantiated with the correct mock type.""" + + def __new__(cls, *args, **kwargs): + """Stops the regular parent class from propagating to newly mocked attributes.""" + if 'parent' in kwargs: + return cls.attribute_mocktype(*args, **kwargs) + + return super().__new__(cls) + + +# 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(AttributeMock, 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`. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__( + self, + guild_id: int = 1, + roles: Optional[Iterable[MockRole]] = None, + members: Optional[Iterable[MockMember]] = None, + **kwargs, + ) -> None: + super().__init__(spec=guild_instance, **kwargs) + + self.id = guild_id + + self.roles = [MockRole("@everyone", 1)] + if roles: + self.roles.extend(roles) + + self.members = [] + if members: + self.members.extend(members) + + +# 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(AttributeMock, 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. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__( + self, + name: str = "role", + role_id: int = 1, + position: int = 1, + **kwargs, + ) -> None: + super().__init__(spec=role_instance, **kwargs) + self.name = name + self.id = role_id + self.position = position + self.mention = f'&{self.name}' + + def __lt__(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(AttributeMock, 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. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__( + self, + name: str = "member", + user_id: int = 1, + roles: Optional[Iterable[MockRole]] = None, + **kwargs, + ) -> None: + super().__init__(spec=member_instance, **kwargs) + self.name = name + self.id = user_id + self.roles = [MockRole("@everyone", 1)] + if roles: + self.roles.extend(roles) + self.mention = f"@{self.name}" + self.send = AsyncMock() + + +# Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` +bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) + + +class MockBot(AttributeMock, 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. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs) -> None: + super().__init__(spec=bot_instance, **kwargs) + self._before_invoke = AsyncMock() + self._after_invoke = AsyncMock() + self.user = MockMember(name="Python", user_id=123456789) + + +# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` +context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) + + +class MockContext(AttributeMock, 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. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs) -> None: + super().__init__(spec=context_instance, **kwargs) + self.bot = MockBot() + self.send = AsyncMock() + self.guild = MockGuild() + self.author = MockMember() + self.command = unittest.mock.MagicMock() -- cgit v1.2.3 From 6d9cb1ad99d064d8810feb553c6b0463c74c92d4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 19:54:47 +0200 Subject: Change pipeline testrunner to xmlrunner I have change the testrunner from `unittest` to `xmlrunner` in the Azure pipeline to be able to publish our test results on Azure. This is the same runner as `site` uses to generate XML reports. In addition, I've cleaned up some small mistakes in docstrings and `README.md`. --- .gitignore | 4 ++-- Pipfile | 1 + Pipfile.lock | 10 +++++++++- azure-pipelines.yml | 13 ++++++++++--- tests/README.md | 6 +++--- tests/helpers.py | 14 ++++++-------- 6 files changed, 31 insertions(+), 17 deletions(-) (limited to 'tests/helpers.py') diff --git a/.gitignore b/.gitignore index 261fa179f..210847759 100644 --- a/.gitignore +++ b/.gitignore @@ -114,5 +114,5 @@ log.* # Custom user configuration config.yml -# JUnit XML reports from pytest -junit.xml +# xmlrunner unittest XML reports +TEST-**.xml diff --git a/Pipfile b/Pipfile index 0c73e4ca2..48d839fc3 100644 --- a/Pipfile +++ b/Pipfile @@ -32,6 +32,7 @@ flake8-tidy-imports = "~=2.0" flake8-todo = "~=0.7" pre-commit = "~=1.18" safety = "~=1.8" +unittest-xml-reporting = "~=2.5" dodgy = "~=0.1" [requires] diff --git a/Pipfile.lock b/Pipfile.lock index 366d1e525..95955ff89 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f5f32a03b561f1805f52447ca4e6582dd459581c5d581638925b7fabb09869f8" + "sha256": "c27d699b4aeeed204dee41f924f682ae2a670add8549a8826e58776594370582" }, "pipfile-spec": 6, "requires": { @@ -880,6 +880,14 @@ ], "version": "==1.4.0" }, + "unittest-xml-reporting": { + "hashes": [ + "sha256:140982e4b58e4052d9ecb775525b246a96bfc1fc26097806e05ea06e9166dd6c", + "sha256:d1fbc7a1b6c6680ccfe75b5e9701e5431c646970de049e687b4bb35ba4325d72" + ], + "index": "pypi", + "version": "==2.5.1" + }, "urllib3": { "hashes": [ "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 15470f9be..da3b06201 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -30,11 +30,11 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m unittest + - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m xmlrunner displayName: Run tests - - script: coverage xml -o coverage.xml - displayName: Create test coverage report + - script: coverage report -m && coverage xml -o coverage.xml + displayName: Generate test coverage report - task: PublishCodeCoverageResults@1 displayName: 'Publish Coverage Results' @@ -43,6 +43,13 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: coverage.xml + - task: PublishTestResults@2 + condition: succeededOrFailed() + displayName: 'Publish Test Results' + inputs: + testResultsFiles: '**/TEST-*.xml' + testRunTitle: 'Bot Test Results' + - job: build displayName: 'Build & Push Container' dependsOn: 'test' diff --git a/tests/README.md b/tests/README.md index 085ea39e0..471a00923 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,8 +1,8 @@ # Testing our Bot -Our bot is one of the most important tools we have to help us run our community. To make sure that tool doesn't break, we decided to start testing it using unit tests. It is our goal to provide it with 100% test coverage in the future. This guide will help you get started with writing the tests needed to achieve that. +Our bot is one of the most important tools we have to help us run our community. To make sure that tool doesn't break, we've decided to start writing unit tests for it. It is our goal to provide it with 100% test coverage in the future. This guide will help you get started with writing the tests needed to achieve that. -_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY). +_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)._ ## Tools @@ -43,7 +43,7 @@ Since it's important to make sure all of our tests are independent from each oth By using the `subTest` context manager, we can perform multiple independent subtests within one test method (e.g., by having a loop for the various inputs we want to test). Using this context manager ensures the test will be run independently and that, if one of them fails, the rest of the subtests are still executed. (Normally, a test function stops once the first exception, including `AssertionError`, is raised.) -An example (taken from [`test_converters.py`])(/tests/bot/test_converters.py): +An example (taken from [`test_converters.py`](/tests/bot/test_converters.py)): ```py def test_tag_content_converter_for_valid(self): diff --git a/tests/helpers.py b/tests/helpers.py index 64fc04afe..18c9866bf 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -50,7 +50,7 @@ class HashableMixin(discord.mixins.EqualityComparable): class ColourMixin: - """A mixin of Mocks that provides the aliasing of color->colour like discord.py does.""" + """A mixin for Mocks that provides the aliasing of color->colour like discord.py does.""" @property def color(self) -> discord.Colour: @@ -159,14 +159,9 @@ class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): attribute_mocktype = unittest.mock.MagicMock - def __init__( - self, - name: str = "role", - role_id: int = 1, - position: int = 1, - **kwargs, - ) -> None: + def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) + self.name = name self.id = role_id self.position = position @@ -201,11 +196,14 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): **kwargs, ) -> None: super().__init__(spec=member_instance, **kwargs) + self.name = name self.id = user_id + self.roles = [MockRole("@everyone", 1)] if roles: self.roles.extend(roles) + self.mention = f"@{self.name}" self.send = AsyncMock() -- cgit v1.2.3 From e4e01cd5388da19435637353e711c2feab5a0e59 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 22:13:22 +0200 Subject: Add more specialized Mocks to tests.helpers This commit introduces some new Mock-types to the already existing Mock-types for discord.py objects. The total list is now: - MockGuild - MockRole - MockMember - MockBot - MockContext - MockTextChannel - MockMessage In addition, I've added all coroutines in the documentation for these discord.py objects as `AsyncMock` attributes to ease testing. Tests ensure that the attributes set for the Mocks exist for the actual discord.py objects as well. --- tests/helpers.py | 179 ++++++++++++++++++++++- tests/test_helpers.py | 385 +++++++++++++++++++++++++++----------------------- 2 files changed, 383 insertions(+), 181 deletions(-) (limited to 'tests/helpers.py') diff --git a/tests/helpers.py b/tests/helpers.py index 18c9866bf..f8e8357f1 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -143,6 +143,32 @@ class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): if members: self.members.extend(members) + # `discord.Guild` coroutines + self.create_category_channel = AsyncMock() + self.ban = AsyncMock() + self.bans = AsyncMock() + self.create_category = AsyncMock() + self.create_custom_emoji = AsyncMock() + self.create_role = AsyncMock() + self.create_text_channel = AsyncMock() + self.create_voice_channel = AsyncMock() + self.delete = AsyncMock() + self.edit = AsyncMock() + self.estimate_pruned_members = AsyncMock() + self.fetch_ban = AsyncMock() + self.fetch_channels = AsyncMock() + self.fetch_emoji = AsyncMock() + self.fetch_emojis = AsyncMock() + self.fetch_member = AsyncMock() + self.invites = AsyncMock() + self.kick = AsyncMock() + self.leave = AsyncMock() + self.prune_members = AsyncMock() + self.unban = AsyncMock() + self.vanity_invite = AsyncMock() + self.webhooks = AsyncMock() + self.widget = AsyncMock() + # Create a Role instance to get a realistic Mock of `discord.Role` role_data = {'name': 'role', 'id': 1} @@ -167,6 +193,10 @@ class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): self.position = position self.mention = f'&{self.name}' + # 'discord.Role' coroutines + self.delete = AsyncMock() + self.edit = AsyncMock() + def __lt__(self, other): """Simplified position-based comparisons similar to those of `discord.Role`.""" return self.position < other.position @@ -205,7 +235,19 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): self.roles.extend(roles) self.mention = f"@{self.name}" + + # `discord.Member` coroutines + self.add_roles = AsyncMock() + self.ban = AsyncMock() + self.edit = AsyncMock() + self.fetch_message = AsyncMock() + self.kick = AsyncMock() + self.move_to = AsyncMock() + self.pins = AsyncMock() + self.remove_roles = AsyncMock() self.send = AsyncMock() + self.trigger_typing = AsyncMock() + self.unban = AsyncMock() # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` @@ -224,9 +266,37 @@ class MockBot(AttributeMock, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) + + # `discord.ext.commands.Bot` coroutines self._before_invoke = AsyncMock() self._after_invoke = AsyncMock() - self.user = MockMember(name="Python", user_id=123456789) + self.application_info = AsyncMock() + self.change_presence = AsyncMock() + self.connect = AsyncMock() + self.close = AsyncMock() + self.create_guild = AsyncMock() + self.delete_invite = AsyncMock() + self.fetch_channel = AsyncMock() + self.fetch_guild = AsyncMock() + self.fetch_guilds = AsyncMock() + self.fetch_invite = AsyncMock() + self.fetch_user = AsyncMock() + self.fetch_user_profile = AsyncMock() + self.fetch_webhook = AsyncMock() + self.fetch_widget = AsyncMock() + self.get_context = AsyncMock() + self.get_prefix = AsyncMock() + self.invoke = AsyncMock() + self.is_owner = AsyncMock() + self.login = AsyncMock() + self.logout = AsyncMock() + self.on_command_error = AsyncMock() + self.on_error = AsyncMock() + self.process_commands = AsyncMock() + self.request_offline_members = AsyncMock() + self.start = AsyncMock() + self.wait_until_ready = AsyncMock() + self.wait_for = AsyncMock() # Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` @@ -246,7 +316,112 @@ class MockContext(AttributeMock, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(spec=context_instance, **kwargs) self.bot = MockBot() - self.send = AsyncMock() self.guild = MockGuild() self.author = MockMember() self.command = unittest.mock.MagicMock() + + # `discord.ext.commands.Context` coroutines + self.fetch_message = AsyncMock() + self.invoke = AsyncMock() + self.pins = AsyncMock() + self.reinvoke = AsyncMock() + self.send = AsyncMock() + self.send_help = AsyncMock() + self.trigger_typing = AsyncMock() + + +# Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` +channel_data = { + 'id': 1, + '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() +channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) + + +class MockTextChannel(AttributeMock, 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. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: + super().__init__(spec=channel_instance, **kwargs) + self.id = channel_id + self.name = name + self.guild = MockGuild() + self.mention = f"#{self.name}" + + # `discord.TextChannel` coroutines + self.clone = AsyncMock() + self.create_invite = AsyncMock() + self.create_webhook = AsyncMock() + self.delete = AsyncMock() + self.delete_messages = AsyncMock() + self.edit = AsyncMock() + self.fetch_message = AsyncMock() + self.invites = AsyncMock() + self.pins = AsyncMock() + self.purge = AsyncMock() + self.send = AsyncMock() + self.set_permissions = AsyncMock() + self.trigger_typing = AsyncMock() + self.webhooks = AsyncMock() + + +# Create a Message instance to get a realistic MagicMock of `discord.Message` +message_data = { + 'id': 1, + 'webhook_id': 431341013479718912, + 'attachments': [], + 'embeds': [], + 'application': 'Python Discord', + '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) + + +class MockMessage(AttributeMock, 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. + """ + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs) -> None: + super().__init__(spec=message_instance, **kwargs) + self.author = MockMember() + + # `discord.Message` coroutines + self.ack = AsyncMock() + self.add_reaction = AsyncMock() + self.clear_reactions = AsyncMock() + self.delete = AsyncMock() + self.edit = AsyncMock() + self.pin = AsyncMock() + self.remove_reaction = AsyncMock() + self.unpin = AsyncMock() diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 766fe17b8..f08239981 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -8,114 +8,8 @@ import discord from tests import helpers -class MockObjectTests(unittest.TestCase): - """Tests the mock objects and mixins we've defined.""" - @classmethod - def setUpClass(cls): - cls.hashable_mocks = (helpers.MockRole, helpers.MockMember, helpers.MockGuild) - - def test_colour_mixin(self): - """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" - class MockHemlock(unittest.mock.MagicMock, helpers.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 if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): - pass - - scragly = MockScragly() - scragly.id = 10 - self.assertEqual(hash(scragly), scragly.id) - - def test_hashable_mixin_uses_id_for_equality_comparison(self): - """Test if the HashableMixing uses the id attribute for hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): - pass - - scragly = MockScragly(spec=object) - scragly.id = 10 - eevee = MockScragly(spec=object) - eevee.id = 10 - python = MockScragly(spec=object) - 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 hashing.""" - class MockScragly(unittest.mock.Mock, helpers.HashableMixin): - pass - - scragly = MockScragly(spec=object) - scragly.id = 10 - eevee = MockScragly(spec=object) - eevee.id = 10 - python = MockScragly(spec=object) - 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 = helpers.MockRole(role_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_spec_propagation_of_mock_subclasses(self): - """Test if the `spec` does not propagate to attributes of the mock object.""" - test_values = ( - (helpers.MockGuild, "region"), - (helpers.MockRole, "mentionable"), - (helpers.MockMember, "display_name"), - (helpers.MockBot, "owner_id"), - (helpers.MockContext, "command_failed"), - ) - - 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.attribute_mocktype)) +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.""" @@ -152,28 +46,6 @@ class MockObjectTests(unittest.TestCase): self.assertEqual(role.guild, "Dino Man") self.assertTrue(role.hoist) - def test_mock_role_rejects_accessing_attributes_not_following_spec(self): - """Test if MockRole throws AttributeError for attribute not existing in discord.Role.""" - with self.assertRaises(AttributeError): - role = helpers.MockRole() - role.joseph - - def test_mock_role_rejects_accessing_methods_not_following_spec(self): - """Test if MockRole throws AttributeError for method not existing in discord.Role.""" - with self.assertRaises(AttributeError): - role = helpers.MockRole() - role.lemon() - - def test_mock_role_accepts_accessing_attributes_following_spec(self): - """Test if MockRole accepts attribute access for valid attributes of discord.Role.""" - role = helpers.MockRole() - role.hoist - - def test_mock_role_accepts_accessing_methods_following_spec(self): - """Test if MockRole accepts method calls for valid methods of discord.Role.""" - role = helpers.MockRole() - role.edit() - 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 = helpers.MockRole(position=1) @@ -223,28 +95,6 @@ class MockObjectTests(unittest.TestCase): self.assertEqual(member.nick, "Dino Man") self.assertEqual(member.colour, discord.Colour.default()) - def test_mock_member_rejects_accessing_attributes_not_following_spec(self): - """Test if MockMember throws AttributeError for attribute not existing in spec discord.Member.""" - with self.assertRaises(AttributeError): - member = helpers.MockMember() - member.joseph - - def test_mock_member_rejects_accessing_methods_not_following_spec(self): - """Test if MockMember throws AttributeError for method not existing in spec discord.Member.""" - with self.assertRaises(AttributeError): - member = helpers.MockMember() - member.lemon() - - def test_mock_member_accepts_accessing_attributes_following_spec(self): - """Test if MockMember accepts attribute access for valid attributes of discord.Member.""" - member = helpers.MockMember() - member.display_name - - def test_mock_member_accepts_accessing_methods_following_spec(self): - """Test if MockMember accepts method calls for valid methods of discord.Member.""" - member = helpers.MockMember() - member.mentioned_in() - def test_mock_guild_default_initialization(self): """Test if the default initialization of Mockguild results in the correct object.""" guild = helpers.MockGuild() @@ -276,28 +126,6 @@ class MockObjectTests(unittest.TestCase): self.assertTupleEqual(guild.emojis, (":hyperjoseph:", ":pensive_ela:")) self.assertEqual(guild.premium_subscription_count, 15) - def test_mock_guild_rejects_accessing_attributes_not_following_spec(self): - """Test if MockGuild throws AttributeError for attribute not existing in spec discord.Guild.""" - with self.assertRaises(AttributeError): - guild = helpers.MockGuild() - guild.aperture - - def test_mock_guild_rejects_accessing_methods_not_following_spec(self): - """Test if MockGuild throws AttributeError for method not existing in spec discord.Guild.""" - with self.assertRaises(AttributeError): - guild = helpers.MockGuild() - guild.volcyyy() - - def test_mock_guild_accepts_accessing_attributes_following_spec(self): - """Test if MockGuild accepts attribute access for valid attributes of discord.Guild.""" - guild = helpers.MockGuild() - guild.name - - def test_mock_guild_accepts_accessing_methods_following_spec(self): - """Test if MockGuild accepts method calls for valid methods of discord.Guild.""" - guild = helpers.MockGuild() - guild.by_category() - def test_mock_bot_default_initialization(self): """Tests if MockBot initializes with the correct values.""" bot = helpers.MockBot() @@ -305,10 +133,6 @@ class MockObjectTests(unittest.TestCase): # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Bot` pass self.assertIsInstance(bot, discord.ext.commands.Bot) - self.assertIsInstance(bot._before_invoke, helpers.AsyncMock) - self.assertIsInstance(bot._after_invoke, helpers.AsyncMock) - self.assertEqual(bot.user, helpers.MockMember(name="Python", user_id=123456789)) - def test_mock_context_default_initialization(self): """Tests if MockContext initializes with the correct values.""" context = helpers.MockContext() @@ -317,10 +141,213 @@ class MockObjectTests(unittest.TestCase): self.assertIsInstance(context, discord.ext.commands.Context) self.assertIsInstance(context.bot, helpers.MockBot) - self.assertIsInstance(context.send, helpers.AsyncMock) self.assertIsInstance(context.guild, helpers.MockGuild) self.assertIsInstance(context.author, helpers.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 = ( + (helpers.MockGuild(), 'name'), + (helpers.MockRole(), 'hoist'), + (helpers.MockMember(), 'display_name'), + (helpers.MockBot(), 'user'), + (helpers.MockContext(), 'invoked_with'), + (helpers.MockTextChannel(), 'last_message'), + (helpers.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 = ( + helpers.MockGuild(), + helpers.MockRole(), + helpers.MockMember(), + helpers.MockBot(), + helpers.MockContext(), + helpers.MockTextChannel(), + helpers.MockMessage(), + ) + + for mock in mocks: + with self.subTest(mock=mock): + with self.assertRaises(AttributeError): + mock.the_cake_is_a_lie + + def test_custom_mock_methods_are_valid_discord_object_methods(self): + """The `AsyncMock` attributes of the mocks should be valid for the class they're mocking.""" + mocks = ( + (helpers.MockGuild, helpers.guild_instance), + (helpers.MockRole, helpers.role_instance), + (helpers.MockMember, helpers.member_instance), + (helpers.MockBot, helpers.bot_instance), + (helpers.MockContext, helpers.context_instance), + (helpers.MockTextChannel, helpers.channel_instance), + (helpers.MockMessage, helpers.message_instance), + ) + + for mock_class, instance in mocks: + mock = mock_class() + async_methods = ( + attr for attr in dir(mock) if isinstance(getattr(mock, attr), helpers.AsyncMock) + ) + + # spec_mock = unittest.mock.MagicMock(spec=instance) + for method in async_methods: + with self.subTest(mock_class=mock_class, method=method): + try: + getattr(instance, method) + except AttributeError: + msg = f"method {method} is not a method attribute of {instance.__class__}" + self.fail(msg) + + @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') + def test_the_custom_mock_methods_test(self, subtest_mock): + """The custom method test should raise AssertionError for invalid methods.""" + class FakeMockBot(helpers.AttributeMock, unittest.mock.MagicMock): + """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" + + attribute_mocktype = unittest.mock.MagicMock + + def __init__(self, **kwargs): + super().__init__(spec=helpers.bot_instance, **kwargs) + + # Fake attribute + self.release_the_walrus = helpers.AsyncMock() + + with unittest.mock.patch("tests.helpers.MockBot", new=FakeMockBot): + msg = "method release_the_walrus is not a valid method of " + with self.assertRaises(AssertionError, msg=msg): + self.test_custom_mock_methods_are_valid_discord_object_methods() + + +class MockObjectTests(unittest.TestCase): + """Tests the mock objects and mixins we've defined.""" + + @classmethod + def setUpClass(cls): + cls.hashable_mocks = (helpers.MockRole, helpers.MockMember, helpers.MockGuild) + + def test_colour_mixin(self): + """Test if the ColourMixin adds aliasing of color -> colour for child classes.""" + class MockHemlock(unittest.mock.MagicMock, helpers.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 if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly() + scragly.id = 10 + self.assertEqual(hash(scragly), scragly.id) + + def test_hashable_mixin_uses_id_for_equality_comparison(self): + """Test if the HashableMixing uses the id attribute for hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly(spec=object) + scragly.id = 10 + eevee = MockScragly(spec=object) + eevee.id = 10 + python = MockScragly(spec=object) + 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 hashing.""" + class MockScragly(unittest.mock.Mock, helpers.HashableMixin): + pass + + scragly = MockScragly(spec=object) + scragly.id = 10 + eevee = MockScragly(spec=object) + eevee.id = 10 + python = MockScragly(spec=object) + 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 = helpers.MockRole(role_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_spec_propagation_of_mock_subclasses(self): + """Test if the `spec` does not propagate to attributes of the mock object.""" + test_values = ( + (helpers.MockGuild, "region"), + (helpers.MockRole, "mentionable"), + (helpers.MockMember, "display_name"), + (helpers.MockBot, "owner_id"), + (helpers.MockContext, "command_failed"), + ) + + 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.attribute_mocktype)) + def test_async_mock_provides_coroutine_for_dunder_call(self): """Test if AsyncMock objects have a coroutine for their __call__ method.""" async_mock = helpers.AsyncMock() -- cgit v1.2.3 From e66237395ab1470002f5dd61de9eeb19ca4600eb Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 22:28:51 +0200 Subject: Make test_token_remover use our discord Mocks This commit replaces the standard MagicMocks by our specialized mocks for discord.py objects. It also adds the missing `channel` attribute to the `tests.helpers.MockMessage` mock and moves the file to the correct folder. --- tests/bot/cogs/test_token_remover.py | 135 ++++++++++++++++++++++++++++++++++ tests/cogs/test_token_remover.py | 139 ----------------------------------- tests/helpers.py | 1 + 3 files changed, 136 insertions(+), 139 deletions(-) create mode 100644 tests/bot/cogs/test_token_remover.py delete mode 100644 tests/cogs/test_token_remover.py (limited to 'tests/helpers.py') diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py new file mode 100644 index 000000000..dfb1bafc9 --- /dev/null +++ b/tests/bot/cogs/test_token_remover.py @@ -0,0 +1,135 @@ +import asyncio +import logging +import unittest +from unittest.mock import MagicMock + +from discord import Colour + +from bot.cogs.token_remover import ( + DELETION_MESSAGE_TEMPLATE, + TokenRemover, + setup as setup_cog, +) +from bot.constants import Channels, Colours, Event, Icons +from tests.helpers import AsyncMock, MockBot, MockMessage + + +class TokenRemoverTests(unittest.TestCase): + """Tests the `TokenRemover` cog.""" + + def setUp(self): + """Adds the cog, a bot, and a message to the instance for usage in tests.""" + self.bot = MockBot() + self.bot.get_cog.return_value = MagicMock() + self.bot.get_cog.return_value.send_log_message = AsyncMock() + self.cog = TokenRemover(bot=self.bot) + + self.msg = MockMessage(message_id=555, content='') + self.msg.author.__str__ = MagicMock() + self.msg.author.__str__.return_value = 'lemon' + self.msg.author.bot = False + self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' + self.msg.author.id = 42 + self.msg.author.mention = '@lemon' + self.msg.channel.mention = "#lemonade-stand" + + def test_is_valid_user_id_is_true_for_numeric_content(self): + """A string decoding to numeric characters is a valid user ID.""" + # MTIz = base64(123) + self.assertTrue(TokenRemover.is_valid_user_id('MTIz')) + + def test_is_valid_user_id_is_false_for_alphabetic_content(self): + """A string decoding to alphabetic characters is not a valid user ID.""" + # YWJj = base64(abc) + self.assertFalse(TokenRemover.is_valid_user_id('YWJj')) + + def test_is_valid_timestamp_is_true_for_valid_timestamps(self): + """A string decoding to a valid timestamp should be recognized as such.""" + self.assertTrue(TokenRemover.is_valid_timestamp('DN9r_A')) + + def test_is_valid_timestamp_is_false_for_invalid_values(self): + """A string not decoding to a valid timestamp should not be recognized as such.""" + # MTIz = base64(123) + self.assertFalse(TokenRemover.is_valid_timestamp('MTIz')) + + def test_mod_log_property(self): + """The `mod_log` property should ask the bot to return the `ModLog` cog.""" + self.bot.get_cog.return_value = 'lemon' + self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) + self.bot.get_cog.assert_called_once_with('ModLog') + + def test_ignores_bot_messages(self): + """When the message event handler is called with a bot message, nothing is done.""" + self.msg.author.bot = True + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_ignores_messages_without_tokens(self): + """Messages without anything looking like a token are ignored.""" + for content in ('', 'lemon wins'): + with self.subTest(content=content): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_ignores_messages_with_invalid_tokens(self): + """Messages with values that are invalid tokens are ignored.""" + for content in ('foo.bar.baz', 'x.y.'): + with self.subTest(content=content): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + self.assertIsNone(asyncio.run(coroutine)) + + def test_censors_valid_tokens(self): + """Valid tokens are censored.""" + cases = ( + # (content, censored_token) + ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), + ) + + for content, censored_token in cases: + with self.subTest(content=content, censored_token=censored_token): + self.msg.content = content + coroutine = self.cog.on_message(self.msg) + with self.assertLogs(logger='bot.cogs.token_remover', level=logging.DEBUG) as cm: + self.assertIsNone(asyncio.run(coroutine)) # no return value + + [line] = cm.output + log_message = ( + "Censored a seemingly valid token sent by " + "lemon (`42`) in #lemonade-stand, " + f"token was `{censored_token}`" + ) + self.assertIn(log_message, line) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_called_once_with( + DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') + ) + self.bot.get_cog.assert_called_with('ModLog') + self.msg.author.avatar_url_as.assert_called_once_with(static_format='png') + + mod_log = self.bot.get_cog.return_value + mod_log.ignore.assert_called_once_with(Event.message_delete, self.msg.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=log_message, + thumbnail='picture-lemon.png', + channel_id=Channels.mod_alerts + ) + + +class TokenRemoverSetupTests(unittest.TestCase): + """Tests setup of the `TokenRemover` cog.""" + + def test_setup(self): + """Setup of the cog should log a message at `INFO` level.""" + bot = MockBot() + with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: + setup_cog(bot) + + [line] = cm.output + bot.add_cog.assert_called_once() + self.assertIn("Cog loaded: TokenRemover", line) diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py deleted file mode 100644 index 3738b6d1b..000000000 --- a/tests/cogs/test_token_remover.py +++ /dev/null @@ -1,139 +0,0 @@ -import asyncio -import logging -import unittest -from unittest.mock import MagicMock - -from discord import Colour - -from bot.cogs.token_remover import ( - DELETION_MESSAGE_TEMPLATE, - TokenRemover, - setup as setup_cog, -) -from bot.constants import Channels, Colours, Event, Icons -from tests.helpers import AsyncMock, MockBot - - -class TokenRemoverTests(unittest.TestCase): - """Tests the `TokenRemover` cog.""" - - def setUp(self): - """Adds the cog, a bot, and a message to the instance for usage in tests.""" - self.bot = MockBot() - self.bot.get_cog.return_value = MagicMock() - self.bot.get_cog.return_value.send_log_message = AsyncMock() - self.cog = TokenRemover(bot=self.bot) - - self.msg = MagicMock() - self.msg.author = MagicMock() - self.msg.author.__str__.return_value = 'lemon' - self.msg.author.bot = False - self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' - self.msg.author.id = 42 - self.msg.author.mention = '@lemon' - self.msg.channel.send = AsyncMock() - self.msg.channel.mention = '#lemonade-stand' - self.msg.content = '' - self.msg.delete = AsyncMock() - self.msg.id = 555 - - def test_is_valid_user_id_is_true_for_numeric_content(self): - """A string decoding to numeric characters is a valid user ID.""" - # MTIz = base64(123) - self.assertTrue(TokenRemover.is_valid_user_id('MTIz')) - - def test_is_valid_user_id_is_false_for_alphabetic_content(self): - """A string decoding to alphabetic characters is not a valid user ID.""" - # YWJj = base64(abc) - self.assertFalse(TokenRemover.is_valid_user_id('YWJj')) - - def test_is_valid_timestamp_is_true_for_valid_timestamps(self): - """A string decoding to a valid timestamp should be recognized as such.""" - self.assertTrue(TokenRemover.is_valid_timestamp('DN9r_A')) - - def test_is_valid_timestamp_is_false_for_invalid_values(self): - """A string not decoding to a valid timestamp should not be recognized as such.""" - # MTIz = base64(123) - self.assertFalse(TokenRemover.is_valid_timestamp('MTIz')) - - def test_mod_log_property(self): - """The `mod_log` property should ask the bot to return the `ModLog` cog.""" - self.bot.get_cog.return_value = 'lemon' - self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) - self.bot.get_cog.assert_called_once_with('ModLog') - - def test_ignores_bot_messages(self): - """When the message event handler is called with a bot message, nothing is done.""" - self.msg.author.bot = True - coroutine = self.cog.on_message(self.msg) - self.assertIsNone(asyncio.run(coroutine)) - - def test_ignores_messages_without_tokens(self): - """Messages without anything looking like a token are ignored.""" - for content in ('', 'lemon wins'): - with self.subTest(content=content): - self.msg.content = content - coroutine = self.cog.on_message(self.msg) - self.assertIsNone(asyncio.run(coroutine)) - - def test_ignores_messages_with_invalid_tokens(self): - """Messages with values that are invalid tokens are ignored.""" - for content in ('foo.bar.baz', 'x.y.'): - with self.subTest(content=content): - self.msg.content = content - coroutine = self.cog.on_message(self.msg) - self.assertIsNone(asyncio.run(coroutine)) - - def test_censors_valid_tokens(self): - """Valid tokens are censored.""" - cases = ( - # (content, censored_token) - ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), - ) - - for content, censored_token in cases: - with self.subTest(content=content, censored_token=censored_token): - self.msg.content = content - coroutine = self.cog.on_message(self.msg) - with self.assertLogs(logger='bot.cogs.token_remover', level=logging.DEBUG) as cm: - self.assertIsNone(asyncio.run(coroutine)) # no return value - - [line] = cm.output - log_message = ( - "Censored a seemingly valid token sent by " - "lemon (`42`) in #lemonade-stand, " - f"token was `{censored_token}`" - ) - self.assertIn(log_message, line) - - self.msg.delete.assert_called_once_with() - self.msg.channel.send.assert_called_once_with( - DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') - ) - self.bot.get_cog.assert_called_with('ModLog') - self.msg.author.avatar_url_as.assert_called_once_with(static_format='png') - - mod_log = self.bot.get_cog.return_value - mod_log.ignore.assert_called_once_with(Event.message_delete, self.msg.id) - mod_log.send_log_message.assert_called_once_with( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=log_message, - thumbnail='picture-lemon.png', - channel_id=Channels.mod_alerts - ) - - -class TokenRemoverSetupTests(unittest.TestCase): - """Tests setup of the `TokenRemover` cog.""" - - def test_setup(self): - """Setup of the cog should log a message at `INFO` level.""" - bot = MockBot() - with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: - setup_cog(bot) - - [line] = cm.output - bot.add_cog.assert_called_once() - self.assertIn("Cog loaded: TokenRemover", line) diff --git a/tests/helpers.py b/tests/helpers.py index f8e8357f1..892d42e6c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -415,6 +415,7 @@ class MockMessage(AttributeMock, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(spec=message_instance, **kwargs) self.author = MockMember() + self.channel = MockTextChannel() # `discord.Message` coroutines self.ack = AsyncMock() -- cgit v1.2.3