aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorGravatar S. Co1 <[email protected]>2019-11-04 20:56:47 -0500
committerGravatar GitHub <[email protected]>2019-11-04 20:56:47 -0500
commitbe198809c757b4f6fa5dbbb419b57ac5438885a5 (patch)
tree2b4db54ba03224ca65bc2c8ca1683a9d2033a9dc /tests
parentSuperstarify: return None in _pardon_action for supported types (diff)
parentUser command enhancements (#637) (diff)
Merge branch 'master' into schedule-superstarify
Diffstat (limited to 'tests')
-rw-r--r--tests/bot/cogs/test_information.py444
-rw-r--r--tests/bot/test_utils.py52
-rw-r--r--tests/helpers.py315
-rw-r--r--tests/test_helpers.py68
4 files changed, 687 insertions, 192 deletions
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py
index 9bbd35a91..5c34541d8 100644
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/cogs/test_information.py
@@ -7,7 +7,11 @@ import discord
from bot import constants
from bot.cogs import information
-from tests.helpers import AsyncMock, MockBot, MockContext, MockGuild, MockMember, MockRole
+from bot.decorators import InChannelCheckFailure
+from tests import helpers
+
+
+COG_PATH = "bot.cogs.information.Information"
class InformationCogTests(unittest.TestCase):
@@ -15,22 +19,22 @@ class InformationCogTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
- cls.moderator_role = MockRole(name="Moderator", role_id=constants.Roles.moderator)
+ cls.moderator_role = helpers.MockRole(name="Moderator", role_id=constants.Roles.moderator)
def setUp(self):
"""Sets up fresh objects for each test."""
- self.bot = MockBot()
+ self.bot = helpers.MockBot()
self.cog = information.Information(self.bot)
- self.ctx = MockContext()
+ self.ctx = helpers.MockContext()
self.ctx.author.roles.append(self.moderator_role)
def test_roles_command_command(self):
"""Test if the `role_info` command correctly returns the `moderator_role`."""
self.ctx.guild.roles.append(self.moderator_role)
- self.cog.roles_info.can_run = AsyncMock()
+ self.cog.roles_info.can_run = helpers.AsyncMock()
self.cog.roles_info.can_run.return_value = True
coroutine = self.cog.roles_info.callback(self.cog, self.ctx)
@@ -48,7 +52,7 @@ class InformationCogTests(unittest.TestCase):
def test_role_info_command(self):
"""Tests the `role info` command."""
- dummy_role = MockRole(
+ dummy_role = helpers.MockRole(
name="Dummy",
role_id=112233445566778899,
colour=discord.Colour.blurple(),
@@ -57,7 +61,7 @@ class InformationCogTests(unittest.TestCase):
permissions=discord.Permissions(0)
)
- admin_role = MockRole(
+ admin_role = helpers.MockRole(
name="Admins",
role_id=998877665544332211,
colour=discord.Colour.red(),
@@ -68,7 +72,7 @@ class InformationCogTests(unittest.TestCase):
self.ctx.guild.roles.append([dummy_role, admin_role])
- self.cog.role_info.can_run = AsyncMock()
+ self.cog.role_info.can_run = helpers.AsyncMock()
self.cog.role_info.can_run.return_value = True
coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role)
@@ -99,7 +103,7 @@ class InformationCogTests(unittest.TestCase):
def test_server_info_command(self, time_since_patch):
time_since_patch.return_value = '2 days ago'
- self.ctx.guild = MockGuild(
+ self.ctx.guild = helpers.MockGuild(
features=('lemons', 'apples'),
region="The Moon",
roles=[self.moderator_role],
@@ -121,10 +125,10 @@ class InformationCogTests(unittest.TestCase):
)
],
members=[
- *(MockMember(status='online') for _ in range(2)),
- *(MockMember(status='idle') for _ in range(1)),
- *(MockMember(status='dnd') for _ in range(4)),
- *(MockMember(status='offline') for _ in range(3)),
+ *(helpers.MockMember(status='online') for _ in range(2)),
+ *(helpers.MockMember(status='idle') for _ in range(1)),
+ *(helpers.MockMember(status='dnd') for _ in range(4)),
+ *(helpers.MockMember(status='offline') for _ in range(3)),
],
member_count=1_234,
icon_url='a-lemon.jpg',
@@ -162,3 +166,417 @@ class InformationCogTests(unittest.TestCase):
)
)
self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg')
+
+
+class UserInfractionHelperMethodTests(unittest.TestCase):
+ """Tests for the helper methods of the `!user` command."""
+
+ def setUp(self):
+ """Common set-up steps done before for each test."""
+ self.bot = helpers.MockBot()
+ self.bot.api_client.get = helpers.AsyncMock()
+ self.cog = information.Information(self.bot)
+ self.member = helpers.MockMember(user_id=1234)
+
+ def test_user_command_helper_method_get_requests(self):
+ """The helper methods should form the correct get requests."""
+ test_values = (
+ {
+ "helper_method": self.cog.basic_user_infraction_counts,
+ "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}),
+ },
+ {
+ "helper_method": self.cog.expanded_user_infraction_counts,
+ "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}),
+ },
+ {
+ "helper_method": self.cog.user_nomination_counts,
+ "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}),
+ },
+ )
+
+ for test_value in test_values:
+ helper_method = test_value["helper_method"]
+ endpoint, params = test_value["expected_args"]
+
+ with self.subTest(method=helper_method, endpoint=endpoint, params=params):
+ asyncio.run(helper_method(self.member))
+ self.bot.api_client.get.assert_called_once_with(endpoint, params=params)
+ self.bot.api_client.get.reset_mock()
+
+ def _method_subtests(self, method, test_values, default_header):
+ """Helper method that runs the subtests for the different helper methods."""
+ for test_value in test_values:
+ api_response = test_value["api response"]
+ expected_lines = test_value["expected_lines"]
+
+ with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines):
+ self.bot.api_client.get.return_value = api_response
+
+ expected_output = "\n".join(default_header + expected_lines)
+ actual_output = asyncio.run(method(self.member))
+
+ self.assertEqual(expected_output, actual_output)
+
+ def test_basic_user_infraction_counts_returns_correct_strings(self):
+ """The method should correctly list both the total and active number of non-hidden infractions."""
+ test_values = (
+ # No infractions means zero counts
+ {
+ "api response": [],
+ "expected_lines": ["Total: 0", "Active: 0"],
+ },
+ # Simple, single-infraction dictionaries
+ {
+ "api response": [{"type": "ban", "active": True}],
+ "expected_lines": ["Total: 1", "Active: 1"],
+ },
+ {
+ "api response": [{"type": "ban", "active": False}],
+ "expected_lines": ["Total: 1", "Active: 0"],
+ },
+ # Multiple infractions with various `active` status
+ {
+ "api response": [
+ {"type": "ban", "active": True},
+ {"type": "kick", "active": False},
+ {"type": "ban", "active": True},
+ {"type": "ban", "active": False},
+ ],
+ "expected_lines": ["Total: 4", "Active: 2"],
+ },
+ )
+
+ header = ["**Infractions**"]
+
+ self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header)
+
+ def test_expanded_user_infraction_counts_returns_correct_strings(self):
+ """The method should correctly list the total and active number of all infractions split by infraction type."""
+ test_values = (
+ {
+ "api response": [],
+ "expected_lines": ["This user has never received an infraction."],
+ },
+ # Shows non-hidden inactive infraction as expected
+ {
+ "api response": [{"type": "kick", "active": False, "hidden": False}],
+ "expected_lines": ["Kicks: 1"],
+ },
+ # Shows non-hidden active infraction as expected
+ {
+ "api response": [{"type": "mute", "active": True, "hidden": False}],
+ "expected_lines": ["Mutes: 1 (1 active)"],
+ },
+ # Shows hidden inactive infraction as expected
+ {
+ "api response": [{"type": "superstar", "active": False, "hidden": True}],
+ "expected_lines": ["Superstars: 1"],
+ },
+ # Shows hidden active infraction as expected
+ {
+ "api response": [{"type": "ban", "active": True, "hidden": True}],
+ "expected_lines": ["Bans: 1 (1 active)"],
+ },
+ # Correctly displays tally of multiple infractions of mixed properties in alphabetical order
+ {
+ "api response": [
+ {"type": "kick", "active": False, "hidden": True},
+ {"type": "ban", "active": True, "hidden": True},
+ {"type": "superstar", "active": True, "hidden": True},
+ {"type": "mute", "active": True, "hidden": True},
+ {"type": "ban", "active": False, "hidden": False},
+ {"type": "note", "active": False, "hidden": True},
+ {"type": "note", "active": False, "hidden": True},
+ {"type": "warn", "active": False, "hidden": False},
+ {"type": "note", "active": False, "hidden": True},
+ ],
+ "expected_lines": [
+ "Bans: 2 (1 active)",
+ "Kicks: 1",
+ "Mutes: 1 (1 active)",
+ "Notes: 3",
+ "Superstars: 1 (1 active)",
+ "Warns: 1",
+ ],
+ },
+ )
+
+ header = ["**Infractions**"]
+
+ self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header)
+
+ def test_user_nomination_counts_returns_correct_strings(self):
+ """The method should list the number of active and historical nominations for the user."""
+ test_values = (
+ {
+ "api response": [],
+ "expected_lines": ["This user has never been nominated."],
+ },
+ {
+ "api response": [{'active': True}],
+ "expected_lines": ["This user is **currently** nominated (1 nomination in total)."],
+ },
+ {
+ "api response": [{'active': True}, {'active': False}],
+ "expected_lines": ["This user is **currently** nominated (2 nominations in total)."],
+ },
+ {
+ "api response": [{'active': False}],
+ "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."],
+ },
+ {
+ "api response": [{'active': False}, {'active': False}],
+ "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."],
+ },
+
+ )
+
+ header = ["**Nominations**"]
+
+ self._method_subtests(self.cog.user_nomination_counts, test_values, header)
+
+
[email protected]("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago"))
[email protected]("bot.cogs.information.constants.MODERATION_CHANNELS", new=[50])
+class UserEmbedTests(unittest.TestCase):
+ """Tests for the creation of the `!user` embed."""
+
+ def setUp(self):
+ """Common set-up steps done before for each test."""
+ self.bot = helpers.MockBot()
+ self.bot.api_client.get = helpers.AsyncMock()
+ self.cog = information.Information(self.bot)
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):
+ """The embed should use the string representation of the user if they don't have a nick."""
+ ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1))
+ user = helpers.MockMember()
+ user.nick = None
+ user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
+
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ self.assertEqual(embed.title, "Mr. Hemlock")
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_uses_nick_in_title_if_available(self):
+ """The embed should use the nick if it's available."""
+ ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1))
+ user = helpers.MockMember()
+ user.nick = "Cat lover"
+ user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
+
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)")
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_ignores_everyone_role(self):
+ """Created `!user` embeds should not contain mention of the @everyone-role."""
+ ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=1))
+ admins_role = helpers.MockRole('Admins')
+ admins_role.colour = 100
+
+ # A `MockMember` has the @Everyone role by default; we add the Admins to that.
+ user = helpers.MockMember(roles=[admins_role], top_role=admins_role)
+
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ self.assertIn("&Admins", embed.description)
+ self.assertNotIn("&Everyone", embed.description)
+
+ @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=helpers.AsyncMock)
+ @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=helpers.AsyncMock)
+ def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts):
+ """The embed should contain expanded infractions and nomination info in mod channels."""
+ ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=50))
+
+ moderators_role = helpers.MockRole('Moderators')
+ moderators_role.colour = 100
+
+ infraction_counts.return_value = "expanded infractions info"
+ nomination_counts.return_value = "nomination info"
+
+ user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role)
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ infraction_counts.assert_called_once_with(user)
+ nomination_counts.assert_called_once_with(user)
+
+ self.assertEqual(
+ textwrap.dedent(f"""
+ **User Information**
+ Created: {"1 year ago"}
+ Profile: {user.mention}
+ ID: {user.id}
+
+ **Member Information**
+ Joined: {"1 year ago"}
+ Roles: &Moderators
+
+ expanded infractions info
+
+ nomination info
+ """).strip(),
+ embed.description
+ )
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=helpers.AsyncMock)
+ def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts):
+ """The embed should contain only basic infraction data outside of mod channels."""
+ ctx = helpers.MockContext(channel=helpers.MockTextChannel(channel_id=100))
+
+ moderators_role = helpers.MockRole('Moderators')
+ moderators_role.colour = 100
+
+ infraction_counts.return_value = "basic infractions info"
+
+ user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role)
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ infraction_counts.assert_called_once_with(user)
+
+ self.assertEqual(
+ textwrap.dedent(f"""
+ **User Information**
+ Created: {"1 year ago"}
+ Profile: {user.mention}
+ ID: {user.id}
+
+ **Member Information**
+ Joined: {"1 year ago"}
+ Roles: &Moderators
+
+ basic infractions info
+ """).strip(),
+ embed.description
+ )
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):
+ """The embed should be created with the colour of the top role, if a top role is available."""
+ ctx = helpers.MockContext()
+
+ moderators_role = helpers.MockRole('Moderators')
+ moderators_role.colour = 100
+
+ user = helpers.MockMember(user_id=314, roles=[moderators_role], top_role=moderators_role)
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ self.assertEqual(embed.colour, discord.Colour(moderators_role.colour))
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):
+ """The embed should be created with a blurple colour if the user has no assigned roles."""
+ ctx = helpers.MockContext()
+
+ user = helpers.MockMember(user_id=217)
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ self.assertEqual(embed.colour, discord.Colour.blurple())
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):
+ """The embed thumbnail should be set to the user's avatar in `png` format."""
+ ctx = helpers.MockContext()
+
+ user = helpers.MockMember(user_id=217)
+ user.avatar_url_as.return_value = "avatar url"
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ user.avatar_url_as.assert_called_once_with(format="png")
+ self.assertEqual(embed.thumbnail.url, "avatar url")
+
+
[email protected]("bot.cogs.information.constants")
+class UserCommandTests(unittest.TestCase):
+ """Tests for the `!user` command."""
+
+ def setUp(self):
+ """Set up steps executed before each test is run."""
+ self.bot = helpers.MockBot()
+ self.cog = information.Information(self.bot)
+
+ self.moderator_role = helpers.MockRole("Moderators", role_id=2, position=10)
+ self.flautist_role = helpers.MockRole("Flautists", role_id=3, position=2)
+ self.bassist_role = helpers.MockRole("Bassists", role_id=4, position=3)
+
+ self.author = helpers.MockMember(user_id=1, name="syntaxaire")
+ self.moderator = helpers.MockMember(user_id=2, name="riffautae", roles=[self.moderator_role])
+ self.target = helpers.MockMember(user_id=3, name="__fluzz__")
+
+ def test_regular_member_cannot_target_another_member(self, constants):
+ """A regular user should not be able to use `!user` targeting another user."""
+ constants.MODERATION_ROLES = [self.moderator_role.id]
+
+ ctx = helpers.MockContext(author=self.author)
+
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target))
+
+ ctx.send.assert_called_once_with("You may not use this command on users other than yourself.")
+
+ def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants):
+ """A regular user should not be able to use this command outside of bot-commands."""
+ constants.MODERATION_ROLES = [self.moderator_role.id]
+ constants.STAFF_ROLES = [self.moderator_role.id]
+ constants.Channels.bot = 50
+
+ ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=100))
+
+ msg = "Sorry, but you may only use this command within <#50>."
+ with self.assertRaises(InChannelCheckFailure, msg=msg):
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx))
+
+ @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock)
+ def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants):
+ """A regular user should be allowed to use `!user` targeting themselves in bot-commands."""
+ constants.STAFF_ROLES = [self.moderator_role.id]
+ constants.Channels.bot = 50
+
+ ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50))
+
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx))
+
+ create_embed.assert_called_once_with(ctx, self.author)
+ ctx.send.assert_called_once()
+
+ @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock)
+ def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants):
+ """A user should target itself with `!user` when a `user` argument was not provided."""
+ constants.STAFF_ROLES = [self.moderator_role.id]
+ constants.Channels.bot = 50
+
+ ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(channel_id=50))
+
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author))
+
+ create_embed.assert_called_once_with(ctx, self.author)
+ ctx.send.assert_called_once()
+
+ @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock)
+ def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants):
+ """Staff members should be able to bypass the bot-commands channel restriction."""
+ constants.STAFF_ROLES = [self.moderator_role.id]
+ constants.Channels.bot = 50
+
+ ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=200))
+
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx))
+
+ create_embed.assert_called_once_with(ctx, self.moderator)
+ ctx.send.assert_called_once()
+
+ @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock)
+ def test_moderators_can_target_another_member(self, create_embed, constants):
+ """A moderator should be able to use `!user` targeting another user."""
+ constants.MODERATION_ROLES = [self.moderator_role.id]
+ constants.STAFF_ROLES = [self.moderator_role.id]
+
+ ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(channel_id=50))
+
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target))
+
+ create_embed.assert_called_once_with(ctx, self.target)
+ ctx.send.assert_called_once()
diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py
new file mode 100644
index 000000000..58ae2a81a
--- /dev/null
+++ b/tests/bot/test_utils.py
@@ -0,0 +1,52 @@
+import unittest
+
+from bot import utils
+
+
+class CaseInsensitiveDictTests(unittest.TestCase):
+ """Tests for the `CaseInsensitiveDict` container."""
+
+ def test_case_insensitive_key_access(self):
+ """Tests case insensitive key access and storage."""
+ instance = utils.CaseInsensitiveDict()
+
+ key = 'LEMON'
+ value = 'trees'
+
+ instance[key] = value
+ self.assertIn(key, instance)
+ self.assertEqual(instance.get(key), value)
+ self.assertEqual(instance.get(key.casefold()), value)
+ self.assertEqual(instance.pop(key.casefold()), value)
+ self.assertNotIn(key, instance)
+ self.assertNotIn(key.casefold(), instance)
+
+ instance.setdefault(key, value)
+ del instance[key]
+ self.assertNotIn(key, instance)
+
+ def test_initialization_from_kwargs(self):
+ """Tests creating the dictionary from keyword arguments."""
+ instance = utils.CaseInsensitiveDict({'FOO': 'bar'})
+ self.assertEqual(instance['foo'], 'bar')
+
+ def test_update_from_other_mapping(self):
+ """Tests updating the dictionary from another mapping."""
+ instance = utils.CaseInsensitiveDict()
+ instance.update({'FOO': 'bar'})
+ self.assertEqual(instance['foo'], 'bar')
+
+
+class ChunkTests(unittest.TestCase):
+ """Tests the `chunk` method."""
+
+ def test_empty_chunking(self):
+ """Tests chunking on an empty iterable."""
+ generator = utils.chunks(iterable=[], size=5)
+ self.assertEqual(list(generator), [])
+
+ def test_list_chunking(self):
+ """Tests chunking a non-empty list."""
+ iterable = [1, 2, 3, 4, 5]
+ generator = utils.chunks(iterable=iterable, size=2)
+ self.assertEqual(list(generator), [[1, 2], [3, 4], [5]])
diff --git a/tests/helpers.py b/tests/helpers.py
index 892d42e6c..8496ba031 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -2,8 +2,9 @@ from __future__ import annotations
import asyncio
import functools
+import inspect
import unittest.mock
-from typing import Iterable, Optional
+from typing import Any, Iterable, Optional
import discord
from discord.ext.commands import Bot, Context
@@ -24,19 +25,6 @@ def async_test(wrapped):
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.
@@ -61,15 +49,61 @@ class ColourMixin:
self.colour = color
-class AttributeMock:
- """Ensures attributes of our mock types will be instantiated with the correct mock type."""
+class CustomMockMixin:
+ """
+ Provides common functionality for our custom Mock types.
- 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)
+ The cooperative `__init__` automatically creates `AsyncMock` attributes for every coroutine
+ function `inspect` detects in the `spec` instance we provide. In addition, this mixin takes care
+ of making sure child mocks are instantiated with the correct class. By default, the mock of the
+ children will be `unittest.mock.MagicMock`, but this can be overwritten by setting the attribute
+ `child_mock_type` on the custom mock inheriting from this mixin.
+ """
- return super().__new__(cls)
+ child_mock_type = unittest.mock.MagicMock
+
+ def __init__(self, spec: Any = None, **kwargs):
+ super().__init__(spec=spec, **kwargs)
+ if spec:
+ self._extract_coroutine_methods_from_spec_instance(spec)
+
+ def _get_child_mock(self, **kw):
+ """
+ Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes.
+
+ 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.
+ """
+ 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)
+
+ def _extract_coroutine_methods_from_spec_instance(self, source: Any) -> None:
+ """Automatically detect coroutine functions in `source` and set them as AsyncMock attributes."""
+ for name, _method in inspect.getmembers(source, inspect.iscoroutinefunction):
+ setattr(self, name, AsyncMock())
+
+
+# TODO: Remove me in Python 3.8
+class AsyncMock(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock async callables.
+
+ Python 3.8 will introduce an AsyncMock class in the standard library that will have some more
+ features; this stand-in only overwrites the `__call__` method to an async version.
+ """
+ async def __call__(self, *args, **kwargs):
+ return super(AsyncMock, self).__call__(*args, **kwargs)
# Create a guild instance to get a realistic Mock of `discord.Guild`
@@ -95,7 +129,7 @@ guild_data = {
guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock())
-class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin):
+class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin):
"""
A `Mock` subclass to mock `discord.Guild` objects.
@@ -121,9 +155,6 @@ class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin):
For more info, see the `Mocking` section in `tests/README.md`.
"""
-
- attribute_mocktype = unittest.mock.MagicMock
-
def __init__(
self,
guild_id: int = 1,
@@ -143,48 +174,19 @@ 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}
role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data)
-class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin):
+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.
"""
-
- 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)
@@ -193,10 +195,6 @@ 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
@@ -208,16 +206,13 @@ 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):
+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.
"""
-
- attribute_mocktype = unittest.mock.MagicMock
-
def __init__(
self,
name: str = "member",
@@ -236,98 +231,29 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin):
self.mention = f"@{self.name}"
- # `discord.Member` coroutines
- self.add_roles = AsyncMock()
- self.ban = AsyncMock()
- self.edit = AsyncMock()
- self.fetch_message = AsyncMock()
- self.kick = AsyncMock()
- self.move_to = AsyncMock()
- self.pins = AsyncMock()
- self.remove_roles = AsyncMock()
- self.send = AsyncMock()
- self.trigger_typing = AsyncMock()
- self.unban = AsyncMock()
-
# Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot`
bot_instance = Bot(command_prefix=unittest.mock.MagicMock())
-class MockBot(AttributeMock, unittest.mock.MagicMock):
+class MockBot(CustomMockMixin, unittest.mock.MagicMock):
"""
A MagicMock subclass to mock Bot objects.
Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances.
For more information, see the `MockGuild` docstring.
"""
-
- attribute_mocktype = 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.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`
-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.guild = MockGuild()
- self.author = MockMember()
- self.command = unittest.mock.MagicMock()
+ # Our custom attributes and methods
+ self.http_session = unittest.mock.MagicMock()
+ self.api_client = 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()
+ # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and
+ # and should therefore be awaited. (The documentation calls it a coroutine as well, which
+ # is technically incorrect, since it's a regular def.)
+ self.wait_for = AsyncMock()
# Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel`
@@ -346,39 +272,20 @@ guild = unittest.mock.MagicMock()
channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data)
-class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin):
+class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
"""
A MagicMock subclass to mock TextChannel objects.
Instances of this class will follow the specifications of `discord.TextChannel` instances. For
more information, see the `MockGuild` docstring.
"""
-
- 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.guild = kwargs.get('guild', MockGuild())
self.mention = f"#{self.name}"
- # `discord.TextChannel` coroutines
- self.clone = AsyncMock()
- self.create_invite = AsyncMock()
- self.create_webhook = AsyncMock()
- self.delete = AsyncMock()
- self.delete_messages = AsyncMock()
- self.edit = AsyncMock()
- self.fetch_message = AsyncMock()
- self.invites = AsyncMock()
- self.pins = AsyncMock()
- self.purge = AsyncMock()
- self.send = AsyncMock()
- self.set_permissions = AsyncMock()
- self.trigger_typing = AsyncMock()
- self.webhooks = AsyncMock()
-
# Create a Message instance to get a realistic MagicMock of `discord.Message`
message_data = {
@@ -402,27 +309,83 @@ channel = unittest.mock.MagicMock()
message_instance = discord.Message(state=state, channel=channel, data=message_data)
-class MockMessage(AttributeMock, unittest.mock.MagicMock):
+# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context`
+context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock())
+
+
+class MockContext(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock Context objects.
+
+ Instances of this class will follow the specifications of `discord.ext.commands.Context`
+ instances. For more information, see the `MockGuild` docstring.
+ """
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec=context_instance, **kwargs)
+ self.bot = kwargs.get('bot', MockBot())
+ self.guild = kwargs.get('guild', MockGuild())
+ self.author = kwargs.get('author', MockMember())
+ self.channel = kwargs.get('channel', MockTextChannel())
+ self.command = kwargs.get('command', unittest.mock.MagicMock())
+
+
+class MockMessage(CustomMockMixin, unittest.mock.MagicMock):
"""
A MagicMock subclass to mock Message objects.
Instances of this class will follow the specifications of `discord.Message` instances. For more
information, see the `MockGuild` docstring.
"""
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec=message_instance, **kwargs)
+ self.author = kwargs.get('author', MockMember())
+ self.channel = kwargs.get('channel', MockTextChannel())
- attribute_mocktype = unittest.mock.MagicMock
+emoji_data = {'require_colons': True, 'managed': True, 'id': 1, 'name': 'hyperlemon'}
+emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data)
+
+
+class MockEmoji(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock Emoji objects.
+
+ Instances of this class will follow the specifications of `discord.Emoji` instances. For more
+ information, see the `MockGuild` docstring.
+ """
def __init__(self, **kwargs) -> None:
- super().__init__(spec=message_instance, **kwargs)
- self.author = MockMember()
- self.channel = MockTextChannel()
-
- # `discord.Message` coroutines
- self.ack = AsyncMock()
- self.add_reaction = AsyncMock()
- self.clear_reactions = AsyncMock()
- self.delete = AsyncMock()
- self.edit = AsyncMock()
- self.pin = AsyncMock()
- self.remove_reaction = AsyncMock()
- self.unpin = AsyncMock()
+ super().__init__(spec=emoji_instance, **kwargs)
+ self.guild = kwargs.get('guild', MockGuild())
+
+ # Get all coroutine functions and set them as AsyncMock attributes
+ self._extract_coroutine_methods_from_spec_instance(emoji_instance)
+
+
+partial_emoji_instance = discord.PartialEmoji(animated=False, name='guido')
+
+
+class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock PartialEmoji objects.
+
+ Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For
+ more information, see the `MockGuild` docstring.
+ """
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec=partial_emoji_instance, **kwargs)
+
+
+reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji())
+
+
+class MockReaction(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock Reaction objects.
+
+ Instances of this class will follow the specifications of `discord.Reaction` instances. For
+ more information, see the `MockGuild` docstring.
+ """
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec=reaction_instance, **kwargs)
+ self.emoji = kwargs.get('emoji', MockEmoji())
+ self.message = kwargs.get('message', MockMessage())
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index f08239981..2b58634dd 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -221,10 +221,10 @@ class DiscordMocksTests(unittest.TestCase):
@unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest')
def test_the_custom_mock_methods_test(self, subtest_mock):
"""The custom method test should raise AssertionError for invalid methods."""
- class FakeMockBot(helpers.AttributeMock, unittest.mock.MagicMock):
+ class FakeMockBot(helpers.CustomMockMixin, unittest.mock.MagicMock):
"""Fake MockBot class with invalid attribute/method `release_the_walrus`."""
- attribute_mocktype = unittest.mock.MagicMock
+ child_mock_type = unittest.mock.MagicMock
def __init__(self, **kwargs):
super().__init__(spec=helpers.bot_instance, **kwargs)
@@ -331,6 +331,18 @@ class MockObjectTests(unittest.TestCase):
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(helpers.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 = (
@@ -339,6 +351,10 @@ class MockObjectTests(unittest.TestCase):
(helpers.MockMember, "display_name"),
(helpers.MockBot, "owner_id"),
(helpers.MockContext, "command_failed"),
+ (helpers.MockMessage, "mention_everyone"),
+ (helpers.MockEmoji, 'managed'),
+ (helpers.MockPartialEmoji, 'url'),
+ (helpers.MockReaction, 'me'),
)
for mock_type, valid_attribute in test_values:
@@ -346,7 +362,53 @@ class MockObjectTests(unittest.TestCase):
mock = mock_type()
self.assertTrue(isinstance(mock, mock_type))
attribute = getattr(mock, valid_attribute)
- self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype))
+ self.assertTrue(isinstance(attribute, mock_type.child_mock_type))
+
+ def test_extract_coroutine_methods_from_spec_instance_should_extract_all_and_only_coroutines(self):
+ """Test if all coroutine functions are extracted, but not regular methods or attributes."""
+ class CoroutineDonor:
+ def __init__(self):
+ self.some_attribute = 'alpha'
+
+ async def first_coroutine():
+ """This coroutine function should be extracted."""
+
+ async def second_coroutine():
+ """This coroutine function should be extracted."""
+
+ def regular_method():
+ """This regular function should not be extracted."""
+
+ class Receiver:
+ pass
+
+ donor = CoroutineDonor()
+ receiver = Receiver()
+
+ helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance(receiver, donor)
+
+ self.assertIsInstance(receiver.first_coroutine, helpers.AsyncMock)
+ self.assertIsInstance(receiver.second_coroutine, helpers.AsyncMock)
+ self.assertFalse(hasattr(receiver, 'regular_method'))
+ self.assertFalse(hasattr(receiver, 'some_attribute'))
+
+ @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock())
+ @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance")
+ def test_custom_mock_mixin_init_with_spec(self, extract_method_mock):
+ """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method."""
+ spec = "pydis"
+
+ helpers.CustomMockMixin(spec=spec)
+
+ extract_method_mock.assert_called_once_with(spec)
+
+ @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock())
+ @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance")
+ def test_custom_mock_mixin_init_without_spec(self, extract_method_mock):
+ """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method."""
+ helpers.CustomMockMixin()
+
+ extract_method_mock.assert_not_called()
def test_async_mock_provides_coroutine_for_dunder_call(self):
"""Test if AsyncMock objects have a coroutine for their __call__ method."""