From dd727df1cbd932010b260aff7d36cf01dd90d035 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 15 Sep 2019 15:05:01 +0200 Subject: Add tests for `bot.utils.time`. --- tests/helpers.py | 4 ++++ tests/utils/test_time.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/utils/test_time.py (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 2908294f7..25059fa3a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -7,6 +7,10 @@ __all__ = ('AsyncMock', 'async_test') # TODO: Remove me on 3.8 +# Allows you to mock a coroutine. Since the default `__call__` of `MagicMock` +# is not a coroutine, trying to mock a coroutine with it will result in errors +# as the default `__call__` is not awaitable. Use this class for monkeypatching +# coroutines instead. class AsyncMock(MagicMock): async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py new file mode 100644 index 000000000..3d7423a1d --- /dev/null +++ b/tests/utils/test_time.py @@ -0,0 +1,48 @@ +import asyncio +from datetime import datetime, timezone +from unittest.mock import patch + +import pytest +from dateutil.relativedelta import relativedelta + +from bot.utils import time +from tests.helpers import AsyncMock + + +@pytest.mark.parametrize( + ('delta', 'precision', 'max_units', 'expected'), + ( + (relativedelta(days=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), + (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), + (relativedelta(days=2, hours=2), 'days', 2, '2 days'), + ) +) +def test_humanize_delta( + delta: relativedelta, + precision: str, + max_units: int, + expected: str +): + assert time.humanize_delta(delta, precision, max_units) == expected + + +@pytest.mark.parametrize( + ('stamp', 'expected'), + ( + ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)), + ) +) +def test_parse_rfc1123(stamp: str, expected: str): + assert time.parse_rfc1123(stamp) == expected + + +@patch('asyncio.sleep', new_callable=AsyncMock) +def test_wait_until(sleep_patch): + start = datetime(2019, 1, 1, 0, 0) + then = datetime(2019, 1, 1, 0, 10) + + # No return value + assert asyncio.run(time.wait_until(then, start)) is None + + sleep_patch.assert_called_once_with(10 * 60) -- cgit v1.2.3 From 2f6379f52ca2b74ad72545b6bb8196da410959e7 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sat, 21 Sep 2019 21:04:16 +0100 Subject: Add unit tests for role info command --- bot/cogs/information.py | 2 +- tests/cogs/test_information.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/bot/cogs/information.py b/bot/cogs/information.py index f05505902..2dd56333f 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -71,7 +71,7 @@ class Information(Cog): h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb()) - embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v:.2f}", inline=True) + embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True) embed.add_field(name="Member count", value=len(role.members), inline=True) diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py index 85b2d092e..986e73a65 100644 --- a/tests/cogs/test_information.py +++ b/tests/cogs/test_information.py @@ -8,6 +8,7 @@ import pytest from discord import ( CategoryChannel, Colour, + Permissions, TextChannel, VoiceChannel, ) @@ -66,6 +67,52 @@ def test_roles_info_command(cog, ctx): assert embed.footer.text == "Total roles: 1" +def test_role_info_command(cog, ctx): + dummy_role = MagicMock() + dummy_role.name = "Dummy" + dummy_role.colour = Colour.blurple() + dummy_role.id = 112233445566778899 + dummy_role.position = 10 + dummy_role.permissions = Permissions(0) + dummy_role.members = [ctx.author] + + admin_role = MagicMock() + admin_role.name = "Admin" + admin_role.colour = Colour.red() + admin_role.id = 998877665544332211 + admin_role.position = 3 + admin_role.permissions = Permissions(0) + admin_role.members = [ctx.author] + + ctx.guild.roles = [dummy_role, admin_role] + + cog.role_info.can_run = AsyncMock() + cog.role_info.can_run.return_value = True + + coroutine = cog.role_info.callback(cog, ctx, [dummy_role, admin_role]) + + assert asyncio.run(coroutine) is None + + assert ctx.send.call_count == 2 + + (_, dummy_kwargs), (_, admin_kwargs) = ctx.send.call_args_list + + dummy_embed = dummy_kwargs["embed"] + admin_embed = admin_kwargs["embed"] + + assert dummy_embed.title == "Dummy info" + assert dummy_embed.colour == Colour.blurple() + + assert dummy_embed.fields[0].value == str(dummy_role.id) + assert dummy_embed.fields[1].value == f"#{dummy_role.colour.value:0>6x}" + assert dummy_embed.fields[2].value == "0.63 0.48 218" + assert dummy_embed.fields[3].value == "1" + assert dummy_embed.fields[4].value == "10" + assert dummy_embed.fields[5].value == "0" + + assert admin_embed.title == "Admin info" + assert admin_embed.colour == Colour.red() + # There is no argument passed in here that we can use to test, # so the return value would change constantly. @patch('bot.cogs.information.time_since') -- cgit v1.2.3 From 20da07562aab1d9041170f70e1a3dc086f5c1b90 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 1 Oct 2019 10:53:18 +0200 Subject: Add converter for ISO-formatted datetime strings Related to https://github.com/python-discord/bot/issues/458 This commit adds a converter that automatically parses ISO-formatted datetime strings and returns a `datetime.datetime` object. It uses `dateutil.parser.isoparse` to do the heavy lifting, so it supports the same formats as this method. In addition, I have added tests that ensure that it accepts certain formats and added a description of these 'guaranteed' formats to the `ISODate.convert` docstring. This commit should make it easy to implement #485 --- bot/converters.py | 33 +++++++++++++++++++++++++++++ tests/test_converters.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) (limited to 'tests') diff --git a/bot/converters.py b/bot/converters.py index 339da7b60..49ac488f4 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -4,6 +4,7 @@ from datetime import datetime from ssl import CertificateError from typing import Union +import dateutil.parser import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta @@ -215,3 +216,35 @@ class Duration(Converter): now = datetime.utcnow() return now + delta + + +class ISODateTime(Converter): + """"Converts an ISO-8601 datetime string into a datetime.datetime.""" + + async def convert(self, ctx: Context, datetime_string: str) -> datetime: + """ + Converts a ISO-8601 `datetime_string` into a `datetime.datetime` object. + + The converter is flexible in the formats it accepts, as it uses the `isoparse` method of + `dateutil.parser`. In general, it accepts datetime strings that start with a date, + optionally followed by a time. + + See: + + Formats that are guaranteed to be valid by our tests are: + + - `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` + - `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` + - `YYYY-mm-dd` + - `YYYY-mm` + - `YYYY` + + Note: ISO-8601 specifies a `T` as the separator between the date and the time part of the + datetime string. The converter accepts both a `T` and a single space character. + """ + try: + dt = dateutil.parser.isoparse(datetime_string) + except ValueError: + raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string") + + return dt diff --git a/tests/test_converters.py b/tests/test_converters.py index 35fc5d88e..aa692f9f8 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -8,6 +8,7 @@ from discord.ext.commands import BadArgument from bot.converters import ( Duration, + ISODateTime, TagContentConverter, TagNameConverter, ValidPythonIdentifier, @@ -184,3 +185,57 @@ def test_duration_converter_for_invalid(duration: str): converter = Duration() with pytest.raises(BadArgument, match=f'`{duration}` is not a valid duration string.'): asyncio.run(converter.convert(None, duration)) + + +@pytest.mark.parametrize( + ("datetime_string", "expected_dt"), + ( + # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` + ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` + ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), + ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), + + # `YYYY-mm-dd` + ('2019-04-01', datetime.datetime(2019, 4, 1)), + + # `YYYY-mm` + ('2019-02-01', datetime.datetime(2019, 2, 1)), + + # `YYYY` + ('2025', datetime.datetime(2025, 1, 1)), + ), +) +def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: datetime.datetime): + converter = ISODateTime() + assert asyncio.run(converter.convert(None, datetime_string)) == expected_dt + + +@pytest.mark.parametrize( + ("datetime_string"), + ( + # Make sure it doesn't interfere with the Duration converation + ('1Y'), + ('1d'), + ('1H'), + + # Check if it fails when only providing the optional time part + ('10:10:10'), + ('10:00'), + + # Invalid date format + ('19-01-01'), + + # Other non-valid strings + ('fisk the tag master'), + ), +) +def test_isodatetime_converter_for_invalid(datetime_string: str): + converter = ISODateTime() + with pytest.raises( + BadArgument, + match=f"`{datetime_string}` is not a valid ISO-8601 datetime string", + ): + asyncio.run(converter.convert(None, datetime_string)) -- cgit v1.2.3 From 607fcac8af43588f3e7dbe8bec2b3cdc15d19cad Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 1 Oct 2019 17:31:26 +0100 Subject: Make tests work with Union converter --- tests/cogs/test_information.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'tests') diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py index 986e73a65..184bd2595 100644 --- a/tests/cogs/test_information.py +++ b/tests/cogs/test_information.py @@ -9,6 +9,7 @@ from discord import ( CategoryChannel, Colour, Permissions, + Role, TextChannel, VoiceChannel, ) @@ -68,7 +69,7 @@ def test_roles_info_command(cog, ctx): def test_role_info_command(cog, ctx): - dummy_role = MagicMock() + dummy_role = MagicMock(spec=Role) dummy_role.name = "Dummy" dummy_role.colour = Colour.blurple() dummy_role.id = 112233445566778899 @@ -76,7 +77,7 @@ def test_role_info_command(cog, ctx): dummy_role.permissions = Permissions(0) dummy_role.members = [ctx.author] - admin_role = MagicMock() + admin_role = MagicMock(spec=Role) admin_role.name = "Admin" admin_role.colour = Colour.red() admin_role.id = 998877665544332211 @@ -89,7 +90,7 @@ def test_role_info_command(cog, ctx): cog.role_info.can_run = AsyncMock() cog.role_info.can_run.return_value = True - coroutine = cog.role_info.callback(cog, ctx, [dummy_role, admin_role]) + coroutine = cog.role_info.callback(cog, ctx, dummy_role, admin_role) assert asyncio.run(coroutine) is None -- cgit v1.2.3 From 0b348e5b8bbdb2227f77a5074a431946bbc46b59 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Sep 2019 20:35:23 -0700 Subject: Fix stars.json resource test --- tests/test_resources.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) (limited to 'tests') diff --git a/tests/test_resources.py b/tests/test_resources.py index 2b17aea64..bcf124f05 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -1,18 +1,13 @@ import json -import mimetypes from pathlib import Path -from urllib.parse import urlparse def test_stars_valid(): - """Validates that `bot/resources/stars.json` contains valid images.""" + """Validates that `bot/resources/stars.json` contains a list of strings.""" path = Path('bot', 'resources', 'stars.json') content = path.read_text() data = json.loads(content) - for url in data.values(): - assert urlparse(url).scheme == 'https' - - mimetype, _ = mimetypes.guess_type(url) - assert mimetype in ('image/jpeg', 'image/png') + for name in data: + assert type(name) is str -- cgit v1.2.3 From 5f81b80a4dea49195053ab0177f4fd9aa9bea5e5 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 2 Oct 2019 07:27:06 +0200 Subject: Apply docstring review suggestion Co-Authored-By: Mark --- tests/test_converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/test_converters.py b/tests/test_converters.py index aa692f9f8..8093f55ac 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -216,7 +216,7 @@ def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: date @pytest.mark.parametrize( ("datetime_string"), ( - # Make sure it doesn't interfere with the Duration converation + # Make sure it doesn't interfere with the Duration converter ('1Y'), ('1d'), ('1H'), -- cgit v1.2.3 From a8b600217cb9ab4524bb307f0a6a922a0d8815be Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 2 Oct 2019 10:42:43 +0200 Subject: Make ISODateTime return tz-unaware datetime The parser we use, `dateutil.parsers.isoparse` returns a timezone- aware or timezone-unaware `datetime` object depending on whether or not the datetime string included a timezone offset specification. Since we can't compare tz-aware objects to tz-unaware objects it's better to make sure our converter is consistent in the type it will return. For now, I've chosen to return tz-unaware datetime objects, since `discord.py` also returns tz-unaware datetime objects when accessing datetime-related attributes of objects. Since we're likely to compare "our" datetime objects to discord.py-provided datetime objects, I think that's the most parsimonious option for now. Note: It's probably a good idea to open a larger discussion about using timezone-aware datetime objects throughout the library to avoid a UTC-time being interpreted as localtime. This will require a broader discussion than this commit/PR allows, though. --- bot/converters.py | 13 ++++++++++++- tests/test_converters.py | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/bot/converters.py b/bot/converters.py index 59a6f6b07..27223e632 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -5,6 +5,7 @@ from ssl import CertificateError from typing import Union import dateutil.parser +import dateutil.tz import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta @@ -227,12 +228,18 @@ class ISODateTime(Converter): The converter is flexible in the formats it accepts, as it uses the `isoparse` method of `dateutil.parser`. In general, it accepts datetime strings that start with a date, - optionally followed by a time. + optionally followed by a time. Specifying a timezone offset in the datetime string is + supported, but the `datetime` object will be converted to UTC and will be returned without + `tzinfo` as a timezone-unaware `datetime` object. See: https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.isoparse Formats that are guaranteed to be valid by our tests are: + - `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` + - `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` + - `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` + - `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` - `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` - `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` - `YYYY-mm-dd` @@ -247,4 +254,8 @@ class ISODateTime(Converter): except ValueError: raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string") + if dt.tzinfo: + dt = dt.astimezone(dateutil.tz.UTC) + dt = dt.replace(tzinfo=None) + return dt diff --git a/tests/test_converters.py b/tests/test_converters.py index 8093f55ac..86e8f2249 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -190,6 +190,27 @@ def test_duration_converter_for_invalid(duration: str): @pytest.mark.parametrize( ("datetime_string", "expected_dt"), ( + + # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` + ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` + ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` + ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` + ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), -- cgit v1.2.3 From 629cb4d05405a155715da765a2408be9156eb215 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 3 Oct 2019 07:14:31 +0200 Subject: Check if tzinfo is None in ISODateTime test As we have decided that the converter should return naive datetime objects, we should explicitly test that datetime strings with a timezone offset are still converted to a naive datetime object. I have done this by adding a `tzinfo is None` assertion. --- tests/test_converters.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/test_converters.py b/tests/test_converters.py index 86e8f2249..f69995ec6 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -231,7 +231,9 @@ def test_duration_converter_for_invalid(duration: str): ) def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: datetime.datetime): converter = ISODateTime() - assert asyncio.run(converter.convert(None, datetime_string)) == expected_dt + converted_dt = asyncio.run(converter.convert(None, datetime_string)) + assert converted_dt.tzinfo is None + assert converted_dt == expected_dt @pytest.mark.parametrize( -- cgit v1.2.3 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`. --- .coveragerc | 5 + Pipfile | 5 +- Pipfile.lock | 116 ++++--------- azure-pipelines.yml | 5 +- tests/README.md | 200 ++++++++++++++++++++++ tests/__init__.py | 5 + tests/base.py | 70 ++++++++ tests/bot/__init__.py | 0 tests/bot/cogs/__init__.py | 0 tests/bot/cogs/test_information.py | 164 ++++++++++++++++++ tests/bot/patches/__init__.py | 0 tests/bot/resources/__init__.py | 0 tests/bot/rules/__init__.py | 0 tests/bot/test_api.py | 134 +++++++++++++++ tests/bot/test_converters.py | 273 +++++++++++++++++++++++++++++ tests/bot/utils/__init__.py | 0 tests/bot/utils/test_checks.py | 43 +++++ tests/cogs/__init__.py | 0 tests/cogs/sync/__init__.py | 0 tests/cogs/sync/test_roles.py | 103 ----------- tests/cogs/sync/test_users.py | 69 -------- tests/cogs/test_antispam.py | 30 ---- tests/cogs/test_information.py | 211 ----------------------- tests/cogs/test_security.py | 54 ------ tests/cogs/test_token_remover.py | 133 --------------- tests/conftest.py | 32 ---- tests/helpers.py | 247 +++++++++++++++++++++++++-- tests/rules/__init__.py | 0 tests/rules/test_attachments.py | 52 ------ tests/test_api.py | 106 ------------ tests/test_base.py | 61 +++++++ tests/test_constants.py | 23 --- tests/test_converters.py | 264 ----------------------------- tests/test_helpers.py | 339 +++++++++++++++++++++++++++++++++++++ tests/test_pagination.py | 29 ---- tests/test_resources.py | 13 -- tests/utils/__init__.py | 0 tests/utils/test_checks.py | 66 -------- 38 files changed, 1567 insertions(+), 1285 deletions(-) create mode 100644 .coveragerc create mode 100644 tests/README.md create mode 100644 tests/base.py create mode 100644 tests/bot/__init__.py create mode 100644 tests/bot/cogs/__init__.py create mode 100644 tests/bot/cogs/test_information.py create mode 100644 tests/bot/patches/__init__.py create mode 100644 tests/bot/resources/__init__.py create mode 100644 tests/bot/rules/__init__.py create mode 100644 tests/bot/test_api.py create mode 100644 tests/bot/test_converters.py create mode 100644 tests/bot/utils/__init__.py create mode 100644 tests/bot/utils/test_checks.py delete mode 100644 tests/cogs/__init__.py delete mode 100644 tests/cogs/sync/__init__.py delete mode 100644 tests/cogs/sync/test_roles.py delete mode 100644 tests/cogs/sync/test_users.py delete mode 100644 tests/cogs/test_antispam.py delete mode 100644 tests/cogs/test_information.py delete mode 100644 tests/cogs/test_security.py delete mode 100644 tests/cogs/test_token_remover.py delete mode 100644 tests/conftest.py delete mode 100644 tests/rules/__init__.py delete mode 100644 tests/rules/test_attachments.py delete mode 100644 tests/test_api.py create mode 100644 tests/test_base.py delete mode 100644 tests/test_constants.py delete mode 100644 tests/test_converters.py create mode 100644 tests/test_helpers.py delete mode 100644 tests/test_pagination.py delete mode 100644 tests/test_resources.py delete mode 100644 tests/utils/__init__.py delete mode 100644 tests/utils/test_checks.py (limited to 'tests') diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..d572bd705 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +branch = true +source = + bot + tests diff --git a/Pipfile b/Pipfile index 82847b23f..0c73e4ca2 100644 --- a/Pipfile +++ b/Pipfile @@ -21,6 +21,7 @@ more_itertools = "~=7.2" urllib3 = ">=1.24.2,<1.25" [dev-packages] +coverage = "~=4.5" flake8 = "~=3.7" flake8-annotations = "~=1.1" flake8-bugbear = "~=19.8" @@ -32,8 +33,6 @@ flake8-todo = "~=0.7" pre-commit = "~=1.18" safety = "~=1.8" dodgy = "~=0.1" -pytest = "~=5.1" -pytest-cov = "~=2.7" [requires] python_version = "3.7" @@ -44,3 +43,5 @@ lint = "python -m flake8" precommit = "pre-commit install" build = "docker build -t pythondiscord/bot:latest -f Dockerfile ." push = "docker push pythondiscord/bot:latest" +test = "coverage run -m unittest" +report = "coverage report" diff --git a/Pipfile.lock b/Pipfile.lock index 4e6b4eaf8..366d1e525 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c2537cc3c5b0886d0b38f9b48f4f4b93e1e74d925454aa71a2189bddedadde42" + "sha256": "f5f32a03b561f1805f52447ca4e6582dd459581c5d581638925b7fabb09869f8" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:29f27a8092169924c9eefb0c5e428d216706618dc9caa75ddb7759638e16cf26", - "sha256:4f77ba9b6e7bc27fc88c49638bc3657ae5d4a2539e17fa0c2b25b370547b1b50" + "sha256:1dcec3e3e3309e277511dc0d7d157676d0165c174a6a745673fc9cf0510db8f0", + "sha256:dd5a23ca26a4872ee73bd107e4c545bace572cdec2a574aeb61f4062c7774b2a" ], "index": "pypi", - "version": "==6.1.2" + "version": "==6.1.3" }, "aiodns": { "hashes": [ @@ -83,10 +83,10 @@ }, "attrs": { "hashes": [ - "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", - "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" + "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2", + "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396" ], - "version": "==19.1.0" + "version": "==19.2.0" }, "babel": { "hashes": [ @@ -97,11 +97,11 @@ }, "beautifulsoup4": { "hashes": [ - "sha256:05668158c7b85b791c5abde53e50265e16f98ad601c402ba44d70f96c4159612", - "sha256:25288c9e176f354bf277c0a10aa96c782a6a18a17122dba2e8cec4a97e03343b", - "sha256:f040590be10520f2ea4c2ae8c3dae441c7cfff5308ec9d58a0ec0c1b8f81d469" + "sha256:5279c36b4b2ec2cb4298d723791467e3000e5384a43ea0cdf5d45207c7e97169", + "sha256:6135db2ba678168c07950f9a16c4031822c6f4aec75a65e0a97bc5ca09789931", + "sha256:dcdef580e18a76d54002088602eba453eec38ebbcafafeaabd8cab12b6155d57" ], - "version": "==4.8.0" + "version": "==4.8.1" }, "certifi": { "hashes": [ @@ -150,13 +150,6 @@ ], "version": "==3.0.4" }, - "colorama": { - "hashes": [ - "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", - "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" - ], - "version": "==0.4.1" - }, "deepdiff": { "hashes": [ "sha256:1123762580af0904621136d117c8397392a244d3ff0fa0a50de57a7939582476", @@ -204,10 +197,10 @@ }, "jinja2": { "hashes": [ - "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", - "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" + "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", + "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de" ], - "version": "==2.10.1" + "version": "==2.10.3" }, "jsonpickle": { "hashes": [ @@ -407,10 +400,10 @@ }, "pytz": { "hashes": [ - "sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32", - "sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7" + "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", + "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" ], - "version": "==2019.2" + "version": "==2019.3" }, "pyyaml": { "hashes": [ @@ -448,9 +441,10 @@ }, "snowballstemmer": { "hashes": [ - "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" ], - "version": "==1.9.1" + "version": "==2.0.0" }, "soupsieve": { "hashes": [ @@ -568,19 +562,12 @@ ], "version": "==1.3.0" }, - "atomicwrites": { - "hashes": [ - "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", - "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" - ], - "version": "==1.3.0" - }, "attrs": { "hashes": [ - "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", - "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" + "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2", + "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396" ], - "version": "==19.1.0" + "version": "==19.2.0" }, "certifi": { "hashes": [ @@ -610,13 +597,6 @@ ], "version": "==7.0" }, - "colorama": { - "hashes": [ - "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", - "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" - ], - "version": "==0.4.1" - }, "coverage": { "hashes": [ "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", @@ -652,6 +632,7 @@ "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" ], + "index": "pypi", "version": "==4.5.4" }, "dodgy": { @@ -701,11 +682,11 @@ }, "flake8-docstrings": { "hashes": [ - "sha256:1666dd069c9c457ee57e80af3c1a6b37b00cc1801c6fde88e455131bb2e186cd", - "sha256:9c0db5a79a1affd70fdf53b8765c8a26bf968e59e0252d7f2fc546b41c0cda06" + "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717", + "sha256:a256ba91bc52307bef1de59e2a009c3cf61c3d0952dbe035d6ff7208940c2edc" ], "index": "pypi", - "version": "==1.4.0" + "version": "==1.5.0" }, "flake8-import-order": { "hashes": [ @@ -757,7 +738,6 @@ "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" ], - "markers": "python_version < '3.8'", "version": "==0.23" }, "mccabe": { @@ -788,13 +768,6 @@ ], "version": "==19.2" }, - "pluggy": { - "hashes": [ - "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", - "sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34" - ], - "version": "==0.13.0" - }, "pre-commit": { "hashes": [ "sha256:1d3c0587bda7c4e537a46c27f2c84aa006acc18facf9970bf947df596ce91f3f", @@ -803,13 +776,6 @@ "index": "pypi", "version": "==1.18.3" }, - "py": { - "hashes": [ - "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", - "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" - ], - "version": "==1.8.0" - }, "pycodestyle": { "hashes": [ "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", @@ -838,22 +804,6 @@ ], "version": "==2.4.2" }, - "pytest": { - "hashes": [ - "sha256:813b99704b22c7d377bbd756ebe56c35252bb710937b46f207100e843440b3c2", - "sha256:cc6620b96bc667a0c8d4fa592a8c9c94178a1bd6cc799dbb057dfd9286d31a31" - ], - "index": "pypi", - "version": "==5.1.3" - }, - "pytest-cov": { - "hashes": [ - "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", - "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a" - ], - "index": "pypi", - "version": "==2.7.1" - }, "pyyaml": { "hashes": [ "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", @@ -898,9 +848,10 @@ }, "snowballstemmer": { "hashes": [ - "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" ], - "version": "==1.9.1" + "version": "==2.0.0" }, "toml": { "hashes": [ @@ -944,13 +895,6 @@ ], "version": "==16.7.5" }, - "wcwidth": { - "hashes": [ - "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", - "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" - ], - "version": "==0.1.7" - }, "zipp": { "hashes": [ "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c22bac089..3d0932398 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -30,9 +30,12 @@ jobs: - script: python -m flake8 displayName: 'Run linter' - - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests + - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m unittest displayName: Run tests + - script: coverage xml -o coverage.xml + displayName: Create test coverage report + - task: PublishCodeCoverageResults@1 displayName: 'Publish Coverage Results' condition: succeededOrFailed() diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..085ea39e0 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,200 @@ +# 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. + +_**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 + +We are using the following modules and packages for our unit tests: + +- [unittest](https://docs.python.org/3/library/unittest.html) (standard library) +- [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) (standard library) +- [coverage.py](https://coverage.readthedocs.io/en/stable/) + +To ensure the results you obtain on your personal machine are comparable to those in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: + +1. `pipenv run test` will run `unittest` with `coverage.py` +2. `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. + +**Note:** If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. + +## Writing tests + +Since our bot is a collaborative, community project, it's important to take a couple of things into when writing tests. This ensures that our test suite is consistent and that everyone understands the tests someone else has written. + +### File and directory structure + +To organize our test suite, we have chosen to mirror the directory structure of [`bot`](/bot/) in the [`tests`](/tests/) subdirectory. This makes it easy to find the relevant tests by providing a natural grouping of files. More general files, such as [`helpers.py`](/tests/helpers.py) are located directly in the `tests` subdirectory. + +All files containing tests should have a filename starting with `test_` to make sure `unittest` will discover them. This prefix is typically followed by the name of the file the tests are written for. If needed, a test file can contain multiple test classes, both to provide structure and to be able to provide different fixtures/set-up methods for different groups of tests. + +### Writing individual and independent tests + +When writing individual test methods, it is important to make sure that each test tests its own *independent* unit. In general, this means that you don't write one large test method that tests every possible branch/part/condition relevant to your function, but rather write separate test methods for each one. The reason is that this will make sure that if one test fails, the rest of the tests will still run and we feedback on exactly which parts are and which are not working correctly. (However, the [DRY-principle](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) also applies to tests, see the `Using self.subTest for independent subtests` section.) + +#### Method names and docstrings + +It's very important that the output of failing tests is easy to understand. That's why it is important to give your test methods a descriptive name that tells us what the failing test was testing. In addition, since **single-line** docstrings will be printed in the output along with the method name, it's also important to add a good single-line docstring that summarizes the purpose of the test to your test method. + +#### Using self.subTest for independent subtests + +Since it's important to make sure all of our tests are independent from each other, you may be tempted to copy-paste your test methods to test a function against a number of different input and output parameters. Luckily, `unittest` provides a better a better of doing that: [`subtests`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests). + +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): + +```py + def test_tag_content_converter_for_valid(self): + """TagContentConverter should return correct values for valid input.""" + test_values = ( + ('hello', 'hellpo'), + (' h ello ', 'h ello'), + ) + + for content, expected_conversion in test_values: + with self.subTest(content=content, expected_conversion=expected_conversion): + conversion = asyncio.run(TagContentConverter.convert(self.context, content)) + self.assertEqual(conversion, expected_conversion) +``` + +It's important to note the keyword arguments we provide to the `self.subTest` context manager: These keyword arguments and their values will printed in the output when one of the subtests fail, making sure we know *which* subTest failed: + +``` +.................................................................... +====================================================================== +FAIL: test_tag_content_converter_for_valid (tests.bot.test_converters.ConverterTests) (content='hello', expected_conversion='hellpo') +TagContentConverter should return correct values for valid input. +---------------------------------------------------------------------- + +# Snipped to save vertical space +``` + +## Mocking + +Since we are testing independent "units" of code, we sometimes need to provide "fake" versions of objects generated by code external to the unit we are trying to test to make sure we're truly testing independently from other units of code. We call these "fake objects" mocks. We mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module to create these mock objects, but we also have a couple special mock types defined in [`helpers.py`](/tests/helpers.py). + +An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object and then assert if the `send` method of the mocked version was called with the right message content by the command function: + +```py +import asyncio +import unittest + +from bot.cogs import bot +from tests.helpers import MockBot, MockContext + + +class BotCogTests(unittest.TestCase): + def test_echo_command_correctly_echoes_arguments(self): + """Test if the `!echo ` command correctly echoes the content.""" + mocked_bot = MockBot() + bot_cog = bot.Bot(mocked_bot) + + mocked_context = MockContext() + + text = "Hello! This should be echoed!" + + asyncio.run(bot_cog.echo_command.callback(bot_cog, mocked_context, text=text)) + + mocked_context.send.assert_called_with(text) +``` + +### Mocking coroutines + +By default, `unittest.mock.Mock` and `unittest.mock.MagicMock` can't mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. + +### Special mocks for some `discord.py` types + +To quote Ned Batchelder, Mock objects are "automatic chameleons". This means that they will happily allow the access to any attribute or method and provide a mocked value in return. One downside to this is that if the code you are testing gets the name of the attribute wrong, your mock object will not complain and the test may still pass. + +In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist by default on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. + +These special mocks are added when they are needed, so if you think it would be sensible to add another one, feel free to propose one in your PR. + +**Note:** These mock types only "know" the attributes that are set by default when these `discord.py` types are first initialized. If you need to work with dynamically set attributes that are added after initialization, you can still explicitly mock them: + +```py +import unittest.mock +from tests.helpers import MockGuild + +guild = MockGuild() +guild.some_attribute = unittest.mock.MagicMock() +``` + +The attribute `some_attribute` will now be accessible as a `MagicMock` on the mocked object. + +--- + +## Some considerations + +Finally, there are some considerations to make when writing tests, both for writing tests in general and for writing tests for our bot in particular. + +### Test coverage is a starting point + +Having test coverage is a good starting point for unit testing: If a part of your code was not covered by a test, we know that we have not tested it properly. The reverse is unfortunately not true: Even if the code we are testing has 100% branch coverage, it does not mean it's fully tested or guaranteed to work. + +One problem is that 100% branch coverage may be misleading if we haven't tested our code against all the realistic input it may get in production. For instance, take a look at the following `member_information` function and the test we've written for it: + +```py +import datetime +import unittest +import unittest.mock + + +def member_information(member): + joined = member.joined.stfptime("%d-%m-%Y") if member.joined else "unknown" + return f"{member.name} (joined: {joined})" + + +class FunctionsTests(unittest.TestCase): + def test_member_information(self): + member = unittest.mock.Mock() + member.name = "lemon" + member.joined = None + self.assertEqual(member_information(member), "lemon (joined: unknown)") +``` + +If you were to run this test, not only would the function pass the test, `coverage.py` will also tell us that the test provides 100% branch coverage for the function. Can you spot the bug the test suite did not catch? + +The problem here is that we have only tested our function with a member object that had `None` for the `member.joined` attribute. This means that `member.joined.stfptime("%d-%m-%Y")` was never executed during our test, leading to us missing the spelling mistake in `stfptime` (it should be `strftime`). + +Adding another test would not increase the test coverage we have, but it does ensure that we'll notice that this function can fail with realistic data: + +```py +# (...) +class FunctionsTests(unittest.TestCase): + # (...) + def test_member_information_with_join_datetime(self): + member = unittest.mock.Mock() + member.name = "lemon" + member.joined = datetime.datetime(year=2019, month=10, day=10) + self.assertEqual(member_information(member), "lemon (joined: 10-10-2019)") +``` + +Output: +``` +.E +====================================================================== +ERROR: test_member_information_with_join_datetime (tests.test_functions.FunctionsTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/home/pydis/playground/tests/test_functions.py", line 23, in test_member_information_with_join_datetime + self.assertEqual(member_information(member), "lemon (joined: 10-10-2019)") + File "/home/pydis/playground/tests/test_functions.py", line 8, in member_information + joined = member.joined.stfptime("%d-%m-%Y") if member.joined else "unknown" +AttributeError: 'datetime.datetime' object has no attribute 'stfptime' + +---------------------------------------------------------------------- +Ran 2 tests in 0.003s + +FAILED (errors=1) +``` + +What's more, even if the spelling mistake would not have been there, the first test did not test if the `member_information` function formatted the `member.join` according to the output we actually want to see. + +### Unit Testing vs Integration Testing + +Another restriction of unit testing is that it tests in, well, units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we work under the implicit assumption of understanding and utilizing those external objects correctly. So, in addition to testing the parts separately, we also need to test if the parts integrate correctly into a single application. + +We currently have no automated integration tests or functional tests, so **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb..2228110ad 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +import logging + + +log = logging.getLogger() +log.setLevel(logging.CRITICAL) diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 000000000..625dcc0a8 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,70 @@ +import logging +import unittest +from contextlib import contextmanager + + +class _CaptureLogHandler(logging.Handler): + """ + A logging handler capturing all (raw and formatted) logging output. + """ + + def __init__(self): + super().__init__() + self.records = [] + + def flush(self): + pass + + def emit(self, record): + self.records.append(record) + + +class LoggingTestCase(unittest.TestCase): + """TestCase subclass that adds more logging assertion tools.""" + + @contextmanager + def assertNotLogs(self, logger=None, level=None, msg=None): + """ + Asserts that no logs of `level` and higher were emitted by `logger`. + + You can specify a specific `logger`, the minimum `logging` level we want to watch and a + custom `msg` to be added to the `AssertionError` if thrown. If the assertion fails, the + recorded log records will be outputted with the `AssertionError` message. The context + manager does not yield a live `look` into the logging records, since we use this context + manager when we're testing under the assumption that no log records will be emitted. + """ + if not isinstance(logger, logging.Logger): + logger = logging.getLogger(logger) + + if level: + level = logging._nameToLevel.get(level, level) + else: + level = logging.INFO + + handler = _CaptureLogHandler() + old_handlers = logger.handlers[:] + old_level = logger.level + old_propagate = logger.propagate + + logger.handlers = [handler] + logger.setLevel(level) + logger.propagate = False + + try: + yield + except Exception as exc: + raise exc + finally: + logger.handlers = old_handlers + logger.propagate = old_propagate + logger.setLevel(old_level) + + if handler.records: + level_name = logging.getLevelName(level) + n_logs = len(handler.records) + base_message = f"{n_logs} logs of {level_name} or higher were triggered on {logger.name}:\n" + records = [str(record) for record in handler.records] + record_message = "\n".join(records) + standard_message = self._truncateMessage(base_message, record_message) + msg = self._formatMessage(msg, standard_message) + self.fail(msg) diff --git a/tests/bot/__init__.py b/tests/bot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/__init__.py b/tests/bot/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py new file mode 100644 index 000000000..9bbd35a91 --- /dev/null +++ b/tests/bot/cogs/test_information.py @@ -0,0 +1,164 @@ +import asyncio +import textwrap +import unittest +import unittest.mock + +import discord + +from bot import constants +from bot.cogs import information +from tests.helpers import AsyncMock, MockBot, MockContext, MockGuild, MockMember, MockRole + + +class InformationCogTests(unittest.TestCase): + """Tests the Information cog.""" + + @classmethod + def setUpClass(cls): + cls.moderator_role = MockRole(name="Moderator", role_id=constants.Roles.moderator) + + def setUp(self): + """Sets up fresh objects for each test.""" + self.bot = MockBot() + + self.cog = information.Information(self.bot) + + self.ctx = 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.return_value = True + + coroutine = self.cog.roles_info.callback(self.cog, self.ctx) + + self.assertIsNone(asyncio.run(coroutine)) + self.ctx.send.assert_called_once() + + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + + self.assertEqual(embed.title, "Role information") + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual(embed.description, f"`{self.moderator_role.id}` - {self.moderator_role.mention}\n") + self.assertEqual(embed.footer.text, "Total roles: 1") + + def test_role_info_command(self): + """Tests the `role info` command.""" + dummy_role = MockRole( + name="Dummy", + role_id=112233445566778899, + colour=discord.Colour.blurple(), + position=10, + members=[self.ctx.author], + permissions=discord.Permissions(0) + ) + + admin_role = MockRole( + name="Admins", + role_id=998877665544332211, + colour=discord.Colour.red(), + position=3, + members=[self.ctx.author], + permissions=discord.Permissions(0), + ) + + self.ctx.guild.roles.append([dummy_role, admin_role]) + + self.cog.role_info.can_run = AsyncMock() + self.cog.role_info.can_run.return_value = True + + coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) + + self.assertIsNone(asyncio.run(coroutine)) + + self.assertEqual(self.ctx.send.call_count, 2) + + (_, dummy_kwargs), (_, admin_kwargs) = self.ctx.send.call_args_list + + dummy_embed = dummy_kwargs["embed"] + admin_embed = admin_kwargs["embed"] + + self.assertEqual(dummy_embed.title, "Dummy info") + self.assertEqual(dummy_embed.colour, discord.Colour.blurple()) + + self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) + self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") + self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") + self.assertEqual(dummy_embed.fields[3].value, "1") + self.assertEqual(dummy_embed.fields[4].value, "10") + self.assertEqual(dummy_embed.fields[5].value, "0") + + self.assertEqual(admin_embed.title, "Admins info") + self.assertEqual(admin_embed.colour, discord.Colour.red()) + + @unittest.mock.patch('bot.cogs.information.time_since') + def test_server_info_command(self, time_since_patch): + time_since_patch.return_value = '2 days ago' + + self.ctx.guild = MockGuild( + features=('lemons', 'apples'), + region="The Moon", + roles=[self.moderator_role], + channels=[ + discord.TextChannel( + state={}, + guild=self.ctx.guild, + data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} + ), + discord.CategoryChannel( + state={}, + guild=self.ctx.guild, + data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} + ), + discord.VoiceChannel( + state={}, + guild=self.ctx.guild, + data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} + ) + ], + 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)), + ], + member_count=1_234, + icon_url='a-lemon.jpg', + ) + + coroutine = self.cog.server_info.callback(self.cog, self.ctx) + self.assertIsNone(asyncio.run(coroutine)) + + time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') + _, kwargs = self.ctx.send.call_args + embed = kwargs.pop('embed') + self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual( + embed.description, + textwrap.dedent( + f""" + **Server information** + Created: {time_since_patch.return_value} + Voice region: {self.ctx.guild.region} + Features: {', '.join(self.ctx.guild.features)} + + **Counts** + Members: {self.ctx.guild.member_count:,} + Roles: {len(self.ctx.guild.roles)} + Text: 1 + Voice: 1 + Channel categories: 1 + + **Members** + {constants.Emojis.status_online} 2 + {constants.Emojis.status_idle} 1 + {constants.Emojis.status_dnd} 4 + {constants.Emojis.status_offline} 3 + """ + ) + ) + self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') diff --git a/tests/bot/patches/__init__.py b/tests/bot/patches/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/resources/__init__.py b/tests/bot/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/rules/__init__.py b/tests/bot/rules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py new file mode 100644 index 000000000..e0ede0eb1 --- /dev/null +++ b/tests/bot/test_api.py @@ -0,0 +1,134 @@ +import logging +import unittest +from unittest.mock import MagicMock, patch + +from bot import api +from tests.base import LoggingTestCase +from tests.helpers import async_test + + +class APIClientTests(unittest.TestCase): + """Tests for the bot's API client.""" + + @classmethod + def setUpClass(cls): + """Sets up the shared fixtures for the tests.""" + cls.error_api_response = MagicMock() + cls.error_api_response.status = 999 + + def test_loop_is_not_running_by_default(self): + """The event loop should not be running by default.""" + self.assertFalse(api.loop_is_running()) + + @async_test + async def test_loop_is_running_in_async_context(self): + """The event loop should be running in an async context.""" + self.assertTrue(api.loop_is_running()) + + def test_response_code_error_default_initialization(self): + """Test the default initialization of `ResponseCodeError` without `text` or `json`""" + error = api.ResponseCodeError(response=self.error_api_response) + + self.assertIs(error.status, self.error_api_response.status) + self.assertEqual(error.response_json, {}) + self.assertEqual(error.response_text, "") + self.assertIs(error.response, self.error_api_response) + + def test_responde_code_error_string_representation_default_initialization(self): + """Test the string representation of `ResponseCodeError` initialized without text or json.""" + error = api.ResponseCodeError(response=self.error_api_response) + self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: ") + + def test_response_code_error_initialization_with_json(self): + """Test the initialization of `ResponseCodeError` with json.""" + json_data = {'hello': 'world'} + error = api.ResponseCodeError( + response=self.error_api_response, + response_json=json_data, + ) + self.assertEqual(error.response_json, json_data) + self.assertEqual(error.response_text, "") + + def test_response_code_error_string_representation_with_nonempty_response_json(self): + """Test the string representation of `ResponseCodeError` initialized with json.""" + json_data = {'hello': 'world'} + error = api.ResponseCodeError( + response=self.error_api_response, + response_json=json_data + ) + self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {json_data}") + + def test_response_code_error_initialization_with_text(self): + """Test the initialization of `ResponseCodeError` with text.""" + text_data = 'Lemon will eat your soul' + error = api.ResponseCodeError( + response=self.error_api_response, + response_text=text_data, + ) + self.assertEqual(error.response_text, text_data) + self.assertEqual(error.response_json, {}) + + def test_response_code_error_string_representation_with_nonempty_response_text(self): + """Test the string representation of `ResponseCodeError` initialized with text.""" + text_data = 'Lemon will eat your soul' + error = api.ResponseCodeError( + response=self.error_api_response, + response_text=text_data + ) + self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {text_data}") + + +class LoggingHandlerTests(LoggingTestCase): + """Tests the bot's API Log Handler.""" + + @classmethod + def setUpClass(cls): + cls.debug_log_record = logging.LogRecord( + name='my.logger', level=logging.DEBUG, + pathname='my/logger.py', lineno=666, + msg="Lemon wins", args=(), + exc_info=None + ) + + cls.trace_log_record = logging.LogRecord( + name='my.logger', level=logging.TRACE, + pathname='my/logger.py', lineno=666, + msg="This will not be logged", args=(), + exc_info=None + ) + + def setUp(self): + self.log_handler = api.APILoggingHandler(None) + + def test_emit_appends_to_queue_with_stopped_event_loop(self): + """Test if `APILoggingHandler.emit` appends to queue when the event loop is not running.""" + with patch("bot.api.APILoggingHandler.ship_off") as ship_off: + # Patch `ship_off` to ease testing against the return value of this coroutine. + ship_off.return_value = 42 + self.log_handler.emit(self.debug_log_record) + + self.assertListEqual(self.log_handler.queue, [42]) + + def test_emit_ignores_less_than_debug(self): + """`APILoggingHandler.emit` should not queue logs with a log level lower than DEBUG.""" + self.log_handler.emit(self.trace_log_record) + self.assertListEqual(self.log_handler.queue, []) + + def test_schedule_queued_tasks_for_empty_queue(self): + """`APILoggingHandler` should not schedule anything when the queue is empty.""" + with self.assertNotLogs(level=logging.DEBUG): + self.log_handler.schedule_queued_tasks() + + def test_schedule_queued_tasks_for_nonempty_queue(self): + """`APILoggingHandler` should schedule logs when the queue is not empty.""" + with self.assertLogs(level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task: + self.log_handler.queue = [555] + self.log_handler.schedule_queued_tasks() + self.assertListEqual(self.log_handler.queue, []) + create_task.assert_called_once_with(555) + + [record] = logs.records + self.assertEqual(record.message, "Scheduled 1 pending logging tasks.") + self.assertEqual(record.levelno, logging.DEBUG) + self.assertEqual(record.name, 'bot.api') + self.assertIn('via_handler', record.__dict__) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py new file mode 100644 index 000000000..b2b78d9dd --- /dev/null +++ b/tests/bot/test_converters.py @@ -0,0 +1,273 @@ +import asyncio +import datetime +import unittest +from unittest.mock import MagicMock, patch + +from dateutil.relativedelta import relativedelta +from discord.ext.commands import BadArgument + +from bot.converters import ( + Duration, + ISODateTime, + TagContentConverter, + TagNameConverter, + ValidPythonIdentifier, +) + + +class ConverterTests(unittest.TestCase): + """Tests our custom argument converters.""" + + @classmethod + def setUpClass(cls): + cls.context = MagicMock + cls.context.author = 'bob' + + cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') + + def test_tag_content_converter_for_valid(self): + """TagContentConverter should return correct values for valid input.""" + test_values = ( + ('hello', 'hello'), + (' h ello ', 'h ello'), + ) + + for content, expected_conversion in test_values: + with self.subTest(content=content, expected_conversion=expected_conversion): + conversion = asyncio.run(TagContentConverter.convert(self.context, content)) + self.assertEqual(conversion, expected_conversion) + + def test_tag_content_converter_for_invalid(self): + """TagContentConverter should raise the proper exception for invalid input.""" + test_values = ( + ('', "Tag contents should not be empty, or filled with whitespace."), + (' ', "Tag contents should not be empty, or filled with whitespace."), + ) + + for value, exception_message in test_values: + with self.subTest(tag_content=value, exception_message=exception_message): + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(TagContentConverter.convert(self.context, value)) + + def test_tag_name_converter_for_valid(self): + """TagNameConverter should return the correct values for valid tag names.""" + test_values = ( + ('tracebacks', 'tracebacks'), + ('Tracebacks', 'tracebacks'), + (' Tracebacks ', 'tracebacks'), + ) + + for name, expected_conversion in test_values: + with self.subTest(name=name, expected_conversion=expected_conversion): + conversion = asyncio.run(TagNameConverter.convert(self.context, name)) + self.assertEqual(conversion, expected_conversion) + + def test_tag_name_converter_for_invalid(self): + """TagNameConverter should raise the correct exception for invalid tag names.""" + test_values = ( + ('👋', "Don't be ridiculous, you can't use that character!"), + ('', "Tag names should not be empty, or filled with whitespace."), + (' ', "Tag names should not be empty, or filled with whitespace."), + ('42', "Tag names can't be numbers."), + ('x' * 128, "Are you insane? That's way too long!"), + ) + + for invalid_name, exception_message in test_values: + with self.subTest(invalid_name=invalid_name, exception_message=exception_message): + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(TagNameConverter.convert(self.context, invalid_name)) + + def test_valid_python_identifier_for_valid(self): + """ValidPythonIdentifier returns valid identifiers unchanged.""" + test_values = ('foo', 'lemon') + + for name in test_values: + with self.subTest(identifier=name): + conversion = asyncio.run(ValidPythonIdentifier.convert(self.context, name)) + self.assertEqual(name, conversion) + + def test_valid_python_identifier_for_invalid(self): + """ValidPythonIdentifier raises the proper exception for invalid identifiers.""" + test_values = ('nested.stuff', '#####') + + for name in test_values: + with self.subTest(identifier=name): + exception_message = f'`{name}` is not a valid Python identifier' + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(ValidPythonIdentifier.convert(self.context, name)) + + def test_duration_converter_for_valid(self): + """Duration returns the correct `datetime` for valid duration strings.""" + test_values = ( + # Simple duration strings + ('1Y', {"years": 1}), + ('1y', {"years": 1}), + ('1year', {"years": 1}), + ('1years', {"years": 1}), + ('1m', {"months": 1}), + ('1month', {"months": 1}), + ('1months', {"months": 1}), + ('1w', {"weeks": 1}), + ('1W', {"weeks": 1}), + ('1week', {"weeks": 1}), + ('1weeks', {"weeks": 1}), + ('1d', {"days": 1}), + ('1D', {"days": 1}), + ('1day', {"days": 1}), + ('1days', {"days": 1}), + ('1h', {"hours": 1}), + ('1H', {"hours": 1}), + ('1hour', {"hours": 1}), + ('1hours', {"hours": 1}), + ('1M', {"minutes": 1}), + ('1minute', {"minutes": 1}), + ('1minutes', {"minutes": 1}), + ('1s', {"seconds": 1}), + ('1S', {"seconds": 1}), + ('1second', {"seconds": 1}), + ('1seconds', {"seconds": 1}), + + # Complex duration strings + ( + '1y1m1w1d1H1M1S', + { + "years": 1, + "months": 1, + "weeks": 1, + "days": 1, + "hours": 1, + "minutes": 1, + "seconds": 1 + } + ), + ('5y100S', {"years": 5, "seconds": 100}), + ('2w28H', {"weeks": 2, "hours": 28}), + + # Duration strings with spaces + ('1 year 2 months', {"years": 1, "months": 2}), + ('1d 2H', {"days": 1, "hours": 2}), + ('1 week2 days', {"weeks": 1, "days": 2}), + ) + + converter = Duration() + + for duration, duration_dict in test_values: + expected_datetime = self.fixed_utc_now + relativedelta(**duration_dict) + + with patch('bot.converters.datetime') as mock_datetime: + mock_datetime.utcnow.return_value = self.fixed_utc_now + + with self.subTest(duration=duration, duration_dict=duration_dict): + converted_datetime = asyncio.run(converter.convert(self.context, duration)) + self.assertEqual(converted_datetime, expected_datetime) + + def test_duration_converter_for_invalid(self): + """Duration raises the right exception for invalid duration strings.""" + test_values = ( + # Units in wrong order + ('1d1w'), + ('1s1y'), + + # Duplicated units + ('1 year 2 years'), + ('1 M 10 minutes'), + + # Unknown substrings + ('1MVes'), + ('1y3breads'), + + # Missing amount + ('ym'), + + # Incorrect whitespace + (" 1y"), + ("1S "), + ("1y 1m"), + + # Garbage + ('Guido van Rossum'), + ('lemon lemon lemon lemon lemon lemon lemon'), + ) + + converter = Duration() + + for invalid_duration in test_values: + with self.subTest(invalid_duration=invalid_duration): + exception_message = f'`{invalid_duration}` is not a valid duration string.' + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(converter.convert(self.context, invalid_duration)) + + def test_isodatetime_converter_for_valid(self): + """ISODateTime converter returns correct datetime for valid datetime string.""" + test_values = ( + # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` + ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` + ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` + ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` + ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` + ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + + # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` + ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), + ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), + + # `YYYY-mm-dd` + ('2019-04-01', datetime.datetime(2019, 4, 1)), + + # `YYYY-mm` + ('2019-02-01', datetime.datetime(2019, 2, 1)), + + # `YYYY` + ('2025', datetime.datetime(2025, 1, 1)), + ) + + converter = ISODateTime() + + for datetime_string, expected_dt in test_values: + with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt): + converted_dt = asyncio.run(converter.convert(self.context, datetime_string)) + self.assertIsNone(converted_dt.tzinfo) + self.assertEqual(converted_dt, expected_dt) + + def test_isodatetime_converter_for_invalid(self): + """ISODateTime converter raises the correct exception for invalid datetime strings.""" + test_values = ( + # Make sure it doesn't interfere with the Duration converter + ('1Y'), + ('1d'), + ('1H'), + + # Check if it fails when only providing the optional time part + ('10:10:10'), + ('10:00'), + + # Invalid date format + ('19-01-01'), + + # Other non-valid strings + ('fisk the tag master'), + ) + + converter = ISODateTime() + for datetime_string in test_values: + with self.subTest(datetime_string=datetime_string): + exception_message = f"`{datetime_string}` is not a valid ISO-8601 datetime string" + with self.assertRaises(BadArgument, msg=exception_message): + asyncio.run(converter.convert(self.context, datetime_string)) diff --git a/tests/bot/utils/__init__.py b/tests/bot/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py new file mode 100644 index 000000000..22dc93073 --- /dev/null +++ b/tests/bot/utils/test_checks.py @@ -0,0 +1,43 @@ +import unittest + +from bot.utils import checks +from tests.helpers import MockContext, MockRole + + +class ChecksTests(unittest.TestCase): + """Tests the check functions defined in `bot.checks`.""" + + def setUp(self): + self.ctx = MockContext() + + def test_with_role_check_without_guild(self): + """`with_role_check` returns `False` if `Context.guild` is None.""" + self.ctx.guild = None + self.assertFalse(checks.with_role_check(self.ctx)) + + def test_with_role_check_without_required_roles(self): + """`with_role_check` returns `False` if `Context.author` lacks the required role.""" + self.ctx.author.roles = [] + self.assertFalse(checks.with_role_check(self.ctx)) + + def test_with_role_check_with_guild_and_required_role(self): + """`with_role_check` returns `True` if `Context.author` has the required role.""" + self.ctx.author.roles.append(MockRole(role_id=10)) + self.assertTrue(checks.with_role_check(self.ctx, 10)) + + def test_without_role_check_without_guild(self): + """`without_role_check` should return `False` when `Context.guild` is None.""" + self.ctx.guild = None + self.assertFalse(checks.without_role_check(self.ctx)) + + def test_without_role_check_returns_false_with_unwanted_role(self): + """`without_role_check` returns `False` if `Context.author` has unwanted role.""" + role_id = 42 + self.ctx.author.roles.append(MockRole(role_id=role_id)) + self.assertFalse(checks.without_role_check(self.ctx, role_id)) + + def test_without_role_check_returns_true_without_unwanted_role(self): + """`without_role_check` returns `True` if `Context.author` does not have unwanted role.""" + role_id = 42 + self.ctx.author.roles.append(MockRole(role_id=role_id)) + self.assertTrue(checks.without_role_check(self.ctx, role_id + 10)) diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py deleted file mode 100644 index c561ba447..000000000 --- a/tests/cogs/sync/test_roles.py +++ /dev/null @@ -1,103 +0,0 @@ -from bot.cogs.sync.syncers import Role, get_roles_for_sync - - -def test_get_roles_for_sync_empty_return_for_equal_roles(): - api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} - guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} - - assert get_roles_for_sync(guild_roles, api_roles) == (set(), set(), set()) - - -def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(): - api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} - guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} - - assert get_roles_for_sync(guild_roles, api_roles) == ( - set(), - guild_roles, - set(), - ) - - -def test_get_roles_only_returns_roles_that_require_update(): - api_roles = { - Role(id=41, name='old name', colour=33, permissions=0x8, position=1), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - guild_roles = { - Role(id=41, name='new name', colour=35, permissions=0x8, position=2), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - set(), - {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, - set(), - ) - - -def test_get_roles_returns_new_roles_in_first_tuple_element(): - api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - } - guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - Role(id=53, name='other role', colour=55, permissions=0, position=2) - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, - set(), - set(), - ) - - -def test_get_roles_returns_roles_to_update_and_new_roles(): - api_roles = { - Role(id=41, name='old name', colour=35, permissions=0x8, position=1), - } - guild_roles = { - Role(id=41, name='new name', colour=40, permissions=0x16, position=2), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, - {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, - set(), - ) - - -def test_get_roles_returns_roles_to_delete(): - api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), - } - guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - set(), - set(), - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) - - -def test_get_roles_returns_roles_to_delete_update_and_new_roles(): - api_roles = { - Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), - Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), - Role(id=71, name='to update', colour=99, permissions=0x9, position=3), - } - guild_roles = { - Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), - Role(id=81, name='to create', colour=99, permissions=0x9, position=4), - Role(id=71, name='updated', colour=101, permissions=0x5, position=3), - } - - assert get_roles_for_sync(guild_roles, api_roles) == ( - {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, - {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py deleted file mode 100644 index a863ae35b..000000000 --- a/tests/cogs/sync/test_users.py +++ /dev/null @@ -1,69 +0,0 @@ -from bot.cogs.sync.syncers import User, get_users_for_sync - - -def fake_user(**kwargs): - kwargs.setdefault('id', 43) - kwargs.setdefault('name', 'bob the test man') - kwargs.setdefault('discriminator', 1337) - kwargs.setdefault('avatar_hash', None) - kwargs.setdefault('roles', (666,)) - kwargs.setdefault('in_guild', True) - return User(**kwargs) - - -def test_get_users_for_sync_returns_nothing_for_empty_params(): - assert get_users_for_sync({}, {}) == (set(), set()) - - -def test_get_users_for_sync_returns_nothing_for_equal_users(): - api_users = {43: fake_user()} - guild_users = {43: fake_user()} - - assert get_users_for_sync(guild_users, api_users) == (set(), set()) - - -def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(): - api_users = {43: fake_user()} - guild_users = {43: fake_user(name='new fancy name')} - - assert get_users_for_sync(guild_users, api_users) == ( - set(), - {fake_user(name='new fancy name')} - ) - - -def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(): - api_users = {43: fake_user()} - guild_users = {43: fake_user(), 63: fake_user(id=63)} - - assert get_users_for_sync(guild_users, api_users) == ( - {fake_user(id=63)}, - set() - ) - - -def test_get_users_for_sync_updates_in_guild_field_on_user_leave(): - api_users = {43: fake_user(), 63: fake_user(id=63)} - guild_users = {43: fake_user()} - - assert get_users_for_sync(guild_users, api_users) == ( - set(), - {fake_user(id=63, in_guild=False)} - ) - - -def test_get_users_for_sync_updates_and_creates_users_as_needed(): - api_users = {43: fake_user()} - guild_users = {63: fake_user(id=63)} - - assert get_users_for_sync(guild_users, api_users) == ( - {fake_user(id=63)}, - {fake_user(in_guild=False)} - ) - - -def test_get_users_for_sync_does_not_duplicate_update_users(): - api_users = {43: fake_user(in_guild=False)} - guild_users = {} - - assert get_users_for_sync(guild_users, api_users) == (set(), set()) diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py deleted file mode 100644 index 67900b275..000000000 --- a/tests/cogs/test_antispam.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest - -from bot.cogs import antispam - - -def test_default_antispam_config_is_valid(): - validation_errors = antispam.validate_config() - assert not validation_errors - - -@pytest.mark.parametrize( - ('config', 'expected'), - ( - ( - {'invalid-rule': {}}, - {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} - ), - ( - {'burst': {'interval': 10}}, - {'burst': "Key `max` is required but not set for rule `burst`"} - ), - ( - {'burst': {'max': 10}}, - {'burst': "Key `interval` is required but not set for rule `burst`"} - ) - ) -) -def test_invalid_antispam_config_returns_validation_errors(config, expected): - validation_errors = antispam.validate_config(config) - assert validation_errors == expected diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py deleted file mode 100644 index 184bd2595..000000000 --- a/tests/cogs/test_information.py +++ /dev/null @@ -1,211 +0,0 @@ -import asyncio -import logging -import textwrap -from datetime import datetime -from unittest.mock import MagicMock, patch - -import pytest -from discord import ( - CategoryChannel, - Colour, - Permissions, - Role, - TextChannel, - VoiceChannel, -) - -from bot.cogs import information -from bot.constants import Emojis -from bot.decorators import InChannelCheckFailure -from tests.helpers import AsyncMock - - -@pytest.fixture() -def cog(simple_bot): - return information.Information(simple_bot) - - -def role(name: str, id_: int): - r = MagicMock() - r.name = name - r.id = id_ - r.mention = f'&{name}' - return r - - -def member(status: str): - m = MagicMock() - m.status = status - return m - - -@pytest.fixture() -def ctx(moderator_role, simple_ctx): - simple_ctx.author.roles = [moderator_role] - simple_ctx.guild.created_at = datetime(2001, 1, 1) - simple_ctx.send = AsyncMock() - return simple_ctx - - -def test_roles_info_command(cog, ctx): - everyone_role = MagicMock() - everyone_role.name = '@everyone' # should be excluded in the output - ctx.author.roles.append(everyone_role) - ctx.guild.roles = ctx.author.roles - - cog.roles_info.can_run = AsyncMock() - cog.roles_info.can_run.return_value = True - - coroutine = cog.roles_info.callback(cog, ctx) - - assert asyncio.run(coroutine) is None # no rval - ctx.send.assert_called_once() - _, kwargs = ctx.send.call_args - embed = kwargs.pop('embed') - assert embed.title == "Role information" - assert embed.colour == Colour.blurple() - assert embed.description == f"`{ctx.guild.roles[0].id}` - {ctx.guild.roles[0].mention}\n" - assert embed.footer.text == "Total roles: 1" - - -def test_role_info_command(cog, ctx): - dummy_role = MagicMock(spec=Role) - dummy_role.name = "Dummy" - dummy_role.colour = Colour.blurple() - dummy_role.id = 112233445566778899 - dummy_role.position = 10 - dummy_role.permissions = Permissions(0) - dummy_role.members = [ctx.author] - - admin_role = MagicMock(spec=Role) - admin_role.name = "Admin" - admin_role.colour = Colour.red() - admin_role.id = 998877665544332211 - admin_role.position = 3 - admin_role.permissions = Permissions(0) - admin_role.members = [ctx.author] - - ctx.guild.roles = [dummy_role, admin_role] - - cog.role_info.can_run = AsyncMock() - cog.role_info.can_run.return_value = True - - coroutine = cog.role_info.callback(cog, ctx, dummy_role, admin_role) - - assert asyncio.run(coroutine) is None - - assert ctx.send.call_count == 2 - - (_, dummy_kwargs), (_, admin_kwargs) = ctx.send.call_args_list - - dummy_embed = dummy_kwargs["embed"] - admin_embed = admin_kwargs["embed"] - - assert dummy_embed.title == "Dummy info" - assert dummy_embed.colour == Colour.blurple() - - assert dummy_embed.fields[0].value == str(dummy_role.id) - assert dummy_embed.fields[1].value == f"#{dummy_role.colour.value:0>6x}" - assert dummy_embed.fields[2].value == "0.63 0.48 218" - assert dummy_embed.fields[3].value == "1" - assert dummy_embed.fields[4].value == "10" - assert dummy_embed.fields[5].value == "0" - - assert admin_embed.title == "Admin info" - assert admin_embed.colour == Colour.red() - -# There is no argument passed in here that we can use to test, -# so the return value would change constantly. -@patch('bot.cogs.information.time_since') -def test_server_info_command(time_since_patch, cog, ctx, moderator_role): - time_since_patch.return_value = '2 days ago' - - ctx.guild.created_at = datetime(2001, 1, 1) - ctx.guild.features = ('lemons', 'apples') - ctx.guild.region = 'The Moon' - ctx.guild.roles = [moderator_role] - ctx.guild.channels = [ - TextChannel( - state={}, - guild=ctx.guild, - data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} - ), - CategoryChannel( - state={}, - guild=ctx.guild, - data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} - ), - VoiceChannel( - state={}, - guild=ctx.guild, - data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} - ) - ] - ctx.guild.members = [ - member('online'), member('online'), - member('idle'), - member('dnd'), member('dnd'), member('dnd'), member('dnd'), - member('offline'), member('offline'), member('offline') - ] - ctx.guild.member_count = 1_234 - ctx.guild.icon_url = 'a-lemon.png' - - coroutine = cog.server_info.callback(cog, ctx) - assert asyncio.run(coroutine) is None # no rval - - time_since_patch.assert_called_once_with(ctx.guild.created_at, precision='days') - _, kwargs = ctx.send.call_args - embed = kwargs.pop('embed') - assert embed.colour == Colour.blurple() - assert embed.description == textwrap.dedent(f""" - **Server information** - Created: {time_since_patch.return_value} - Voice region: {ctx.guild.region} - Features: {', '.join(ctx.guild.features)} - - **Counts** - Members: {ctx.guild.member_count:,} - Roles: {len(ctx.guild.roles)} - Text: 1 - Voice: 1 - Channel categories: 1 - - **Members** - {Emojis.status_online} 2 - {Emojis.status_idle} 1 - {Emojis.status_dnd} 4 - {Emojis.status_offline} 3 - """) - assert embed.thumbnail.url == 'a-lemon.png' - - -def test_user_info_on_other_users_from_non_moderator(ctx, cog): - ctx.author = MagicMock() - ctx.author.__eq__.return_value = False - ctx.author.roles = [] - coroutine = cog.user_info.callback(cog, ctx, user='scragly') # skip checks, pass args - - assert asyncio.run(coroutine) is None # no rval - ctx.send.assert_called_once_with( - "You may not use this command on users other than yourself." - ) - - -def test_user_info_in_wrong_channel_from_non_moderator(ctx, cog): - ctx.author = MagicMock() - ctx.author.__eq__.return_value = False - ctx.author.roles = [] - - coroutine = cog.user_info.callback(cog, ctx) - message = 'Sorry, but you may only use this command within <#267659945086812160>.' - with pytest.raises(InChannelCheckFailure, match=message): - assert asyncio.run(coroutine) is None # no rval - - -def test_setup(simple_bot, caplog): - information.setup(simple_bot) - simple_bot.add_cog.assert_called_once() - [record] = caplog.records - - assert record.message == "Cog loaded: Information" - assert record.levelno == logging.INFO diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py deleted file mode 100644 index 1efb460fe..000000000 --- a/tests/cogs/test_security.py +++ /dev/null @@ -1,54 +0,0 @@ -import logging -from unittest.mock import MagicMock - -import pytest -from discord.ext.commands import NoPrivateMessage - -from bot.cogs import security - - -@pytest.fixture() -def cog(): - bot = MagicMock() - return security.Security(bot) - - -@pytest.fixture() -def context(): - return MagicMock() - - -def test_check_additions(cog): - cog.bot.check.assert_any_call(cog.check_on_guild) - cog.bot.check.assert_any_call(cog.check_not_bot) - - -def test_check_not_bot_for_humans(cog, context): - context.author.bot = False - assert cog.check_not_bot(context) - - -def test_check_not_bot_for_robots(cog, context): - context.author.bot = True - assert not cog.check_not_bot(context) - - -def test_check_on_guild_outside_of_guild(cog, context): - context.guild = None - - with pytest.raises(NoPrivateMessage, match="This command cannot be used in private messages."): - cog.check_on_guild(context) - - -def test_check_on_guild_on_guild(cog, context): - context.guild = "lemon's lemonade stand" - assert cog.check_on_guild(context) - - -def test_security_cog_load(caplog): - bot = MagicMock() - security.setup(bot) - bot.add_cog.assert_called_once() - [record] = caplog.records - assert record.message == "Cog loaded: Security" - assert record.levelno == logging.INFO diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py deleted file mode 100644 index 9d46b3a05..000000000 --- a/tests/cogs/test_token_remover.py +++ /dev/null @@ -1,133 +0,0 @@ -import asyncio -from unittest.mock import MagicMock - -import pytest -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 - - -@pytest.fixture() -def token_remover(): - bot = MagicMock() - bot.get_cog.return_value = MagicMock() - bot.get_cog.return_value.send_log_message = AsyncMock() - return TokenRemover(bot=bot) - - -@pytest.fixture() -def message(): - message = MagicMock() - message.author.__str__.return_value = 'lemon' - message.author.bot = False - message.author.avatar_url_as.return_value = 'picture-lemon.png' - message.author.id = 42 - message.author.mention = '@lemon' - message.channel.send = AsyncMock() - message.channel.mention = '#lemonade-stand' - message.content = '' - message.delete = AsyncMock() - message.id = 555 - return message - - -@pytest.mark.parametrize( - ('content', 'expected'), - ( - ('MTIz', True), # 123 - ('YWJj', False), # abc - ) -) -def test_is_valid_user_id(content: str, expected: bool): - assert TokenRemover.is_valid_user_id(content) is expected - - -@pytest.mark.parametrize( - ('content', 'expected'), - ( - ('DN9r_A', True), # stolen from dapi, thanks to the author of the 'token' tag! - ('MTIz', False), # 123 - ) -) -def test_is_valid_timestamp(content: str, expected: bool): - assert TokenRemover.is_valid_timestamp(content) is expected - - -def test_mod_log_property(token_remover): - token_remover.bot.get_cog.return_value = 'lemon' - assert token_remover.mod_log == 'lemon' - token_remover.bot.get_cog.assert_called_once_with('ModLog') - - -def test_ignores_bot_messages(token_remover, message): - message.author.bot = True - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None - - -@pytest.mark.parametrize('content', ('', 'lemon wins')) -def test_ignores_messages_without_tokens(token_remover, message, content): - message.content = content - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None - - -@pytest.mark.parametrize('content', ('foo.bar.baz', 'x.y.')) -def test_ignores_invalid_tokens(token_remover, message, content): - message.content = content - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None - - -@pytest.mark.parametrize( - 'content, censored_token', - ( - ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), - ) -) -def test_censors_valid_tokens( - token_remover, message, content, censored_token, caplog -): - message.content = content - coroutine = token_remover.on_message(message) - assert asyncio.run(coroutine) is None # still no rval - - # asyncio logs some stuff about its reactor, discard it - [_, record] = caplog.records - assert record.message == ( - "Censored a seemingly valid token sent by lemon (`42`) in #lemonade-stand, " - f"token was `{censored_token}`" - ) - - message.delete.assert_called_once_with() - message.channel.send.assert_called_once_with( - DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') - ) - token_remover.bot.get_cog.assert_called_with('ModLog') - message.author.avatar_url_as.assert_called_once_with(static_format='png') - - mod_log = token_remover.bot.get_cog.return_value - mod_log.ignore.assert_called_once_with(Event.message_delete, message.id) - mod_log.send_log_message.assert_called_once_with( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=record.message, - thumbnail='picture-lemon.png', - channel_id=Channels.mod_alerts - ) - - -def test_setup(caplog): - bot = MagicMock() - setup_cog(bot) - [record] = caplog.records - - bot.add_cog.assert_called_once() - assert record.message == "Cog loaded: TokenRemover" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index d3de4484d..000000000 --- a/tests/conftest.py +++ /dev/null @@ -1,32 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from bot.constants import Roles -from tests.helpers import AsyncMock - - -@pytest.fixture() -def moderator_role(): - mock = MagicMock() - mock.id = Roles.moderator - mock.name = 'Moderator' - mock.mention = f'&{mock.name}' - return mock - - -@pytest.fixture() -def simple_bot(): - mock = MagicMock() - mock._before_invoke = AsyncMock() - mock._after_invoke = AsyncMock() - mock.can_run = AsyncMock() - mock.can_run.return_value = True - return mock - - -@pytest.fixture() -def simple_ctx(simple_bot): - mock = MagicMock() - mock.bot = simple_bot - return mock 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() diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py deleted file mode 100644 index 6f025b3cb..000000000 --- a/tests/rules/test_attachments.py +++ /dev/null @@ -1,52 +0,0 @@ -import asyncio -from dataclasses import dataclass -from typing import Any, List - -import pytest - -from bot.rules import attachments - - -# Using `MagicMock` sadly doesn't work for this usecase -# since it's __eq__ compares the MagicMock's ID. We just -# want to compare the actual attributes we set. -@dataclass -class FakeMessage: - author: str - attachments: List[Any] - - -def msg(total_attachments: int): - return FakeMessage(author='lemon', attachments=list(range(total_attachments))) - - -@pytest.mark.parametrize( - 'messages', - ( - (msg(0), msg(0), msg(0)), - (msg(2), msg(2)), - (msg(0),), - ) -) -def test_allows_messages_without_too_many_attachments(messages): - last_message, *recent_messages = messages - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - assert asyncio.run(coro) is None - - -@pytest.mark.parametrize( - ('messages', 'relevant_messages', 'total'), - ( - ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), - ((msg(6),), [msg(6)], 6), - ((msg(1),) * 6, [msg(1)] * 6, 6), - ) -) -def test_disallows_messages_with_too_many_attachments(messages, relevant_messages, total): - last_message, *recent_messages = messages - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - assert asyncio.run(coro) == ( - f"sent {total} attachments in 5s", - ('lemon',), - relevant_messages - ) diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index ce69ef187..000000000 --- a/tests/test_api.py +++ /dev/null @@ -1,106 +0,0 @@ -import logging -from unittest.mock import MagicMock, patch - -import pytest - -from bot import api -from tests.helpers import async_test - - -def test_loop_is_not_running_by_default(): - assert not api.loop_is_running() - - -@async_test -async def test_loop_is_running_in_async_test(): - assert api.loop_is_running() - - -@pytest.fixture() -def error_api_response(): - response = MagicMock() - response.status = 999 - return response - - -@pytest.fixture() -def api_log_handler(): - return api.APILoggingHandler(None) - - -@pytest.fixture() -def debug_log_record(): - return logging.LogRecord( - name='my.logger', level=logging.DEBUG, - pathname='my/logger.py', lineno=666, - msg="Lemon wins", args=(), - exc_info=None - ) - - -def test_response_code_error_default_initialization(error_api_response): - error = api.ResponseCodeError(response=error_api_response) - assert error.status is error_api_response.status - assert not error.response_json - assert not error.response_text - assert error.response is error_api_response - - -def test_response_code_error_default_representation(error_api_response): - error = api.ResponseCodeError(response=error_api_response) - assert str(error) == f"Status: {error_api_response.status} Response: " - - -def test_response_code_error_representation_with_nonempty_response_json(error_api_response): - error = api.ResponseCodeError( - response=error_api_response, - response_json={'hello': 'world'} - ) - assert str(error) == f"Status: {error_api_response.status} Response: {{'hello': 'world'}}" - - -def test_response_code_error_representation_with_nonempty_response_text(error_api_response): - error = api.ResponseCodeError( - response=error_api_response, - response_text='Lemon will eat your soul' - ) - assert str(error) == f"Status: {error_api_response.status} Response: Lemon will eat your soul" - - -@patch('bot.api.APILoggingHandler.ship_off') -def test_emit_appends_to_queue_with_stopped_event_loop( - ship_off_patch, api_log_handler, debug_log_record -): - # This is a coroutine so returns something we should await, - # but asyncio complains about that. To ease testing, we patch - # `ship_off` to just return a regular value instead. - ship_off_patch.return_value = 42 - api_log_handler.emit(debug_log_record) - - assert api_log_handler.queue == [42] - - -def test_emit_ignores_less_than_debug(debug_log_record, api_log_handler): - debug_log_record.levelno = logging.DEBUG - 5 - api_log_handler.emit(debug_log_record) - assert not api_log_handler.queue - - -def test_schedule_queued_tasks_for_empty_queue(api_log_handler, caplog): - api_log_handler.schedule_queued_tasks() - # Logs when tasks are scheduled - assert not caplog.records - - -@patch('asyncio.create_task') -def test_schedule_queued_tasks_for_nonempty_queue(create_task_patch, api_log_handler, caplog): - api_log_handler.queue = [555] - api_log_handler.schedule_queued_tasks() - assert not api_log_handler.queue - create_task_patch.assert_called_once_with(555) - - [record] = caplog.records - assert record.message == "Scheduled 1 pending logging tasks." - assert record.levelno == logging.DEBUG - assert record.name == 'bot.api' - assert record.__dict__['via_handler'] diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 000000000..b7c1e0037 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,61 @@ +import logging +import unittest +import unittest.mock + + +from tests.base import LoggingTestCase + + +class LoggingTestCaseTests(unittest.TestCase): + """Tests for the LoggingTestCase.""" + + @classmethod + def setUpClass(cls): + cls.log = logging.getLogger(__name__) + + def test_assert_not_logs_does_not_raise_with_no_logs(self): + """Test if LoggingTestCase.assertNotLogs does not raise when no logs were emitted.""" + try: + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + pass + except AssertionError: + self.fail("`self.assertNotLogs` raised an AssertionError when it should not!") + + @unittest.mock.patch("tests.base.LoggingTestCase.assertNotLogs") + def test_the_test_function_assert_not_logs_does_not_raise_with_no_logs(self, assertNotLogs): + """Test if test_assert_not_logs_does_not_raise_with_no_logs captures exception correctly.""" + assertNotLogs.return_value = iter([None]) + assertNotLogs.side_effect = AssertionError + + message = "`self.assertNotLogs` raised an AssertionError when it should not!" + with self.assertRaises(AssertionError, msg=message): + self.test_assert_not_logs_does_not_raise_with_no_logs() + + def test_assert_not_logs_raises_correct_assertion_error_when_logs_are_emitted(self): + """Test if LoggingTestCase.assertNotLogs raises AssertionError when logs were emitted.""" + msg_regex = ( + r"1 logs of DEBUG or higher were triggered on root:\n" + r'' + ) + with self.assertRaisesRegex(AssertionError, msg_regex): + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + self.log.debug("Log!") + + def test_assert_not_logs_reraises_unexpected_exception_in_managed_context(self): + """Test if LoggingTestCase.assertNotLogs reraises an unexpected exception.""" + with self.assertRaises(ValueError, msg="test exception"): + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + raise ValueError("test exception") + + def test_assert_not_logs_restores_old_logging_settings(self): + """Test if LoggingTestCase.assertNotLogs reraises an unexpected exception.""" + old_handlers = self.log.handlers[:] + old_level = self.log.level + old_propagate = self.log.propagate + + with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): + pass + + self.assertEqual(self.log.handlers, old_handlers) + self.assertEqual(self.log.level, old_level) + self.assertEqual(self.log.propagate, old_propagate) diff --git a/tests/test_constants.py b/tests/test_constants.py deleted file mode 100644 index e4a29d994..000000000 --- a/tests/test_constants.py +++ /dev/null @@ -1,23 +0,0 @@ -import inspect - -import pytest - -from bot import constants - - -@pytest.mark.parametrize( - 'section', - ( - cls - for (name, cls) in inspect.getmembers(constants) - if hasattr(cls, 'section') and isinstance(cls, type) - ) -) -def test_section_configuration_matches_typespec(section): - for (name, annotation) in section.__annotations__.items(): - value = getattr(section, name) - - if getattr(annotation, '_name', None) in ('Dict', 'List'): - pytest.skip("Cannot validate containers yet") - - assert isinstance(value, annotation) diff --git a/tests/test_converters.py b/tests/test_converters.py deleted file mode 100644 index f69995ec6..000000000 --- a/tests/test_converters.py +++ /dev/null @@ -1,264 +0,0 @@ -import asyncio -import datetime -from unittest.mock import MagicMock, patch - -import pytest -from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument - -from bot.converters import ( - Duration, - ISODateTime, - TagContentConverter, - TagNameConverter, - ValidPythonIdentifier, -) - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('hello', 'hello'), - (' h ello ', 'h ello') - ) -) -def test_tag_content_converter_for_valid(value: str, expected: str): - assert asyncio.run(TagContentConverter.convert(None, value)) == expected - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('', "Tag contents should not be empty, or filled with whitespace."), - (' ', "Tag contents should not be empty, or filled with whitespace.") - ) -) -def test_tag_content_converter_for_invalid(value: str, expected: str): - context = MagicMock() - context.author = 'bob' - - with pytest.raises(BadArgument, match=expected): - asyncio.run(TagContentConverter.convert(context, value)) - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('tracebacks', 'tracebacks'), - ('Tracebacks', 'tracebacks'), - (' Tracebacks ', 'tracebacks'), - ) -) -def test_tag_name_converter_for_valid(value: str, expected: str): - assert asyncio.run(TagNameConverter.convert(None, value)) == expected - - -@pytest.mark.parametrize( - ('value', 'expected'), - ( - ('👋', "Don't be ridiculous, you can't use that character!"), - ('', "Tag names should not be empty, or filled with whitespace."), - (' ', "Tag names should not be empty, or filled with whitespace."), - ('42', "Tag names can't be numbers."), - # Escape question mark as this is evaluated as regular expression. - ('x' * 128, r"Are you insane\? That's way too long!"), - ) -) -def test_tag_name_converter_for_invalid(value: str, expected: str): - context = MagicMock() - context.author = 'bob' - - with pytest.raises(BadArgument, match=expected): - asyncio.run(TagNameConverter.convert(context, value)) - - -@pytest.mark.parametrize('value', ('foo', 'lemon')) -def test_valid_python_identifier_for_valid(value: str): - assert asyncio.run(ValidPythonIdentifier.convert(None, value)) == value - - -@pytest.mark.parametrize('value', ('nested.stuff', '#####')) -def test_valid_python_identifier_for_invalid(value: str): - with pytest.raises(BadArgument, match=f'`{value}` is not a valid Python identifier'): - asyncio.run(ValidPythonIdentifier.convert(None, value)) - - -FIXED_UTC_NOW = datetime.datetime.fromisoformat('2019-01-01T00:00:00') - - -@pytest.fixture( - params=( - # Simple duration strings - ('1Y', {"years": 1}), - ('1y', {"years": 1}), - ('1year', {"years": 1}), - ('1years', {"years": 1}), - ('1m', {"months": 1}), - ('1month', {"months": 1}), - ('1months', {"months": 1}), - ('1w', {"weeks": 1}), - ('1W', {"weeks": 1}), - ('1week', {"weeks": 1}), - ('1weeks', {"weeks": 1}), - ('1d', {"days": 1}), - ('1D', {"days": 1}), - ('1day', {"days": 1}), - ('1days', {"days": 1}), - ('1h', {"hours": 1}), - ('1H', {"hours": 1}), - ('1hour', {"hours": 1}), - ('1hours', {"hours": 1}), - ('1M', {"minutes": 1}), - ('1minute', {"minutes": 1}), - ('1minutes', {"minutes": 1}), - ('1s', {"seconds": 1}), - ('1S', {"seconds": 1}), - ('1second', {"seconds": 1}), - ('1seconds', {"seconds": 1}), - - # Complex duration strings - ( - '1y1m1w1d1H1M1S', - { - "years": 1, - "months": 1, - "weeks": 1, - "days": 1, - "hours": 1, - "minutes": 1, - "seconds": 1 - } - ), - ('5y100S', {"years": 5, "seconds": 100}), - ('2w28H', {"weeks": 2, "hours": 28}), - - # Duration strings with spaces - ('1 year 2 months', {"years": 1, "months": 2}), - ('1d 2H', {"days": 1, "hours": 2}), - ('1 week2 days', {"weeks": 1, "days": 2}), - ) -) -def create_future_datetime(request): - """Yields duration string and target datetime.datetime object.""" - duration, duration_dict = request.param - future_datetime = FIXED_UTC_NOW + relativedelta(**duration_dict) - yield duration, future_datetime - - -def test_duration_converter_for_valid(create_future_datetime: tuple): - converter = Duration() - duration, expected = create_future_datetime - with patch('bot.converters.datetime') as mock_datetime: - mock_datetime.utcnow.return_value = FIXED_UTC_NOW - assert asyncio.run(converter.convert(None, duration)) == expected - - -@pytest.mark.parametrize( - ('duration'), - ( - # Units in wrong order - ('1d1w'), - ('1s1y'), - - # Duplicated units - ('1 year 2 years'), - ('1 M 10 minutes'), - - # Unknown substrings - ('1MVes'), - ('1y3breads'), - - # Missing amount - ('ym'), - - # Incorrect whitespace - (" 1y"), - ("1S "), - ("1y 1m"), - - # Garbage - ('Guido van Rossum'), - ('lemon lemon lemon lemon lemon lemon lemon'), - ) -) -def test_duration_converter_for_invalid(duration: str): - converter = Duration() - with pytest.raises(BadArgument, match=f'`{duration}` is not a valid duration string.'): - asyncio.run(converter.convert(None, duration)) - - -@pytest.mark.parametrize( - ("datetime_string", "expected_dt"), - ( - - # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` - ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` - ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` - ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` - ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` - ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), - - # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` - ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), - ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), - - # `YYYY-mm-dd` - ('2019-04-01', datetime.datetime(2019, 4, 1)), - - # `YYYY-mm` - ('2019-02-01', datetime.datetime(2019, 2, 1)), - - # `YYYY` - ('2025', datetime.datetime(2025, 1, 1)), - ), -) -def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: datetime.datetime): - converter = ISODateTime() - converted_dt = asyncio.run(converter.convert(None, datetime_string)) - assert converted_dt.tzinfo is None - assert converted_dt == expected_dt - - -@pytest.mark.parametrize( - ("datetime_string"), - ( - # Make sure it doesn't interfere with the Duration converter - ('1Y'), - ('1d'), - ('1H'), - - # Check if it fails when only providing the optional time part - ('10:10:10'), - ('10:00'), - - # Invalid date format - ('19-01-01'), - - # Other non-valid strings - ('fisk the tag master'), - ), -) -def test_isodatetime_converter_for_invalid(datetime_string: str): - converter = ISODateTime() - with pytest.raises( - BadArgument, - match=f"`{datetime_string}` is not a valid ISO-8601 datetime string", - ): - asyncio.run(converter.convert(None, datetime_string)) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 000000000..766fe17b8 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,339 @@ +import asyncio +import inspect +import unittest +import unittest.mock + +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)) + + def test_mock_role_default_initialization(self): + """Test if the default initialization of MockRole results in the correct object.""" + role = helpers.MockRole() + + # The `spec` argument makes sure `isistance` checks with `discord.Role` pass + self.assertIsInstance(role, discord.Role) + + self.assertEqual(role.name, "role") + self.assertEqual(role.id, 1) + self.assertEqual(role.position, 1) + self.assertEqual(role.mention, "&role") + + def test_mock_role_alternative_arguments(self): + """Test if MockRole initializes with the arguments provided.""" + role = helpers.MockRole( + name="Admins", + role_id=90210, + position=10, + ) + + self.assertEqual(role.name, "Admins") + self.assertEqual(role.id, 90210) + self.assertEqual(role.position, 10) + self.assertEqual(role.mention, "&Admins") + + def test_mock_role_accepts_dynamic_arguments(self): + """Test if MockRole accepts and sets abitrary keyword arguments.""" + role = helpers.MockRole( + guild="Dino Man", + hoist=True, + ) + + 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) + role_two = helpers.MockRole(position=2) + role_three = helpers.MockRole(position=3) + + self.assertLess(role_one, role_two) + self.assertLess(role_one, role_three) + self.assertLess(role_two, role_three) + self.assertGreater(role_three, role_two) + self.assertGreater(role_three, role_one) + self.assertGreater(role_two, role_one) + + def test_mock_member_default_initialization(self): + """Test if the default initialization of Mockmember results in the correct object.""" + member = helpers.MockMember() + + # The `spec` argument makes sure `isistance` checks with `discord.Member` pass + self.assertIsInstance(member, discord.Member) + + self.assertEqual(member.name, "member") + self.assertEqual(member.id, 1) + self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1)]) + self.assertEqual(member.mention, "@member") + + def test_mock_member_alternative_arguments(self): + """Test if MockMember initializes with the arguments provided.""" + core_developer = helpers.MockRole("Core Developer", 2) + member = helpers.MockMember( + name="Mark", + user_id=12345, + roles=[core_developer] + ) + + self.assertEqual(member.name, "Mark") + self.assertEqual(member.id, 12345) + self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1), core_developer]) + self.assertEqual(member.mention, "@Mark") + + def test_mock_member_accepts_dynamic_arguments(self): + """Test if MockMember accepts and sets abitrary keyword arguments.""" + member = helpers.MockMember( + nick="Dino Man", + colour=discord.Colour.default(), + ) + + 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() + + # The `spec` argument makes sure `isistance` checks with `discord.Guild` pass + self.assertIsInstance(guild, discord.Guild) + + self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1)]) + self.assertListEqual(guild.members, []) + + def test_mock_guild_alternative_arguments(self): + """Test if MockGuild initializes with the arguments provided.""" + core_developer = helpers.MockRole("Core Developer", 2) + guild = helpers.MockGuild( + roles=[core_developer], + members=[helpers.MockMember(user_id=54321)], + ) + + self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1), core_developer]) + self.assertListEqual(guild.members, [helpers.MockMember(user_id=54321)]) + + def test_mock_guild_accepts_dynamic_arguments(self): + """Test if MockGuild accepts and sets abitrary keyword arguments.""" + guild = helpers.MockGuild( + emojis=(":hyperjoseph:", ":pensive_ela:"), + premium_subscription_count=15, + ) + + self.assertTupleEqual(guild.emojis, (":hyperjoseph:", ":pensive_ela:")) + self.assertEqual(guild.premium_subscription_count, 15) + + def test_mock_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() + + # 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() + + # The `spec` argument makes sure `isistance` checks with `discord.ext.commands.Context` pass + 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_async_mock_provides_coroutine_for_dunder_call(self): + """Test if AsyncMock objects have a coroutine for their __call__ method.""" + async_mock = helpers.AsyncMock() + self.assertTrue(inspect.iscoroutinefunction(async_mock.__call__)) + + coroutine = async_mock() + self.assertTrue(inspect.iscoroutine(coroutine)) + self.assertIsNotNone(asyncio.run(coroutine)) + + def test_async_test_decorator_allows_synchronous_call_to_async_def(self): + """Test if the `async_test` decorator allows an `async def` to be called synchronously.""" + @helpers.async_test + async def kosayoda(): + return "return value" + + self.assertEqual(kosayoda(), "return value") diff --git a/tests/test_pagination.py b/tests/test_pagination.py deleted file mode 100644 index 11d6541ae..000000000 --- a/tests/test_pagination.py +++ /dev/null @@ -1,29 +0,0 @@ -from unittest import TestCase - -import pytest - -from bot import pagination - - -class LinePaginatorTests(TestCase): - def setUp(self): - self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) - - def test_add_line_raises_on_too_long_lines(self): - message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" - with pytest.raises(RuntimeError, match=message): - self.paginator.add_line('x' * self.paginator.max_size) - - def test_add_line_works_on_small_lines(self): - self.paginator.add_line('x' * (self.paginator.max_size - 3)) - - -class ImagePaginatorTests(TestCase): - def setUp(self): - self.paginator = pagination.ImagePaginator() - - def test_add_image_appends_image(self): - image = 'lemon' - self.paginator.add_image(image) - - assert self.paginator.images == [image] diff --git a/tests/test_resources.py b/tests/test_resources.py deleted file mode 100644 index bcf124f05..000000000 --- a/tests/test_resources.py +++ /dev/null @@ -1,13 +0,0 @@ -import json -from pathlib import Path - - -def test_stars_valid(): - """Validates that `bot/resources/stars.json` contains a list of strings.""" - - path = Path('bot', 'resources', 'stars.json') - content = path.read_text() - data = json.loads(content) - - for name in data: - assert type(name) is str diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py deleted file mode 100644 index 7121acebd..000000000 --- a/tests/utils/test_checks.py +++ /dev/null @@ -1,66 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from bot.utils import checks - - -@pytest.fixture() -def context(): - return MagicMock() - - -def test_with_role_check_without_guild(context): - context.guild = None - - assert not checks.with_role_check(context) - - -def test_with_role_check_with_guild_without_required_role(context): - context.guild = True - context.author.roles = [] - - assert not checks.with_role_check(context) - - -def test_with_role_check_with_guild_with_required_role(context): - context.guild = True - role = MagicMock() - role.id = 42 - context.author.roles = (role,) - - assert checks.with_role_check(context, role.id) - - -def test_without_role_check_without_guild(context): - context.guild = None - - assert not checks.without_role_check(context) - - -def test_without_role_check_with_unwanted_role(context): - context.guild = True - role = MagicMock() - role.id = 42 - context.author.roles = (role,) - - assert not checks.without_role_check(context, role.id) - - -def test_without_role_check_without_unwanted_role(context): - context.guild = True - role = MagicMock() - role.id = 42 - context.author.roles = (role,) - - assert checks.without_role_check(context, role.id + 10) - - -def test_in_channel_check_for_correct_channel(context): - context.channel.id = 42 - assert checks.in_channel_check(context, context.channel.id) - - -def test_in_channel_check_for_incorrect_channel(context): - context.channel.id = 42 - assert not checks.in_channel_check(context, context.channel.id + 10) -- cgit v1.2.3 From 70fb1315199d83f53d24b0772c940e66422d4cd4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 18:11:43 +0200 Subject: Add tests for tests.base I forgot to test some aspects of the `tests.base` module, including some branches of the `self.assertNotLogs` method. I've corrected that by including a couple of tests. I also removed the test result publishing from the Azure pipeline, since I've not configured an XML test runner yet. The coverage report is still published, of course and test output will be available in standard out, so information is readily available. --- azure-pipelines.yml | 7 ------- tests/base.py | 3 --- tests/test_base.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 31 insertions(+), 11 deletions(-) (limited to 'tests') diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3d0932398..15470f9be 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -43,13 +43,6 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: coverage.xml - - task: PublishTestResults@2 - displayName: 'Publish Test Results' - condition: succeededOrFailed() - inputs: - testResultsFiles: junit.xml - testRunTitle: 'Bot Test results' - - job: build displayName: 'Build & Push Container' dependsOn: 'test' diff --git a/tests/base.py b/tests/base.py index 625dcc0a8..029a249ed 100644 --- a/tests/base.py +++ b/tests/base.py @@ -12,9 +12,6 @@ class _CaptureLogHandler(logging.Handler): super().__init__() self.records = [] - def flush(self): - pass - def emit(self, record): self.records.append(record) diff --git a/tests/test_base.py b/tests/test_base.py index b7c1e0037..a16e2af8f 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -3,7 +3,7 @@ import unittest import unittest.mock -from tests.base import LoggingTestCase +from tests.base import LoggingTestCase, _CaptureLogHandler class LoggingTestCaseTests(unittest.TestCase): @@ -59,3 +59,33 @@ class LoggingTestCaseTests(unittest.TestCase): self.assertEqual(self.log.handlers, old_handlers) self.assertEqual(self.log.level, old_level) self.assertEqual(self.log.propagate, old_propagate) + + def test_logging_test_case_works_with_logger_instance(self): + """Test if the LoggingTestCase captures logging for provided logger.""" + log = logging.getLogger("new_logger") + with self.assertRaises(AssertionError): + with LoggingTestCase.assertNotLogs(self, logger=log): + log.info("Hello, this should raise an AssertionError") + + def test_logging_test_case_respects_alternative_logger(self): + """Test if LoggingTestCase only checks the provided logger.""" + log_one = logging.getLogger("log one") + log_two = logging.getLogger("log two") + with LoggingTestCase.assertNotLogs(self, logger=log_one): + log_two.info("Hello, this should not raise an AssertionError") + + def test_logging_test_case_respects_logging_level(self): + """Test if LoggingTestCase does not raise for a logging level lower than provided.""" + with LoggingTestCase.assertNotLogs(self, level=logging.CRITICAL): + self.log.info("Hello, this should raise an AssertionError") + + def test_capture_log_handler_default_initialization(self): + """Test if the _CaptureLogHandler is initialized properly.""" + handler = _CaptureLogHandler() + self.assertFalse(handler.records) + + def test_capture_log_handler_saves_record_on_emit(self): + """Test if the _CaptureLogHandler saves the log record when it's emitted.""" + handler = _CaptureLogHandler() + handler.emit("Log message") + self.assertIn("Log message", handler.records) -- 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') 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 a6e4f8572bdaaa918fc7dd61824f68b03e1f9cd7 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 11 Oct 2019 21:00:53 +0200 Subject: Implement test cases suggested by @MarkKoz. --- tests/utils/test_time.py | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'tests') diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 3d7423a1d..61dd55c4a 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -16,6 +16,17 @@ from tests.helpers import AsyncMock (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), (relativedelta(days=2, hours=2), 'days', 2, '2 days'), + + # Does not abort for unknown units, as the unit name is checked + # against the attribute of the relativedelta instance. + (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'), + + # Very high maximum units, but it only ever iterates over + # each value the relativedelta might have. + (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), + + # Negative maximum units. + (relativedelta(days=2, hours=2), 'hours', -1, 'less than a hour'), ) ) def test_humanize_delta( -- cgit v1.2.3 From d6bedd16eb8fc81b2f0a17992ba000ed27fd7d72 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 23:05:01 +0200 Subject: Make textual changes to testing guide I've made some textual changes to the testing guidelines defined in README.md. --- tests/README.md | 54 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 20 deletions(-) (limited to 'tests') diff --git a/tests/README.md b/tests/README.md index 471a00923..4ed32c29b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # 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'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. +Our bot is one of the most important tools we have for running our community. As we don't want that tool tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. 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)._ @@ -12,38 +12,45 @@ We are using the following modules and packages for our unit tests: - [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) (standard library) - [coverage.py](https://coverage.readthedocs.io/en/stable/) -To ensure the results you obtain on your personal machine are comparable to those in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: +To ensure the results you obtain on your personal machine are comparable to those generated in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts: -1. `pipenv run test` will run `unittest` with `coverage.py` -2. `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. +- `pipenv run test` will run `unittest` with `coverage.py` +- `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. -**Note:** If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. +If you want a coverage report, make sure to run the tests with `pipenv run test` *first*. ## Writing tests -Since our bot is a collaborative, community project, it's important to take a couple of things into when writing tests. This ensures that our test suite is consistent and that everyone understands the tests someone else has written. +Since consistency is an important consideration for collaborative projects, we have written some guidelines on writing tests for the bot. In addition to these guidelines, it's a good idea to look at the existing code base for examples (e.g., [`test_converters.py`](/tests/bot/test_converters.py)). ### File and directory structure -To organize our test suite, we have chosen to mirror the directory structure of [`bot`](/bot/) in the [`tests`](/tests/) subdirectory. This makes it easy to find the relevant tests by providing a natural grouping of files. More general files, such as [`helpers.py`](/tests/helpers.py) are located directly in the `tests` subdirectory. +To organize our test suite, we have chosen to mirror the directory structure of [`bot`](/bot/) in the [`tests`](/tests/) subdirectory. This makes it easy to find the relevant tests by providing a natural grouping of files. More general testing files, such as [`helpers.py`](/tests/helpers.py) are located directly in the `tests` subdirectory. All files containing tests should have a filename starting with `test_` to make sure `unittest` will discover them. This prefix is typically followed by the name of the file the tests are written for. If needed, a test file can contain multiple test classes, both to provide structure and to be able to provide different fixtures/set-up methods for different groups of tests. -### Writing individual and independent tests +### Writing independent tests -When writing individual test methods, it is important to make sure that each test tests its own *independent* unit. In general, this means that you don't write one large test method that tests every possible branch/part/condition relevant to your function, but rather write separate test methods for each one. The reason is that this will make sure that if one test fails, the rest of the tests will still run and we feedback on exactly which parts are and which are not working correctly. (However, the [DRY-principle](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) also applies to tests, see the `Using self.subTest for independent subtests` section.) +When writing unit tests, it's really important to make sure each test that you write runs independently from all of the other tests. This both means that the code you write for one test shouldn't influence the result of another test and that if one tests fails, the other tests should still run. + +The basis for this is that when you write a test method, it should really only test a single aspect of the thing you're testing. This often means that you do not write one large test that tests "everything" that can be tested for a function, but rather that you write multiple smaller tests that each test a specific branch/path/condition of the function under scrutiny. + +To make sure you're not repeating the same set-up steps in all these smaller tests, `unittest` provides fixtures that are executed before and after each test is run. In addition to test fixtures, it also provides special set-up and clean-up methods that are run before the first test in a test class or after the last test of that class has been run. For more information, see the documentation for [`unittest.TestCase`](https://docs.python.org/3/library/unittest.html#unittest.TestCase). #### Method names and docstrings -It's very important that the output of failing tests is easy to understand. That's why it is important to give your test methods a descriptive name that tells us what the failing test was testing. In addition, since **single-line** docstrings will be printed in the output along with the method name, it's also important to add a good single-line docstring that summarizes the purpose of the test to your test method. +As you can probably imagine, writing smaller, independent tests also means that the number of tests will be large. This means that it is incredibly import to use good method names to identify what each test is doing. A general guideline is that the name should capture the goal of your test: What is this test method trying to assert? + +In addition to good method names, it's also really important to write a good *single-line* docstring. The `unittest` module will print such a single-line docstring along with the method name in the output it gives when a test fails. This means that a good docstring that really captures the purpose of the test makes it much easier to quickly make sense of output. #### Using self.subTest for independent subtests -Since it's important to make sure all of our tests are independent from each other, you may be tempted to copy-paste your test methods to test a function against a number of different input and output parameters. Luckily, `unittest` provides a better a better of doing that: [`subtests`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests). +Another thing that you will probably encounter is that you want to test a function against a list of input and output values. Given the section on writing independent tests, you may now be tempted to copy-paste the same test method over and over again, once for each unique value that you want to test. However, that would result in a lot of duplicate code that is hard to maintain. + -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.) +Luckily, `unittest` provides a good alternative to that: the [`subTest`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests) context manager. It is often used in conjunction with a `for`-loop iterating of a collection of values you want to test your function against and it provides two important features. First, it will make sure that if an assertion statements fails on of the iterations, the other iterations are still run. The other important feature it provides is that it will distinguish your iterations from each other in the output. -An example (taken from [`test_converters.py`](/tests/bot/test_converters.py)): +This is an example of `TestCase.subTest` in action (taken from [`test_converters.py`](/tests/bot/test_converters.py)): ```py def test_tag_content_converter_for_valid(self): @@ -68,14 +75,19 @@ FAIL: test_tag_content_converter_for_valid (tests.bot.test_converters.ConverterT TagContentConverter should return correct values for valid input. ---------------------------------------------------------------------- -# Snipped to save vertical space +# ... ``` ## Mocking -Since we are testing independent "units" of code, we sometimes need to provide "fake" versions of objects generated by code external to the unit we are trying to test to make sure we're truly testing independently from other units of code. We call these "fake objects" mocks. We mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module to create these mock objects, but we also have a couple special mock types defined in [`helpers.py`](/tests/helpers.py). +As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or an external piece of code. -An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object and then assert if the `send` method of the mocked version was called with the right message content by the command function: + +However, the features that we are trying to test often depend on those objects generated by other pieces of code. It would be difficult to test a bot command, without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". + +To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). + +An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object. This makes sure we can then check (_assert_) if the `send` method of the mocked Context object was called with the correct message content (without having to send a real message to the Discord API!): ```py import asyncio @@ -102,13 +114,13 @@ class BotCogTests(unittest.TestCase): ### Mocking coroutines -By default, `unittest.mock.Mock` and `unittest.mock.MagicMock` can't mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. +By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. ### Special mocks for some `discord.py` types To quote Ned Batchelder, Mock objects are "automatic chameleons". This means that they will happily allow the access to any attribute or method and provide a mocked value in return. One downside to this is that if the code you are testing gets the name of the attribute wrong, your mock object will not complain and the test may still pass. -In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist by default on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. +In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. These special mocks are added when they are needed, so if you think it would be sensible to add another one, feel free to propose one in your PR. @@ -193,8 +205,10 @@ FAILED (errors=1) What's more, even if the spelling mistake would not have been there, the first test did not test if the `member_information` function formatted the `member.join` according to the output we actually want to see. +All in all, it's not only important to consider if all statements or branches were touched at least once with a test, but also if they are extensively tested in all situations that may happen in production. + ### Unit Testing vs Integration Testing -Another restriction of unit testing is that it tests in, well, units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we work under the implicit assumption of understanding and utilizing those external objects correctly. So, in addition to testing the parts separately, we also need to test if the parts integrate correctly into a single application. +Another restriction of unit testing is that it tests, well, in units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we are work under the implicit assumption that we fully understand those external parts and utilize it correctly. What if our mocked `Context` object works with a `send` method, but `discord.py` has changed it to a `send_message` method in a recent update? It could mean our tests are passing, but the code it's testing still doesn't work in production. -We currently have no automated integration tests or functional tests, so **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. +The answer to this is that we also need to make sure that the individual parts come together into a working application. In addition, we will also need to make sure that the application communicates correctly with external applications. Since we currently have no automated integration tests or functional tests, that means **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written. -- cgit v1.2.3 From 2d938c610f42b62de78a26a186e6ffb5ff6e624a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Oct 2019 23:16:28 +0200 Subject: Update README.md --- tests/README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) (limited to 'tests') diff --git a/tests/README.md b/tests/README.md index 4ed32c29b..6ab9bc93e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # Testing our Bot -Our bot is one of the most important tools we have for running our community. As we don't want that tool tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. 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 for running our community. As we don't want that tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. 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)._ @@ -31,7 +31,7 @@ All files containing tests should have a filename starting with `test_` to make ### Writing independent tests -When writing unit tests, it's really important to make sure each test that you write runs independently from all of the other tests. This both means that the code you write for one test shouldn't influence the result of another test and that if one tests fails, the other tests should still run. +When writing unit tests, it's really important to make sure that each test that you write runs independently from all of the other tests. This both means that the code you write for one test shouldn't influence the result of another test and that if one tests fails, the other tests should still run. The basis for this is that when you write a test method, it should really only test a single aspect of the thing you're testing. This often means that you do not write one large test that tests "everything" that can be tested for a function, but rather that you write multiple smaller tests that each test a specific branch/path/condition of the function under scrutiny. @@ -39,7 +39,7 @@ To make sure you're not repeating the same set-up steps in all these smaller tes #### Method names and docstrings -As you can probably imagine, writing smaller, independent tests also means that the number of tests will be large. This means that it is incredibly import to use good method names to identify what each test is doing. A general guideline is that the name should capture the goal of your test: What is this test method trying to assert? +As you can probably imagine, writing smaller, independent tests also results in a large number of tests. To make sure that it's easy to see which test does what, it is incredibly important to use good method names to identify what each test is doing. A general guideline is that the name should capture the goal of your test: What is this test method trying to assert? In addition to good method names, it's also really important to write a good *single-line* docstring. The `unittest` module will print such a single-line docstring along with the method name in the output it gives when a test fails. This means that a good docstring that really captures the purpose of the test makes it much easier to quickly make sense of output. @@ -47,8 +47,7 @@ In addition to good method names, it's also really important to write a good *si Another thing that you will probably encounter is that you want to test a function against a list of input and output values. Given the section on writing independent tests, you may now be tempted to copy-paste the same test method over and over again, once for each unique value that you want to test. However, that would result in a lot of duplicate code that is hard to maintain. - -Luckily, `unittest` provides a good alternative to that: the [`subTest`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests) context manager. It is often used in conjunction with a `for`-loop iterating of a collection of values you want to test your function against and it provides two important features. First, it will make sure that if an assertion statements fails on of the iterations, the other iterations are still run. The other important feature it provides is that it will distinguish your iterations from each other in the output. +Luckily, `unittest` provides a good alternative to that: the [`subTest`](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests) context manager. This method is often used in conjunction with a `for`-loop iterating of a collection of values that we want to test a function against and it provides two important features. First, it will make sure that if an assertion statements fails on one of the iterations, the other iterations are still run. The other important feature it provides is that it will distinguish the iterations from each other in the output. This is an example of `TestCase.subTest` in action (taken from [`test_converters.py`](/tests/bot/test_converters.py)): @@ -80,12 +79,12 @@ TagContentConverter should return correct values for valid input. ## Mocking -As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or an external piece of code. +As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or something external to it. -However, the features that we are trying to test often depend on those objects generated by other pieces of code. It would be difficult to test a bot command, without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". +However, the features that we are trying to test often depend on those objects generated by external pieces of code. It would be difficult to test a bot command without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". -To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). +To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we have also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object. This makes sure we can then check (_assert_) if the `send` method of the mocked Context object was called with the correct message content (without having to send a real message to the Discord API!): -- cgit v1.2.3 From 7625d2abf5ac358ccb79a140e0227d2a51aa06cf Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 00:05:28 +0200 Subject: Raise `ValueError` on negative `max_units`. --- bot/utils/time.py | 3 +++ tests/utils/test_time.py | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) (limited to 'tests') diff --git a/bot/utils/time.py b/bot/utils/time.py index 183eff986..2aea2c099 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -35,6 +35,9 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). """ + if max_units <= 0: + raise ValueError("max_units must be positive") + units = ( ("years", delta.years), ("months", delta.months), diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py index 61dd55c4a..4baa6395c 100644 --- a/tests/utils/test_time.py +++ b/tests/utils/test_time.py @@ -24,9 +24,6 @@ from tests.helpers import AsyncMock # Very high maximum units, but it only ever iterates over # each value the relativedelta might have. (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'), - - # Negative maximum units. - (relativedelta(days=2, hours=2), 'hours', -1, 'less than a hour'), ) ) def test_humanize_delta( @@ -38,6 +35,12 @@ def test_humanize_delta( assert time.humanize_delta(delta, precision, max_units) == expected +@pytest.mark.parametrize('max_units', (-1, 0)) +def test_humanize_delta_raises_for_invalid_max_units(max_units: int): + with pytest.raises(ValueError, match='max_units must be positive'): + time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) + + @pytest.mark.parametrize( ('stamp', 'expected'), ( -- cgit v1.2.3 From 8a83d68e370d479072846e669be7b73c242e1d96 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:39:56 +0200 Subject: Move the `sync` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/sync/__init__.py | 0 tests/cogs/sync/test_roles.py | 126 ++++++++++++++++++++++++++++++++++++++++++ tests/cogs/sync/test_users.py | 84 ++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/sync/__init__.py create mode 100644 tests/cogs/sync/test_roles.py create mode 100644 tests/cogs/sync/test_users.py (limited to 'tests') diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py new file mode 100644 index 000000000..27ae27639 --- /dev/null +++ b/tests/cogs/sync/test_roles.py @@ -0,0 +1,126 @@ +import unittest + +from bot.cogs.sync.syncers import Role, get_roles_for_sync + + +class GetRolesForSyncTests(unittest.TestCase): + """Tests constructing the roles to synchronize with the site.""" + + def test_get_roles_for_sync_empty_return_for_equal_roles(self): + """No roles should be synced when no diff is found.""" + api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), set(), set()) + ) + + def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(self): + """Roles to be synced are returned when non-ID attributes differ.""" + api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), guild_roles, set()) + ) + + def test_get_roles_only_returns_roles_that_require_update(self): + """Roles that require an update should be returned as the second tuple element.""" + api_roles = { + Role(id=41, name='old name', colour=33, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + guild_roles = { + Role(id=41, name='new name', colour=35, permissions=0x8, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, + set(), + ) + ) + + def test_get_roles_returns_new_roles_in_first_tuple_element(self): + """Newly created roles are returned as the first tuple element.""" + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=2) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, + set(), + set(), + ) + ) + + def test_get_roles_returns_roles_to_update_and_new_roles(self): + """Newly created and updated roles should be returned together.""" + api_roles = { + Role(id=41, name='old name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='new name', colour=40, permissions=0x16, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, + {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, + set(), + ) + ) + + def test_get_roles_returns_roles_to_delete(self): + """Roles to be deleted should be returned as the third tuple element.""" + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + set(), + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + ) + + def test_get_roles_returns_roles_to_delete_update_and_new_roles(self): + """When roles were added, updated, and removed, all of them are returned properly.""" + api_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + Role(id=71, name='to update', colour=99, permissions=0x9, position=3), + } + guild_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=81, name='to create', colour=99, permissions=0x9, position=4), + Role(id=71, name='updated', colour=101, permissions=0x5, position=3), + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, + {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py new file mode 100644 index 000000000..ccaf67490 --- /dev/null +++ b/tests/cogs/sync/test_users.py @@ -0,0 +1,84 @@ +import unittest + +from bot.cogs.sync.syncers import User, get_users_for_sync + + +def fake_user(**kwargs): + kwargs.setdefault('id', 43) + kwargs.setdefault('name', 'bob the test man') + kwargs.setdefault('discriminator', 1337) + kwargs.setdefault('avatar_hash', None) + kwargs.setdefault('roles', (666,)) + kwargs.setdefault('in_guild', True) + return User(**kwargs) + + +class GetUsersForSyncTests(unittest.TestCase): + """Tests constructing the users to synchronize with the site.""" + + def test_get_users_for_sync_returns_nothing_for_empty_params(self): + """When no users are given, none are returned.""" + self.assertEqual( + get_users_for_sync({}, {}), + (set(), set()) + ) + + def test_get_users_for_sync_returns_nothing_for_equal_users(self): + """When no users are updated, none are returned.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), set()) + ) + + def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(self): + """When a non-ID-field differs, the user to update is returned.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user(name='new fancy name')} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(name='new fancy name')}) + ) + + def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(self): + """When new users join the guild, they are returned as the first tuple element.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user(), 63: fake_user(id=63)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, set()) + ) + + def test_get_users_for_sync_updates_in_guild_field_on_user_leave(self): + """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" + api_users = {43: fake_user(), 63: fake_user(id=63)} + guild_users = {43: fake_user()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(id=63, in_guild=False)}) + ) + + def test_get_users_for_sync_updates_and_creates_users_as_needed(self): + """When one user left and another one was updated, both are returned.""" + api_users = {43: fake_user()} + guild_users = {63: fake_user(id=63)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, {fake_user(in_guild=False)}) + ) + + def test_get_users_for_sync_does_not_duplicate_update_users(self): + """When the API knows a user the guild doesn't, nothing is performed.""" + api_users = {43: fake_user(in_guild=False)} + guild_users = {} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), set()) + ) -- cgit v1.2.3 From 42abcc5b3c3cc2846a7be8b0e2f5549d820e196e Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:44:16 +0200 Subject: Move `tests.test_resources` to `unittest`. --- tests/test_resources.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/test_resources.py (limited to 'tests') diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 000000000..2fc36c697 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,16 @@ +import json +import unittest +from pathlib import Path + + +class ResourceValidationTests(unittest.TestCase): + """Validates resources used by the bot.""" + def test_stars_valid(self): + """The resource `bot/resources/stars.json` should contain a list of strings.""" + path = Path('bot', 'resources', 'stars.json') + content = path.read_text() + data = json.loads(content) + + self.assertIsInstance(data, list) + for name in data: + self.assertIsInstance(name, str) -- cgit v1.2.3 From e1c4f0819ba94e88baea4f0de4ff7edb9b9cf2ca Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:48:18 +0200 Subject: Move `tests.test_pagination` to `unittest`. --- tests/test_pagination.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_pagination.py (limited to 'tests') diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 000000000..0a734b505 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,36 @@ +from unittest import TestCase + +from bot import pagination + + +class LinePaginatorTests(TestCase): + """Tests functionality of the `LinePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) + + def test_add_line_raises_on_too_long_lines(self): + """`add_line` should raise a `RuntimeError` for too long lines.""" + message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" + with self.assertRaises(RuntimeError, msg=message): + self.paginator.add_line('x' * self.paginator.max_size) + + def test_add_line_works_on_small_lines(self): + """`add_line` should allow small lines to be added.""" + self.paginator.add_line('x' * (self.paginator.max_size - 3)) + + +class ImagePaginatorTests(TestCase): + """Tests functionality of the `ImagePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.ImagePaginator() + + def test_add_image_appends_image(self): + """`add_image` appends the image to the image list.""" + image = 'lemon' + self.paginator.add_image(image) + + assert self.paginator.images == [image] -- cgit v1.2.3 From 4f225508b7c1c0c5ea02f9788c9495e7edf4414c Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 12:54:32 +0200 Subject: Move the `rules.attachments` module tests to `unittest`. --- tests/rules/__init__.py | 0 tests/rules/test_attachments.py | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/rules/__init__.py create mode 100644 tests/rules/test_attachments.py (limited to 'tests') diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py new file mode 100644 index 000000000..4bb0acf7c --- /dev/null +++ b/tests/rules/test_attachments.py @@ -0,0 +1,52 @@ +import asyncio +import unittest +from dataclasses import dataclass +from typing import Any, List + +from bot.rules import attachments + + +# Using `MagicMock` sadly doesn't work for this usecase +# since it's __eq__ compares the MagicMock's ID. We just +# want to compare the actual attributes we set. +@dataclass +class FakeMessage: + author: str + attachments: List[Any] + + +def msg(total_attachments: int) -> FakeMessage: + return FakeMessage(author='lemon', attachments=list(range(total_attachments))) + + +class AttachmentRuleTests(unittest.TestCase): + """Tests applying the `attachment` antispam rule.""" + + def test_allows_messages_without_too_many_attachments(self): + """Messages without too many attachments are allowed as-is.""" + cases = ( + (msg(0), msg(0), msg(0)), + (msg(2), msg(2)), + (msg(0),), + ) + + for last_message, *recent_messages in cases: + with self.subTest(last_message=last_message, recent_messages=recent_messages): + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertIsNone(asyncio.run(coro)) + + def test_disallows_messages_with_too_many_attachments(self): + """Messages with too many attachments trigger the rule.""" + cases = ( + ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), + ((msg(6),), [msg(6)], 6), + ((msg(1),) * 6, [msg(1)] * 6, 6), + ) + for messages, relevant_messages, total in cases: + with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertEqual( + asyncio.run(coro), + (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) + ) -- cgit v1.2.3 From 6d3af7ccbc8a0ff0d9657c562707281a3a7c2ee2 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 14:16:20 +0200 Subject: Move the `antispam` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/test_antispam.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/test_antispam.py (limited to 'tests') diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py new file mode 100644 index 000000000..ce5472c71 --- /dev/null +++ b/tests/cogs/test_antispam.py @@ -0,0 +1,35 @@ +import unittest + +from bot.cogs import antispam + + +class AntispamConfigurationValidationTests(unittest.TestCase): + """Tests validation of the antispam cog configuration.""" + + def test_default_antispam_config_is_valid(self): + """The default antispam configuration is valid.""" + validation_errors = antispam.validate_config() + self.assertEqual(validation_errors, {}) + + def test_unknown_rule_returns_error(self): + """Configuring an unknown rule returns an error.""" + self.assertEqual( + antispam.validate_config({'invalid-rule': {}}), + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ) + + def test_missing_keys_returns_error(self): + """Not configuring required keys returns an error.""" + keys = (('interval', 'max'), ('max', 'interval')) + for configured_key, unconfigured_key in keys: + with self.subTest( + configured_key=configured_key, + unconfigured_key=unconfigured_key + ): + config = {'burst': {configured_key: 10}} + error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" + + self.assertEqual( + antispam.validate_config(config), + {'burst': error} + ) -- cgit v1.2.3 From 63fad8b9a23d83539d8d17fc711883215f932db5 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 14:26:41 +0200 Subject: Move the `security` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/test_security.py | 59 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/test_security.py (limited to 'tests') diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py new file mode 100644 index 000000000..6c646ae70 --- /dev/null +++ b/tests/cogs/test_security.py @@ -0,0 +1,59 @@ +import logging +import unittest +from unittest.mock import MagicMock + +from discord.ext.commands import NoPrivateMessage + +from bot.cogs import security + + +class SecurityCogTests(unittest.TestCase): + """Tests the `Security` cog.""" + + def setUp(self): + """Attach an instance of the cog to the class for tests.""" + self.bot = MagicMock() + self.cog = security.Security(self.bot) + self.ctx = MagicMock() + self.ctx.author = MagicMock() + + def test_check_additions(self): + """The cog should add its checks after initialization.""" + self.bot.check.assert_any_call(self.cog.check_on_guild) + self.bot.check.assert_any_call(self.cog.check_not_bot) + + def test_check_not_bot_returns_false_for_humans(self): + """The bot check should return `True` when invoked with human authors.""" + self.ctx.author.bot = False + self.assertTrue(self.cog.check_not_bot(self.ctx)) + + def test_check_not_bot_returns_true_for_robots(self): + """The bot check should return `False` when invoked with robotic authors.""" + self.ctx.author.bot = True + self.assertFalse(self.cog.check_not_bot(self.ctx)) + + def test_check_on_guild_raises_when_outside_of_guild(self): + """When invoked outside of a guild, `check_on_guild` should cause an error.""" + self.ctx.guild = None + + with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): + self.cog.check_on_guild(self.ctx) + + def test_check_on_guild_returns_true_inside_of_guild(self): + """When invoked inside of a guild, `check_on_guild` should return `True`.""" + self.ctx.guild = "lemon's lemonade stand" + self.assertTrue(self.cog.check_on_guild(self.ctx)) + + +class SecurityCogLoadTests(unittest.TestCase): + """Tests loading the `Security` cog.""" + + def test_security_cog_load(self): + """Cog loading logs a message at `INFO` level.""" + bot = MagicMock() + with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm: + security.setup(bot) + bot.add_cog.assert_called_once() + + [line] = cm.output + self.assertIn("Cog loaded: Security", line) -- cgit v1.2.3 From 776861636dbd180b8ad0bcc9540d935afaf2b873 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 11:36:06 +0200 Subject: Add subTest + move test_resource to resources subdir I've added a `self.subTest` to the `name` loop so we still test and get output for all names in the list if one of them fails the test. In addition, I've moved it to the `tests/bot/resources` subdirectory. --- tests/bot/resources/test_resources.py | 17 +++++++++++++++++ tests/test_resources.py | 16 ---------------- 2 files changed, 17 insertions(+), 16 deletions(-) create mode 100644 tests/bot/resources/test_resources.py delete mode 100644 tests/test_resources.py (limited to 'tests') diff --git a/tests/bot/resources/test_resources.py b/tests/bot/resources/test_resources.py new file mode 100644 index 000000000..73937cfa6 --- /dev/null +++ b/tests/bot/resources/test_resources.py @@ -0,0 +1,17 @@ +import json +import unittest +from pathlib import Path + + +class ResourceValidationTests(unittest.TestCase): + """Validates resources used by the bot.""" + def test_stars_valid(self): + """The resource `bot/resources/stars.json` should contain a list of strings.""" + path = Path('bot', 'resources', 'stars.json') + content = path.read_text() + data = json.loads(content) + + self.assertIsInstance(data, list) + for name in data: + with self.subTest(name=name): + self.assertIsInstance(name, str) diff --git a/tests/test_resources.py b/tests/test_resources.py deleted file mode 100644 index 2fc36c697..000000000 --- a/tests/test_resources.py +++ /dev/null @@ -1,16 +0,0 @@ -import json -import unittest -from pathlib import Path - - -class ResourceValidationTests(unittest.TestCase): - """Validates resources used by the bot.""" - def test_stars_valid(self): - """The resource `bot/resources/stars.json` should contain a list of strings.""" - path = Path('bot', 'resources', 'stars.json') - content = path.read_text() - data = json.loads(content) - - self.assertIsInstance(data, list) - for name in data: - self.assertIsInstance(name, str) -- cgit v1.2.3 From 562ede819308929900e3e0c6a41ae61ca32abab6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 11:56:36 +0200 Subject: Move test_attachments.py to tests/bot/rules dir --- tests/bot/rules/test_attachments.py | 52 +++++++++++++++++++++++++++++++++++++ tests/rules/__init__.py | 0 tests/rules/test_attachments.py | 52 ------------------------------------- 3 files changed, 52 insertions(+), 52 deletions(-) create mode 100644 tests/bot/rules/test_attachments.py delete mode 100644 tests/rules/__init__.py delete mode 100644 tests/rules/test_attachments.py (limited to 'tests') diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py new file mode 100644 index 000000000..4bb0acf7c --- /dev/null +++ b/tests/bot/rules/test_attachments.py @@ -0,0 +1,52 @@ +import asyncio +import unittest +from dataclasses import dataclass +from typing import Any, List + +from bot.rules import attachments + + +# Using `MagicMock` sadly doesn't work for this usecase +# since it's __eq__ compares the MagicMock's ID. We just +# want to compare the actual attributes we set. +@dataclass +class FakeMessage: + author: str + attachments: List[Any] + + +def msg(total_attachments: int) -> FakeMessage: + return FakeMessage(author='lemon', attachments=list(range(total_attachments))) + + +class AttachmentRuleTests(unittest.TestCase): + """Tests applying the `attachment` antispam rule.""" + + def test_allows_messages_without_too_many_attachments(self): + """Messages without too many attachments are allowed as-is.""" + cases = ( + (msg(0), msg(0), msg(0)), + (msg(2), msg(2)), + (msg(0),), + ) + + for last_message, *recent_messages in cases: + with self.subTest(last_message=last_message, recent_messages=recent_messages): + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertIsNone(asyncio.run(coro)) + + def test_disallows_messages_with_too_many_attachments(self): + """Messages with too many attachments trigger the rule.""" + cases = ( + ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), + ((msg(6),), [msg(6)], 6), + ((msg(1),) * 6, [msg(1)] * 6, 6), + ) + for messages, relevant_messages, total in cases: + with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): + last_message, *recent_messages = messages + coro = attachments.apply(last_message, recent_messages, {'max': 5}) + self.assertEqual( + asyncio.run(coro), + (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) + ) diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py deleted file mode 100644 index 4bb0acf7c..000000000 --- a/tests/rules/test_attachments.py +++ /dev/null @@ -1,52 +0,0 @@ -import asyncio -import unittest -from dataclasses import dataclass -from typing import Any, List - -from bot.rules import attachments - - -# Using `MagicMock` sadly doesn't work for this usecase -# since it's __eq__ compares the MagicMock's ID. We just -# want to compare the actual attributes we set. -@dataclass -class FakeMessage: - author: str - attachments: List[Any] - - -def msg(total_attachments: int) -> FakeMessage: - return FakeMessage(author='lemon', attachments=list(range(total_attachments))) - - -class AttachmentRuleTests(unittest.TestCase): - """Tests applying the `attachment` antispam rule.""" - - def test_allows_messages_without_too_many_attachments(self): - """Messages without too many attachments are allowed as-is.""" - cases = ( - (msg(0), msg(0), msg(0)), - (msg(2), msg(2)), - (msg(0),), - ) - - for last_message, *recent_messages in cases: - with self.subTest(last_message=last_message, recent_messages=recent_messages): - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - self.assertIsNone(asyncio.run(coro)) - - def test_disallows_messages_with_too_many_attachments(self): - """Messages with too many attachments trigger the rule.""" - cases = ( - ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10), - ((msg(6),), [msg(6)], 6), - ((msg(1),) * 6, [msg(1)] * 6, 6), - ) - for messages, relevant_messages, total in cases: - with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total): - last_message, *recent_messages = messages - coro = attachments.apply(last_message, recent_messages, {'max': 5}) - self.assertEqual( - asyncio.run(coro), - (f"sent {total} attachments in 5s", ('lemon',), relevant_messages) - ) -- cgit v1.2.3 From 7bd0be0c7dfd0d75ccaa639ccc124fffd9ef785a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 12:11:13 +0200 Subject: Move test_antispam.py to tests.bot.cogs --- tests/bot/cogs/test_antispam.py | 35 +++++++++++++++++++++++++++++++++++ tests/cogs/__init__.py | 0 tests/cogs/test_antispam.py | 35 ----------------------------------- 3 files changed, 35 insertions(+), 35 deletions(-) create mode 100644 tests/bot/cogs/test_antispam.py delete mode 100644 tests/cogs/__init__.py delete mode 100644 tests/cogs/test_antispam.py (limited to 'tests') diff --git a/tests/bot/cogs/test_antispam.py b/tests/bot/cogs/test_antispam.py new file mode 100644 index 000000000..ce5472c71 --- /dev/null +++ b/tests/bot/cogs/test_antispam.py @@ -0,0 +1,35 @@ +import unittest + +from bot.cogs import antispam + + +class AntispamConfigurationValidationTests(unittest.TestCase): + """Tests validation of the antispam cog configuration.""" + + def test_default_antispam_config_is_valid(self): + """The default antispam configuration is valid.""" + validation_errors = antispam.validate_config() + self.assertEqual(validation_errors, {}) + + def test_unknown_rule_returns_error(self): + """Configuring an unknown rule returns an error.""" + self.assertEqual( + antispam.validate_config({'invalid-rule': {}}), + {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} + ) + + def test_missing_keys_returns_error(self): + """Not configuring required keys returns an error.""" + keys = (('interval', 'max'), ('max', 'interval')) + for configured_key, unconfigured_key in keys: + with self.subTest( + configured_key=configured_key, + unconfigured_key=unconfigured_key + ): + config = {'burst': {configured_key: 10}} + error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" + + self.assertEqual( + antispam.validate_config(config), + {'burst': error} + ) diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py deleted file mode 100644 index ce5472c71..000000000 --- a/tests/cogs/test_antispam.py +++ /dev/null @@ -1,35 +0,0 @@ -import unittest - -from bot.cogs import antispam - - -class AntispamConfigurationValidationTests(unittest.TestCase): - """Tests validation of the antispam cog configuration.""" - - def test_default_antispam_config_is_valid(self): - """The default antispam configuration is valid.""" - validation_errors = antispam.validate_config() - self.assertEqual(validation_errors, {}) - - def test_unknown_rule_returns_error(self): - """Configuring an unknown rule returns an error.""" - self.assertEqual( - antispam.validate_config({'invalid-rule': {}}), - {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} - ) - - def test_missing_keys_returns_error(self): - """Not configuring required keys returns an error.""" - keys = (('interval', 'max'), ('max', 'interval')) - for configured_key, unconfigured_key in keys: - with self.subTest( - configured_key=configured_key, - unconfigured_key=unconfigured_key - ): - config = {'burst': {configured_key: 10}} - error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" - - self.assertEqual( - antispam.validate_config(config), - {'burst': error} - ) -- cgit v1.2.3 From 5032521fa8a4be9738c52b16e6bd82322cdee337 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 13 Oct 2019 12:56:35 +0200 Subject: Move sync tests to tests.bot.cogs.sync --- tests/bot/cogs/sync/__init__.py | 0 tests/bot/cogs/sync/test_roles.py | 126 ++++++++++++++++++++++++++++++++++++++ tests/bot/cogs/sync/test_users.py | 84 +++++++++++++++++++++++++ tests/cogs/sync/__init__.py | 0 tests/cogs/sync/test_roles.py | 126 -------------------------------------- tests/cogs/sync/test_users.py | 84 ------------------------- 6 files changed, 210 insertions(+), 210 deletions(-) create mode 100644 tests/bot/cogs/sync/__init__.py create mode 100644 tests/bot/cogs/sync/test_roles.py create mode 100644 tests/bot/cogs/sync/test_users.py delete mode 100644 tests/cogs/sync/__init__.py delete mode 100644 tests/cogs/sync/test_roles.py delete mode 100644 tests/cogs/sync/test_users.py (limited to 'tests') diff --git a/tests/bot/cogs/sync/__init__.py b/tests/bot/cogs/sync/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/cogs/sync/test_roles.py new file mode 100644 index 000000000..27ae27639 --- /dev/null +++ b/tests/bot/cogs/sync/test_roles.py @@ -0,0 +1,126 @@ +import unittest + +from bot.cogs.sync.syncers import Role, get_roles_for_sync + + +class GetRolesForSyncTests(unittest.TestCase): + """Tests constructing the roles to synchronize with the site.""" + + def test_get_roles_for_sync_empty_return_for_equal_roles(self): + """No roles should be synced when no diff is found.""" + api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), set(), set()) + ) + + def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(self): + """Roles to be synced are returned when non-ID attributes differ.""" + api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} + guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + (set(), guild_roles, set()) + ) + + def test_get_roles_only_returns_roles_that_require_update(self): + """Roles that require an update should be returned as the second tuple element.""" + api_roles = { + Role(id=41, name='old name', colour=33, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + guild_roles = { + Role(id=41, name='new name', colour=35, permissions=0x8, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, + set(), + ) + ) + + def test_get_roles_returns_new_roles_in_first_tuple_element(self): + """Newly created roles are returned as the first tuple element.""" + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=53, name='other role', colour=55, permissions=0, position=2) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, + set(), + set(), + ) + ) + + def test_get_roles_returns_roles_to_update_and_new_roles(self): + """Newly created and updated roles should be returned together.""" + api_roles = { + Role(id=41, name='old name', colour=35, permissions=0x8, position=1), + } + guild_roles = { + Role(id=41, name='new name', colour=40, permissions=0x16, position=2), + Role(id=53, name='other role', colour=55, permissions=0, position=3) + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, + {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, + set(), + ) + ) + + def test_get_roles_returns_roles_to_delete(self): + """Roles to be deleted should be returned as the third tuple element.""" + api_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + } + guild_roles = { + Role(id=41, name='name', colour=35, permissions=0x8, position=1), + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + set(), + set(), + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + ) + + def test_get_roles_returns_roles_to_delete_update_and_new_roles(self): + """When roles were added, updated, and removed, all of them are returned properly.""" + api_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), + Role(id=71, name='to update', colour=99, permissions=0x9, position=3), + } + guild_roles = { + Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), + Role(id=81, name='to create', colour=99, permissions=0x9, position=4), + Role(id=71, name='updated', colour=101, permissions=0x5, position=3), + } + + self.assertEqual( + get_roles_for_sync(guild_roles, api_roles), + ( + {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, + {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, + {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, + ) + ) diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/cogs/sync/test_users.py new file mode 100644 index 000000000..ccaf67490 --- /dev/null +++ b/tests/bot/cogs/sync/test_users.py @@ -0,0 +1,84 @@ +import unittest + +from bot.cogs.sync.syncers import User, get_users_for_sync + + +def fake_user(**kwargs): + kwargs.setdefault('id', 43) + kwargs.setdefault('name', 'bob the test man') + kwargs.setdefault('discriminator', 1337) + kwargs.setdefault('avatar_hash', None) + kwargs.setdefault('roles', (666,)) + kwargs.setdefault('in_guild', True) + return User(**kwargs) + + +class GetUsersForSyncTests(unittest.TestCase): + """Tests constructing the users to synchronize with the site.""" + + def test_get_users_for_sync_returns_nothing_for_empty_params(self): + """When no users are given, none are returned.""" + self.assertEqual( + get_users_for_sync({}, {}), + (set(), set()) + ) + + def test_get_users_for_sync_returns_nothing_for_equal_users(self): + """When no users are updated, none are returned.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), set()) + ) + + def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(self): + """When a non-ID-field differs, the user to update is returned.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user(name='new fancy name')} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(name='new fancy name')}) + ) + + def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(self): + """When new users join the guild, they are returned as the first tuple element.""" + api_users = {43: fake_user()} + guild_users = {43: fake_user(), 63: fake_user(id=63)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, set()) + ) + + def test_get_users_for_sync_updates_in_guild_field_on_user_leave(self): + """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" + api_users = {43: fake_user(), 63: fake_user(id=63)} + guild_users = {43: fake_user()} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), {fake_user(id=63, in_guild=False)}) + ) + + def test_get_users_for_sync_updates_and_creates_users_as_needed(self): + """When one user left and another one was updated, both are returned.""" + api_users = {43: fake_user()} + guild_users = {63: fake_user(id=63)} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + ({fake_user(id=63)}, {fake_user(in_guild=False)}) + ) + + def test_get_users_for_sync_does_not_duplicate_update_users(self): + """When the API knows a user the guild doesn't, nothing is performed.""" + api_users = {43: fake_user(in_guild=False)} + guild_users = {} + + self.assertEqual( + get_users_for_sync(guild_users, api_users), + (set(), set()) + ) diff --git a/tests/cogs/sync/__init__.py b/tests/cogs/sync/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py deleted file mode 100644 index 27ae27639..000000000 --- a/tests/cogs/sync/test_roles.py +++ /dev/null @@ -1,126 +0,0 @@ -import unittest - -from bot.cogs.sync.syncers import Role, get_roles_for_sync - - -class GetRolesForSyncTests(unittest.TestCase): - """Tests constructing the roles to synchronize with the site.""" - - def test_get_roles_for_sync_empty_return_for_equal_roles(self): - """No roles should be synced when no diff is found.""" - api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} - guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)} - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - (set(), set(), set()) - ) - - def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff(self): - """Roles to be synced are returned when non-ID attributes differ.""" - api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)} - guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)} - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - (set(), guild_roles, set()) - ) - - def test_get_roles_only_returns_roles_that_require_update(self): - """Roles that require an update should be returned as the second tuple element.""" - api_roles = { - Role(id=41, name='old name', colour=33, permissions=0x8, position=1), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - guild_roles = { - Role(id=41, name='new name', colour=35, permissions=0x8, position=2), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - set(), - {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)}, - set(), - ) - ) - - def test_get_roles_returns_new_roles_in_first_tuple_element(self): - """Newly created roles are returned as the first tuple element.""" - api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - } - guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - Role(id=53, name='other role', colour=55, permissions=0, position=2) - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - {Role(id=53, name='other role', colour=55, permissions=0, position=2)}, - set(), - set(), - ) - ) - - def test_get_roles_returns_roles_to_update_and_new_roles(self): - """Newly created and updated roles should be returned together.""" - api_roles = { - Role(id=41, name='old name', colour=35, permissions=0x8, position=1), - } - guild_roles = { - Role(id=41, name='new name', colour=40, permissions=0x16, position=2), - Role(id=53, name='other role', colour=55, permissions=0, position=3) - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - {Role(id=53, name='other role', colour=55, permissions=0, position=3)}, - {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)}, - set(), - ) - ) - - def test_get_roles_returns_roles_to_delete(self): - """Roles to be deleted should be returned as the third tuple element.""" - api_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), - } - guild_roles = { - Role(id=41, name='name', colour=35, permissions=0x8, position=1), - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - set(), - set(), - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) - ) - - def test_get_roles_returns_roles_to_delete_update_and_new_roles(self): - """When roles were added, updated, and removed, all of them are returned properly.""" - api_roles = { - Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), - Role(id=61, name='to delete', colour=99, permissions=0x9, position=2), - Role(id=71, name='to update', colour=99, permissions=0x9, position=3), - } - guild_roles = { - Role(id=41, name='not changed', colour=35, permissions=0x8, position=1), - Role(id=81, name='to create', colour=99, permissions=0x9, position=4), - Role(id=71, name='updated', colour=101, permissions=0x5, position=3), - } - - self.assertEqual( - get_roles_for_sync(guild_roles, api_roles), - ( - {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)}, - {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)}, - {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)}, - ) - ) diff --git a/tests/cogs/sync/test_users.py b/tests/cogs/sync/test_users.py deleted file mode 100644 index ccaf67490..000000000 --- a/tests/cogs/sync/test_users.py +++ /dev/null @@ -1,84 +0,0 @@ -import unittest - -from bot.cogs.sync.syncers import User, get_users_for_sync - - -def fake_user(**kwargs): - kwargs.setdefault('id', 43) - kwargs.setdefault('name', 'bob the test man') - kwargs.setdefault('discriminator', 1337) - kwargs.setdefault('avatar_hash', None) - kwargs.setdefault('roles', (666,)) - kwargs.setdefault('in_guild', True) - return User(**kwargs) - - -class GetUsersForSyncTests(unittest.TestCase): - """Tests constructing the users to synchronize with the site.""" - - def test_get_users_for_sync_returns_nothing_for_empty_params(self): - """When no users are given, none are returned.""" - self.assertEqual( - get_users_for_sync({}, {}), - (set(), set()) - ) - - def test_get_users_for_sync_returns_nothing_for_equal_users(self): - """When no users are updated, none are returned.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user()} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), set()) - ) - - def test_get_users_for_sync_returns_users_to_update_on_non_id_field_diff(self): - """When a non-ID-field differs, the user to update is returned.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user(name='new fancy name')} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), {fake_user(name='new fancy name')}) - ) - - def test_get_users_for_sync_returns_users_to_create_with_new_ids_on_guild(self): - """When new users join the guild, they are returned as the first tuple element.""" - api_users = {43: fake_user()} - guild_users = {43: fake_user(), 63: fake_user(id=63)} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - ({fake_user(id=63)}, set()) - ) - - def test_get_users_for_sync_updates_in_guild_field_on_user_leave(self): - """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" - api_users = {43: fake_user(), 63: fake_user(id=63)} - guild_users = {43: fake_user()} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), {fake_user(id=63, in_guild=False)}) - ) - - def test_get_users_for_sync_updates_and_creates_users_as_needed(self): - """When one user left and another one was updated, both are returned.""" - api_users = {43: fake_user()} - guild_users = {63: fake_user(id=63)} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - ({fake_user(id=63)}, {fake_user(in_guild=False)}) - ) - - def test_get_users_for_sync_does_not_duplicate_update_users(self): - """When the API knows a user the guild doesn't, nothing is performed.""" - api_users = {43: fake_user(in_guild=False)} - guild_users = {} - - self.assertEqual( - get_users_for_sync(guild_users, api_users), - (set(), set()) - ) -- cgit v1.2.3 From 612994ae248e614a9f1712337e0eb7942e0c5f32 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 13 Oct 2019 17:15:32 +0200 Subject: Use `MockBot` and `MockContext`. --- tests/cogs/test_security.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'tests') diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py index 6c646ae70..efa7a50b1 100644 --- a/tests/cogs/test_security.py +++ b/tests/cogs/test_security.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock from discord.ext.commands import NoPrivateMessage from bot.cogs import security +from tests.helpers import MockBot, MockContext class SecurityCogTests(unittest.TestCase): @@ -12,10 +13,9 @@ class SecurityCogTests(unittest.TestCase): def setUp(self): """Attach an instance of the cog to the class for tests.""" - self.bot = MagicMock() + self.bot = MockBot() self.cog = security.Security(self.bot) - self.ctx = MagicMock() - self.ctx.author = MagicMock() + self.ctx = MockContext() def test_check_additions(self): """The cog should add its checks after initialization.""" -- cgit v1.2.3 From 2e18b4164c70e8f96750667bfd8e0e14f4a65cff Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 13:34:47 +0200 Subject: Move test_pagination to tests.bot subdir --- tests/bot/test_pagination.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_pagination.py | 36 ------------------------------------ 2 files changed, 36 insertions(+), 36 deletions(-) create mode 100644 tests/bot/test_pagination.py delete mode 100644 tests/test_pagination.py (limited to 'tests') diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py new file mode 100644 index 000000000..0a734b505 --- /dev/null +++ b/tests/bot/test_pagination.py @@ -0,0 +1,36 @@ +from unittest import TestCase + +from bot import pagination + + +class LinePaginatorTests(TestCase): + """Tests functionality of the `LinePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) + + def test_add_line_raises_on_too_long_lines(self): + """`add_line` should raise a `RuntimeError` for too long lines.""" + message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" + with self.assertRaises(RuntimeError, msg=message): + self.paginator.add_line('x' * self.paginator.max_size) + + def test_add_line_works_on_small_lines(self): + """`add_line` should allow small lines to be added.""" + self.paginator.add_line('x' * (self.paginator.max_size - 3)) + + +class ImagePaginatorTests(TestCase): + """Tests functionality of the `ImagePaginator`.""" + + def setUp(self): + """Create a paginator for the test method.""" + self.paginator = pagination.ImagePaginator() + + def test_add_image_appends_image(self): + """`add_image` appends the image to the image list.""" + image = 'lemon' + self.paginator.add_image(image) + + assert self.paginator.images == [image] diff --git a/tests/test_pagination.py b/tests/test_pagination.py deleted file mode 100644 index 0a734b505..000000000 --- a/tests/test_pagination.py +++ /dev/null @@ -1,36 +0,0 @@ -from unittest import TestCase - -from bot import pagination - - -class LinePaginatorTests(TestCase): - """Tests functionality of the `LinePaginator`.""" - - def setUp(self): - """Create a paginator for the test method.""" - self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) - - def test_add_line_raises_on_too_long_lines(self): - """`add_line` should raise a `RuntimeError` for too long lines.""" - message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" - with self.assertRaises(RuntimeError, msg=message): - self.paginator.add_line('x' * self.paginator.max_size) - - def test_add_line_works_on_small_lines(self): - """`add_line` should allow small lines to be added.""" - self.paginator.add_line('x' * (self.paginator.max_size - 3)) - - -class ImagePaginatorTests(TestCase): - """Tests functionality of the `ImagePaginator`.""" - - def setUp(self): - """Create a paginator for the test method.""" - self.paginator = pagination.ImagePaginator() - - def test_add_image_appends_image(self): - """`add_image` appends the image to the image list.""" - image = 'lemon' - self.paginator.add_image(image) - - assert self.paginator.images == [image] -- cgit v1.2.3 From 0dabafc3fba58c5ffc74207d32b6654f9b219379 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 13:45:43 +0200 Subject: Move test_security to tests.bot.cogs --- tests/bot/cogs/test_security.py | 59 +++++++++++++++++++++++++++++++++++++++++ tests/cogs/__init__.py | 0 tests/cogs/test_security.py | 59 ----------------------------------------- 3 files changed, 59 insertions(+), 59 deletions(-) create mode 100644 tests/bot/cogs/test_security.py delete mode 100644 tests/cogs/__init__.py delete mode 100644 tests/cogs/test_security.py (limited to 'tests') diff --git a/tests/bot/cogs/test_security.py b/tests/bot/cogs/test_security.py new file mode 100644 index 000000000..efa7a50b1 --- /dev/null +++ b/tests/bot/cogs/test_security.py @@ -0,0 +1,59 @@ +import logging +import unittest +from unittest.mock import MagicMock + +from discord.ext.commands import NoPrivateMessage + +from bot.cogs import security +from tests.helpers import MockBot, MockContext + + +class SecurityCogTests(unittest.TestCase): + """Tests the `Security` cog.""" + + def setUp(self): + """Attach an instance of the cog to the class for tests.""" + self.bot = MockBot() + self.cog = security.Security(self.bot) + self.ctx = MockContext() + + def test_check_additions(self): + """The cog should add its checks after initialization.""" + self.bot.check.assert_any_call(self.cog.check_on_guild) + self.bot.check.assert_any_call(self.cog.check_not_bot) + + def test_check_not_bot_returns_false_for_humans(self): + """The bot check should return `True` when invoked with human authors.""" + self.ctx.author.bot = False + self.assertTrue(self.cog.check_not_bot(self.ctx)) + + def test_check_not_bot_returns_true_for_robots(self): + """The bot check should return `False` when invoked with robotic authors.""" + self.ctx.author.bot = True + self.assertFalse(self.cog.check_not_bot(self.ctx)) + + def test_check_on_guild_raises_when_outside_of_guild(self): + """When invoked outside of a guild, `check_on_guild` should cause an error.""" + self.ctx.guild = None + + with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): + self.cog.check_on_guild(self.ctx) + + def test_check_on_guild_returns_true_inside_of_guild(self): + """When invoked inside of a guild, `check_on_guild` should return `True`.""" + self.ctx.guild = "lemon's lemonade stand" + self.assertTrue(self.cog.check_on_guild(self.ctx)) + + +class SecurityCogLoadTests(unittest.TestCase): + """Tests loading the `Security` cog.""" + + def test_security_cog_load(self): + """Cog loading logs a message at `INFO` level.""" + bot = MagicMock() + with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm: + security.setup(bot) + bot.add_cog.assert_called_once() + + [line] = cm.output + self.assertIn("Cog loaded: Security", line) diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py deleted file mode 100644 index efa7a50b1..000000000 --- a/tests/cogs/test_security.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging -import unittest -from unittest.mock import MagicMock - -from discord.ext.commands import NoPrivateMessage - -from bot.cogs import security -from tests.helpers import MockBot, MockContext - - -class SecurityCogTests(unittest.TestCase): - """Tests the `Security` cog.""" - - def setUp(self): - """Attach an instance of the cog to the class for tests.""" - self.bot = MockBot() - self.cog = security.Security(self.bot) - self.ctx = MockContext() - - def test_check_additions(self): - """The cog should add its checks after initialization.""" - self.bot.check.assert_any_call(self.cog.check_on_guild) - self.bot.check.assert_any_call(self.cog.check_not_bot) - - def test_check_not_bot_returns_false_for_humans(self): - """The bot check should return `True` when invoked with human authors.""" - self.ctx.author.bot = False - self.assertTrue(self.cog.check_not_bot(self.ctx)) - - def test_check_not_bot_returns_true_for_robots(self): - """The bot check should return `False` when invoked with robotic authors.""" - self.ctx.author.bot = True - self.assertFalse(self.cog.check_not_bot(self.ctx)) - - def test_check_on_guild_raises_when_outside_of_guild(self): - """When invoked outside of a guild, `check_on_guild` should cause an error.""" - self.ctx.guild = None - - with self.assertRaises(NoPrivateMessage, msg="This command cannot be used in private messages."): - self.cog.check_on_guild(self.ctx) - - def test_check_on_guild_returns_true_inside_of_guild(self): - """When invoked inside of a guild, `check_on_guild` should return `True`.""" - self.ctx.guild = "lemon's lemonade stand" - self.assertTrue(self.cog.check_on_guild(self.ctx)) - - -class SecurityCogLoadTests(unittest.TestCase): - """Tests loading the `Security` cog.""" - - def test_security_cog_load(self): - """Cog loading logs a message at `INFO` level.""" - bot = MagicMock() - with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm: - security.setup(bot) - bot.add_cog.assert_called_once() - - [line] = cm.output - self.assertIn("Cog loaded: Security", line) -- cgit v1.2.3 From ffe5dba72d428f73a5874e19bf4fcff52fb4fb6e Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 14:16:10 +0200 Subject: Remove empty tests.cogs folder --- tests/cogs/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/cogs/__init__.py (limited to 'tests') diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From 9de24c0417be4277354af4322dd174a38e8d1785 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 14:48:18 +0200 Subject: Migrate test_constants to unittest Migrates the `test_constants.py` file to unittest. As with the pytest version, there is not yet support to test container types. --- tests/bot/test_constants.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/bot/test_constants.py (limited to 'tests') diff --git a/tests/bot/test_constants.py b/tests/bot/test_constants.py new file mode 100644 index 000000000..dae7c066c --- /dev/null +++ b/tests/bot/test_constants.py @@ -0,0 +1,26 @@ +import inspect +import unittest + +from bot import constants + + +class ConstantsTests(unittest.TestCase): + """Tests for our constants.""" + + def test_section_configuration_matches_type_specification(self): + """The section annotations should match the actual types of the sections.""" + + sections = ( + cls + for (name, cls) in inspect.getmembers(constants) + if hasattr(cls, 'section') and isinstance(cls, type) + ) + for section in sections: + for name, annotation in section.__annotations__.items(): + with self.subTest(section=section, name=name, annotation=annotation): + value = getattr(section, name) + + if getattr(annotation, '_name', None) in ('Dict', 'List'): + self.skipTest("Cannot validate containers yet.") + + self.assertIsInstance(value, annotation) -- 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') 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 0ccb798f03ecb92b73111ffc05ee0f446034142b Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 12 Oct 2019 14:43:10 +0200 Subject: Move the `token_remover` cog tests to `unittest`. --- tests/cogs/__init__.py | 0 tests/cogs/test_token_remover.py | 139 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tests/cogs/__init__.py create mode 100644 tests/cogs/test_token_remover.py (limited to 'tests') diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py new file mode 100644 index 000000000..e5d3648de --- /dev/null +++ b/tests/cogs/test_token_remover.py @@ -0,0 +1,139 @@ +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 + + +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 = MagicMock() + 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 = MagicMock() + 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) -- cgit v1.2.3 From ae0177432e26c5bde66db46cdeb7850a7dddeca0 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Mon, 14 Oct 2019 19:15:07 +0200 Subject: Use `MockBot`. --- tests/cogs/test_token_remover.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'tests') diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py index e5d3648de..3738b6d1b 100644 --- a/tests/cogs/test_token_remover.py +++ b/tests/cogs/test_token_remover.py @@ -11,7 +11,7 @@ from bot.cogs.token_remover import ( setup as setup_cog, ) from bot.constants import Channels, Colours, Event, Icons -from tests.helpers import AsyncMock +from tests.helpers import AsyncMock, MockBot class TokenRemoverTests(unittest.TestCase): @@ -19,7 +19,7 @@ class TokenRemoverTests(unittest.TestCase): def setUp(self): """Adds the cog, a bot, and a message to the instance for usage in tests.""" - self.bot = MagicMock() + 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) @@ -130,7 +130,7 @@ class TokenRemoverSetupTests(unittest.TestCase): def test_setup(self): """Setup of the cog should log a message at `INFO` level.""" - bot = MagicMock() + bot = MockBot() with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm: setup_cog(bot) -- 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') 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 From 20f1cedef806d5bec84533e4ae99f45469c20132 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 14 Oct 2019 22:42:56 +0200 Subject: Remove empty tests.cogs folder --- tests/cogs/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/cogs/__init__.py (limited to 'tests') diff --git a/tests/cogs/__init__.py b/tests/cogs/__init__.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From 1dc08b8622b4d99bc7c480da5f91f774fdfd0787 Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Tue, 22 Oct 2019 21:53:39 +0530 Subject: Modify in_channel_check to accept list of channels - Update test cases for in_channel_check --- bot/utils/checks.py | 6 +++--- tests/utils/test_checks.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) (limited to 'tests') diff --git a/bot/utils/checks.py b/bot/utils/checks.py index ad892e512..db56c347c 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -38,9 +38,9 @@ def without_role_check(ctx: Context, *role_ids: int) -> bool: return check -def in_channel_check(ctx: Context, channel_id: int) -> bool: - """Checks if the command was executed inside of the specified channel.""" - check = ctx.channel.id == channel_id +def in_channel_check(ctx: Context, *channel_ids: int) -> bool: + """Checks if the command was executed inside the list of specified channels.""" + check = ctx.channel.id in channel_ids log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " f"The result of the in_channel check was {check}.") return check diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py index 7121acebd..ef1144ac9 100644 --- a/tests/utils/test_checks.py +++ b/tests/utils/test_checks.py @@ -57,10 +57,10 @@ def test_without_role_check_without_unwanted_role(context): def test_in_channel_check_for_correct_channel(context): - context.channel.id = 42 - assert checks.in_channel_check(context, context.channel.id) + context.channel.id = [42] + assert checks.in_channel_check(context, *context.channel.id) def test_in_channel_check_for_incorrect_channel(context): - context.channel.id = 42 - assert not checks.in_channel_check(context, context.channel.id + 10) + context.channel.id = [42 + 10] + assert not checks.in_channel_check(context, *context.channel.id) -- cgit v1.2.3 From a11596de969a53853151ad8a5ca2d6564227e0ab Mon Sep 17 00:00:00 2001 From: Atul Mishra Date: Tue, 22 Oct 2019 22:37:22 +0530 Subject: Add test cases for in_channel_check --- tests/bot/utils/test_checks.py | 8 ++++++++ tests/utils/test_checks.py | 0 2 files changed, 8 insertions(+) delete mode 100644 tests/utils/test_checks.py (limited to 'tests') diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index 22dc93073..19b758336 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -41,3 +41,11 @@ class ChecksTests(unittest.TestCase): role_id = 42 self.ctx.author.roles.append(MockRole(role_id=role_id)) self.assertTrue(checks.without_role_check(self.ctx, role_id + 10)) + + def test_in_channel_check_for_correct_channel(self): + self.ctx.channel.id = 42 + self.assertTrue(checks.in_channel_check(self.ctx, *[42])) + + def test_in_channel_check_for_incorrect_channel(self): + self.ctx.channel.id = 42 + 10 + self.assertFalse(checks.in_channel_check(self.ctx, *[42])) diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From c5d0eb473a9a1dc486dd2dd60603463435e49da4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 28 Oct 2019 15:56:34 +0100 Subject: Change generation of child mocks - https://docs.python.org/3/library/unittest.mock.html We previously used an override of the `__new__` method to prevent our custom mock types from instantiating their children with their own type instead of a general mock type like `MagicMock` or `Mock`. As it turns out, the Python documentation suggests another method of doing this that does not involve overriding `__new__`. This commit implements this new method to make sure we're using the idiomatic way of handling this. The suggested method is overriding the `_get_child_mock` method in the subclass. To make our code DRY, I've created a mixin that should come BEFORE the mock type we're subclassing in the MRO. --- In addition, I have also added this new mixin to our `AsyncMock` class to make sure that its `__call__` method returns a proper mock object after it has been awaited. This makes sure that subsequent attribute access on the returned object is mocked as expected. --- tests/helpers.py | 85 +++++++++++++++++++++++++++++++-------------------- tests/test_helpers.py | 18 +++++++++-- 2 files changed, 67 insertions(+), 36 deletions(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 892d42e6c..9375d0986 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -24,19 +24,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 +48,43 @@ class ColourMixin: self.colour = color -class AttributeMock: +class GetChildMockMixin: """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) + 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 - return super().__new__(cls) + 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) + + +# TODO: Remove me in Python 3.8 +class AsyncMock(GetChildMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock async callables. + + Python 3.8 will introduce an AsyncMock class in the standard library that will have some more + features; this stand-in only overwrites the `__call__` method to an async version. + """ + child_mock_type = unittest.mock.MagicMock + + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) # Create a guild instance to get a realistic Mock of `discord.Guild` @@ -95,7 +110,7 @@ guild_data = { guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) -class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): +class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): """ A `Mock` subclass to mock `discord.Guild` objects. @@ -122,7 +137,7 @@ class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__( self, @@ -175,7 +190,7 @@ 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(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock `discord.Role` objects. @@ -183,7 +198,7 @@ class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) @@ -208,7 +223,7 @@ 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(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock Member objects. @@ -216,7 +231,7 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__( self, @@ -254,7 +269,7 @@ class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin): bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) -class MockBot(AttributeMock, unittest.mock.MagicMock): +class MockBot(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Bot objects. @@ -262,11 +277,15 @@ class MockBot(AttributeMock, unittest.mock.MagicMock): For more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) + # Our custom attributes and methods + self.http_session = unittest.mock.MagicMock() + self.api_client = unittest.mock.MagicMock() + # `discord.ext.commands.Bot` coroutines self._before_invoke = AsyncMock() self._after_invoke = AsyncMock() @@ -303,7 +322,7 @@ class MockBot(AttributeMock, unittest.mock.MagicMock): context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) -class MockContext(AttributeMock, unittest.mock.MagicMock): +class MockContext(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Context objects. @@ -311,7 +330,7 @@ class MockContext(AttributeMock, unittest.mock.MagicMock): instances. For more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=context_instance, **kwargs) @@ -346,7 +365,7 @@ guild = unittest.mock.MagicMock() channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) -class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin): +class MockTextChannel(GetChildMockMixin, unittest.mock.Mock, HashableMixin): """ A MagicMock subclass to mock TextChannel objects. @@ -354,7 +373,7 @@ class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin): more information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: super().__init__(spec=channel_instance, **kwargs) @@ -402,7 +421,7 @@ channel = unittest.mock.MagicMock() message_instance = discord.Message(state=state, channel=channel, data=message_data) -class MockMessage(AttributeMock, unittest.mock.MagicMock): +class MockMessage(GetChildMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. @@ -410,7 +429,7 @@ class MockMessage(AttributeMock, unittest.mock.MagicMock): information, see the `MockGuild` docstring. """ - attribute_mocktype = unittest.mock.MagicMock + child_mock_type = unittest.mock.MagicMock def __init__(self, **kwargs) -> None: super().__init__(spec=message_instance, **kwargs) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f08239981..62007ff4e 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.GetChildMockMixin, 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_get_child_mock_mixin_accepts_mock_seal(self): + """The `GetChildMockMixin` should support `unittest.mock.seal`.""" + class MyMock(helpers.GetChildMockMixin, 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 = ( @@ -346,7 +358,7 @@ 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_async_mock_provides_coroutine_for_dunder_call(self): """Test if AsyncMock objects have a coroutine for their __call__ method.""" -- cgit v1.2.3 From 618ba6a523dababde230382e1965ecc89f23aaf5 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 29 Oct 2019 10:04:37 +0100 Subject: Enhance custom mock helpers I have enhanced the custom mocks defined in `tests/helpers.py` in a couple of important ways. 1. Automatically create AsyncMock attributes using `inspect` Our previous approach, hard-coding AsynckMock attributes for all the coroutine function methods defined for the class we are trying to mock is prone to human error and not resilient against changes introduced in updates of the library we are using. Instead, I have now created a helper method in our `CustomMockMixin` (formerly `GetChildMockMixin`) that automatically inspects the spec instance we've passed for `coroutine functions` using the `inspect` module. It then sets the according attributes with instances of the AsyncMock class. There is one caveat: `discord.py` very rarely defines regular methods that return a coroutine object. Since the returned coroutine should still be awaited, these regular methods should also be mocked with an AsyncMock. However, since they are regular methods, `inspect` does not detect them and they have to be added manually. (The only case of this I've found so far is `Client.wait_for`.) 2. Properly set special attributes using `kwargs.get` As we want attributes that point to other discord.py objects to use our custom mocks (.e.g, `Message.author` should use `MockMember`), the `__init__` method of our custom mocks make sure to correctly instantiate these attributes. However, the way we previously did that means we can't instantiate the custom mock with a mock instance we provide, since this special instantiation would overwrite the custom object we'd passed. I've solved this by using `kwargs.get`, with a new mock as the default value. This makes sure we only create a new mock if we didn't pass a custom one: ```py class MockMesseage: def __init__(self, **kwargs): self.author = kwargs.get('author', MockMember()) ``` As you can see, we will only create a new MockMember if we did not pass an `author` argument. 3. Factoring out duplicate lines Since our `CustomMockMixin` is a parent to all of our custom mock types, it makes sense to use it to factor out common code of all of our custom mocks. I've made the following changes: - Set a default child mock type in the mixin. - Create an `__init__` that takes care of the `inspect` of point 1 This means we won't have to repeat this in all of the child classes. 4. Three new Mock types: Emoji, PartialEmoji, and Reaction I have added three more custom mocks: - MockEmoji - MockPartialEmoji - MockReaction --- tests/helpers.py | 253 +++++++++++++++++++------------------------------- tests/test_helpers.py | 58 +++++++++++- 2 files changed, 150 insertions(+), 161 deletions(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 9375d0986..673beae3f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio import functools +import inspect import unittest.mock -from typing import Iterable, Optional +from typing import Any, Iterable, Optional import discord from discord.ext.commands import Bot, Context @@ -48,9 +49,16 @@ class ColourMixin: self.colour = color -class GetChildMockMixin: +class CustomMockMixin: """Ensures attributes of our mock types will be instantiated with the correct mock type.""" + child_mock_type = unittest.mock.MagicMock + + def __init__(self, spec: Any = None, **kwargs): + super().__init__(spec=spec, **kwargs) + if spec: + self._extract_coroutine_methods_from_spec_instance(spec) + def _get_child_mock(self, **kw): """ Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes. @@ -72,17 +80,20 @@ class GetChildMockMixin: return klass(**kw) + def _extract_coroutine_methods_from_spec_instance(self, source: Any) -> None: + """Automatically detect coroutine functions in `source` and set them as AsyncMock attributes.""" + for name, _method in inspect.getmembers(source, inspect.iscoroutinefunction): + setattr(self, name, AsyncMock()) + # TODO: Remove me in Python 3.8 -class AsyncMock(GetChildMockMixin, unittest.mock.MagicMock): +class AsyncMock(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock async callables. Python 3.8 will introduce an AsyncMock class in the standard library that will have some more features; this stand-in only overwrites the `__call__` method to an async version. """ - child_mock_type = unittest.mock.MagicMock - async def __call__(self, *args, **kwargs): return super(AsyncMock, self).__call__(*args, **kwargs) @@ -110,7 +121,7 @@ guild_data = { guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) -class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): +class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): """ A `Mock` subclass to mock `discord.Guild` objects. @@ -136,9 +147,6 @@ class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): For more info, see the `Mocking` section in `tests/README.md`. """ - - child_mock_type = unittest.mock.MagicMock - def __init__( self, guild_id: int = 1, @@ -158,39 +166,13 @@ class MockGuild(GetChildMockMixin, unittest.mock.Mock, HashableMixin): if members: self.members.extend(members) - # `discord.Guild` coroutines - self.create_category_channel = AsyncMock() - self.ban = AsyncMock() - self.bans = AsyncMock() - self.create_category = AsyncMock() - self.create_custom_emoji = AsyncMock() - self.create_role = AsyncMock() - self.create_text_channel = AsyncMock() - self.create_voice_channel = AsyncMock() - self.delete = AsyncMock() - self.edit = AsyncMock() - self.estimate_pruned_members = AsyncMock() - self.fetch_ban = AsyncMock() - self.fetch_channels = AsyncMock() - self.fetch_emoji = AsyncMock() - self.fetch_emojis = AsyncMock() - self.fetch_member = AsyncMock() - self.invites = AsyncMock() - self.kick = AsyncMock() - self.leave = AsyncMock() - self.prune_members = AsyncMock() - self.unban = AsyncMock() - self.vanity_invite = AsyncMock() - self.webhooks = AsyncMock() - self.widget = AsyncMock() - # Create a Role instance to get a realistic Mock of `discord.Role` role_data = {'name': 'role', 'id': 1} role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) -class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock `discord.Role` objects. @@ -208,10 +190,6 @@ class MockRole(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin self.position = position self.mention = f'&{self.name}' - # 'discord.Role' coroutines - self.delete = AsyncMock() - self.edit = AsyncMock() - def __lt__(self, other): """Simplified position-based comparisons similar to those of `discord.Role`.""" return self.position < other.position @@ -223,16 +201,13 @@ state_mock = unittest.mock.MagicMock() member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) -class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): +class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): """ A Mock subclass to mock Member objects. Instances of this class will follow the specifications of `discord.Member` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__( self, name: str = "member", @@ -251,34 +226,18 @@ class MockMember(GetChildMockMixin, unittest.mock.Mock, ColourMixin, HashableMix self.mention = f"@{self.name}" - # `discord.Member` coroutines - self.add_roles = AsyncMock() - self.ban = AsyncMock() - self.edit = AsyncMock() - self.fetch_message = AsyncMock() - self.kick = AsyncMock() - self.move_to = AsyncMock() - self.pins = AsyncMock() - self.remove_roles = AsyncMock() - self.send = AsyncMock() - self.trigger_typing = AsyncMock() - self.unban = AsyncMock() - # Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot` bot_instance = Bot(command_prefix=unittest.mock.MagicMock()) -class MockBot(GetChildMockMixin, unittest.mock.MagicMock): +class MockBot(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Bot objects. Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, **kwargs) -> None: super().__init__(spec=bot_instance, **kwargs) @@ -286,69 +245,12 @@ class MockBot(GetChildMockMixin, unittest.mock.MagicMock): self.http_session = unittest.mock.MagicMock() self.api_client = unittest.mock.MagicMock() - # `discord.ext.commands.Bot` coroutines - self._before_invoke = AsyncMock() - self._after_invoke = AsyncMock() - self.application_info = AsyncMock() - self.change_presence = AsyncMock() - self.connect = AsyncMock() - self.close = AsyncMock() - self.create_guild = AsyncMock() - self.delete_invite = AsyncMock() - self.fetch_channel = AsyncMock() - self.fetch_guild = AsyncMock() - self.fetch_guilds = AsyncMock() - self.fetch_invite = AsyncMock() - self.fetch_user = AsyncMock() - self.fetch_user_profile = AsyncMock() - self.fetch_webhook = AsyncMock() - self.fetch_widget = AsyncMock() - self.get_context = AsyncMock() - self.get_prefix = AsyncMock() - self.invoke = AsyncMock() - self.is_owner = AsyncMock() - self.login = AsyncMock() - self.logout = AsyncMock() - self.on_command_error = AsyncMock() - self.on_error = AsyncMock() - self.process_commands = AsyncMock() - self.request_offline_members = AsyncMock() - self.start = AsyncMock() - self.wait_until_ready = AsyncMock() + # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and + # and should therefore be awaited. (The documentation calls it a coroutine as well, which + # is technically incorrect, since it's a regular def.) self.wait_for = AsyncMock() -# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` -context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) - - -class MockContext(GetChildMockMixin, unittest.mock.MagicMock): - """ - A MagicMock subclass to mock Context objects. - - Instances of this class will follow the specifications of `discord.ext.commands.Context` - instances. For more information, see the `MockGuild` docstring. - """ - - child_mock_type = unittest.mock.MagicMock - - def __init__(self, **kwargs) -> None: - super().__init__(spec=context_instance, **kwargs) - self.bot = MockBot() - self.guild = MockGuild() - self.author = MockMember() - self.command = unittest.mock.MagicMock() - - # `discord.ext.commands.Context` coroutines - self.fetch_message = AsyncMock() - self.invoke = AsyncMock() - self.pins = AsyncMock() - self.reinvoke = AsyncMock() - self.send = AsyncMock() - self.send_help = AsyncMock() - self.trigger_typing = AsyncMock() - - # Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` channel_data = { 'id': 1, @@ -365,39 +267,20 @@ guild = unittest.mock.MagicMock() channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) -class MockTextChannel(GetChildMockMixin, unittest.mock.Mock, HashableMixin): +class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): """ A MagicMock subclass to mock TextChannel objects. Instances of this class will follow the specifications of `discord.TextChannel` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: super().__init__(spec=channel_instance, **kwargs) self.id = channel_id self.name = name - self.guild = MockGuild() + self.guild = kwargs.get('guild', MockGuild()) self.mention = f"#{self.name}" - # `discord.TextChannel` coroutines - self.clone = AsyncMock() - self.create_invite = AsyncMock() - self.create_webhook = AsyncMock() - self.delete = AsyncMock() - self.delete_messages = AsyncMock() - self.edit = AsyncMock() - self.fetch_message = AsyncMock() - self.invites = AsyncMock() - self.pins = AsyncMock() - self.purge = AsyncMock() - self.send = AsyncMock() - self.set_permissions = AsyncMock() - self.trigger_typing = AsyncMock() - self.webhooks = AsyncMock() - # Create a Message instance to get a realistic MagicMock of `discord.Message` message_data = { @@ -421,27 +304,83 @@ channel = unittest.mock.MagicMock() message_instance = discord.Message(state=state, channel=channel, data=message_data) -class MockMessage(GetChildMockMixin, unittest.mock.MagicMock): +# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` +context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) + + +class MockContext(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Context objects. + + Instances of this class will follow the specifications of `discord.ext.commands.Context` + instances. For more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=context_instance, **kwargs) + self.bot = kwargs.get('bot', MockBot()) + self.guild = kwargs.get('guild', MockGuild()) + self.author = kwargs.get('author', MockMember()) + self.channel = kwargs.get('channel', MockTextChannel()) + self.command = kwargs.get('command', unittest.mock.MagicMock()) + + +class MockMessage(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. Instances of this class will follow the specifications of `discord.Message` instances. For more information, see the `MockGuild` docstring. """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=message_instance, **kwargs) + self.author = kwargs.get('author', MockMember()) + self.channel = kwargs.get('channel', MockTextChannel()) - child_mock_type = unittest.mock.MagicMock +emoji_data = {'require_colons': True, 'managed': True, 'id': 1, 'name': 'hyperlemon'} +emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data) + + +class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Emoji objects. + + Instances of this class will follow the specifications of `discord.Emoji` instances. For more + information, see the `MockGuild` docstring. + """ def __init__(self, **kwargs) -> None: - super().__init__(spec=message_instance, **kwargs) - self.author = MockMember() - self.channel = MockTextChannel() - - # `discord.Message` coroutines - self.ack = AsyncMock() - self.add_reaction = AsyncMock() - self.clear_reactions = AsyncMock() - self.delete = AsyncMock() - self.edit = AsyncMock() - self.pin = AsyncMock() - self.remove_reaction = AsyncMock() - self.unpin = AsyncMock() + super().__init__(spec=emoji_instance, **kwargs) + self.guild = kwargs.get('guild', MockGuild()) + + # Get all coroutine functions and set them as AsyncMock attributes + self._extract_coroutine_methods_from_spec_instance(emoji_instance) + + +partial_emoji_instance = discord.PartialEmoji(animated=False, name='guido') + + +class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock PartialEmoji objects. + + Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For + more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=partial_emoji_instance, **kwargs) + + +reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji()) + + +class MockReaction(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Reaction objects. + + Instances of this class will follow the specifications of `discord.Reaction` instances. For + more information, see the `MockGuild` docstring. + """ + def __init__(self, **kwargs) -> None: + super().__init__(spec=reaction_instance, **kwargs) + self.emoji = kwargs.get('emoji', MockEmoji()) + self.message = kwargs.get('message', MockMessage()) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 62007ff4e..2b58634dd 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -221,7 +221,7 @@ class DiscordMocksTests(unittest.TestCase): @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') def test_the_custom_mock_methods_test(self, subtest_mock): """The custom method test should raise AssertionError for invalid methods.""" - class FakeMockBot(helpers.GetChildMockMixin, unittest.mock.MagicMock): + class FakeMockBot(helpers.CustomMockMixin, unittest.mock.MagicMock): """Fake MockBot class with invalid attribute/method `release_the_walrus`.""" child_mock_type = unittest.mock.MagicMock @@ -331,9 +331,9 @@ class MockObjectTests(unittest.TestCase): self.assertFalse(instance_one != instance_two) self.assertTrue(instance_one != instance_three) - def test_get_child_mock_mixin_accepts_mock_seal(self): - """The `GetChildMockMixin` should support `unittest.mock.seal`.""" - class MyMock(helpers.GetChildMockMixin, unittest.mock.MagicMock): + def test_custom_mock_mixin_accepts_mock_seal(self): + """The `CustomMockMixin` should support `unittest.mock.seal`.""" + class MyMock(helpers.CustomMockMixin, unittest.mock.MagicMock): child_mock_type = unittest.mock.MagicMock pass @@ -351,6 +351,10 @@ class MockObjectTests(unittest.TestCase): (helpers.MockMember, "display_name"), (helpers.MockBot, "owner_id"), (helpers.MockContext, "command_failed"), + (helpers.MockMessage, "mention_everyone"), + (helpers.MockEmoji, 'managed'), + (helpers.MockPartialEmoji, 'url'), + (helpers.MockReaction, 'me'), ) for mock_type, valid_attribute in test_values: @@ -360,6 +364,52 @@ class MockObjectTests(unittest.TestCase): attribute = getattr(mock, valid_attribute) self.assertTrue(isinstance(attribute, mock_type.child_mock_type)) + def test_extract_coroutine_methods_from_spec_instance_should_extract_all_and_only_coroutines(self): + """Test if all coroutine functions are extracted, but not regular methods or attributes.""" + class CoroutineDonor: + def __init__(self): + self.some_attribute = 'alpha' + + async def first_coroutine(): + """This coroutine function should be extracted.""" + + async def second_coroutine(): + """This coroutine function should be extracted.""" + + def regular_method(): + """This regular function should not be extracted.""" + + class Receiver: + pass + + donor = CoroutineDonor() + receiver = Receiver() + + helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance(receiver, donor) + + self.assertIsInstance(receiver.first_coroutine, helpers.AsyncMock) + self.assertIsInstance(receiver.second_coroutine, helpers.AsyncMock) + self.assertFalse(hasattr(receiver, 'regular_method')) + self.assertFalse(hasattr(receiver, 'some_attribute')) + + @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) + @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") + def test_custom_mock_mixin_init_with_spec(self, extract_method_mock): + """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" + spec = "pydis" + + helpers.CustomMockMixin(spec=spec) + + extract_method_mock.assert_called_once_with(spec) + + @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock()) + @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance") + def test_custom_mock_mixin_init_without_spec(self, extract_method_mock): + """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method.""" + helpers.CustomMockMixin() + + extract_method_mock.assert_not_called() + def test_async_mock_provides_coroutine_for_dunder_call(self): """Test if AsyncMock objects have a coroutine for their __call__ method.""" async_mock = helpers.AsyncMock() -- cgit v1.2.3 From 586ae18842de0dd92e93945c63ed5e6cd158c1f7 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 30 Oct 2019 22:54:27 +0100 Subject: Update docstring and remove redundant attribute I accidentally forgot to update the docstring of `CustomMockMixin`, which changed quite dramatically in scope with the last commit. This commit remedies that. In addition, I inadvertently forgot to remove the `child_mock_type` class attribute from `MockRole`. Since it uses the default value, it is no longer necessary to specify it in the child class as well. --- tests/helpers.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) (limited to 'tests') diff --git a/tests/helpers.py b/tests/helpers.py index 673beae3f..8496ba031 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -50,7 +50,15 @@ class ColourMixin: class CustomMockMixin: - """Ensures attributes of our mock types will be instantiated with the correct mock type.""" + """ + Provides common functionality for our custom Mock types. + + 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. + """ child_mock_type = unittest.mock.MagicMock @@ -179,9 +187,6 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): Instances of this class will follow the specifications of `discord.Role` instances. For more information, see the `MockGuild` docstring. """ - - child_mock_type = unittest.mock.MagicMock - def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None: super().__init__(spec=role_instance, **kwargs) -- cgit v1.2.3 From 6e57f41d727c9cde51d36adc04393c3723d62472 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 28 Oct 2019 15:54:31 +0100 Subject: Enhance the output of the user command https://github.com/python-discord/bot/issues/628 https://github.com/python-discord/bot/issues/339 This commit introduces several changes to the output of the `!user` command for moderation staff. The output for regular users has not changed. Changes: - When issued in a moderation channel, the infraction count of the user will now be broken down by type as described in #339. This allows moderators to get a quicker overview of someone's history by providing more information. The command will display the total number of infractions per type, with the number of active infractions in parentheses behind it if there are any. This change also means that there no longer a need for the `hidden` parameter: When issued in a moderation channel, hidden infractions are included by default; when issued outside of a mod channel, the command will be equal to what a regular user would get. In addition to broken-down infraction info, the command now also shows information about the nominations of a user when it's issued inside of a moderation channel. - The code has been refactored to smaller units that take care of a single action to make unit testing easier. I have included tests that cover the command and all of the new helper methods. Tests for the other methods/commands in the cog will be added in the specific issue calling for tests for this cog (#581) This commit closes #628 and closes #339 --- bot/cogs/information.py | 170 ++++++++++---- tests/bot/cogs/test_information.py | 444 +++++++++++++++++++++++++++++++++++-- 2 files changed, 552 insertions(+), 62 deletions(-) (limited to 'tests') diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 3a7ba0444..4a3af7edd 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -3,6 +3,7 @@ import logging import pprint import textwrap import typing +from collections import defaultdict from typing import Any, Mapping, Optional import discord @@ -10,7 +11,7 @@ from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, V from discord.ext import commands from discord.ext.commands import Bot, BucketType, Cog, Context, command, group -from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES +from bot import constants from bot.decorators import InChannelCheckFailure, in_channel, with_role from bot.utils.checks import cooldown_with_role_bypass, with_role_check from bot.utils.time import time_since @@ -24,7 +25,7 @@ class Information(Cog): def __init__(self, bot: Bot): self.bot = bot - @with_role(*MODERATION_ROLES) + @with_role(*constants.MODERATION_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: """Returns a list of all roles and their corresponding IDs.""" @@ -48,7 +49,7 @@ class Information(Cog): await ctx.send(embed=embed) - @with_role(*MODERATION_ROLES) + @with_role(*constants.MODERATION_ROLES) @command(name="role") async def role_info(self, ctx: Context, *roles: typing.Union[Role, str]) -> None: """ @@ -148,10 +149,10 @@ class Information(Cog): Channel categories: {category_channels} **Members** - {Emojis.status_online} {online} - {Emojis.status_idle} {idle} - {Emojis.status_dnd} {dnd} - {Emojis.status_offline} {offline} + {constants.Emojis.status_online} {online} + {constants.Emojis.status_idle} {idle} + {constants.Emojis.status_dnd} {dnd} + {constants.Emojis.status_offline} {offline} """) ) @@ -160,59 +161,38 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None: + async def user_info(self, ctx: Context, user: Member = None) -> None: """Returns info about a user.""" if user is None: user = ctx.author # Do a role check if this is being executed on someone other than the caller - if user != ctx.author and not with_role_check(ctx, *MODERATION_ROLES): + if user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES): await ctx.send("You may not use this command on users other than yourself.") return - # Non-moderators may only do this in #bot-commands and can't see hidden infractions. - if not with_role_check(ctx, *STAFF_ROLES): - if not ctx.channel.id == Channels.bot: - raise InChannelCheckFailure(Channels.bot) - # Hide hidden infractions for users without a moderation role - hidden = False + # Non-staff may only do this in #bot-commands + if not with_role_check(ctx, *constants.STAFF_ROLES): + if not ctx.channel.id == constants.Channels.bot: + raise InChannelCheckFailure(constants.Channels.bot) - # User information + embed = await self.create_user_embed(ctx, user) + + await ctx.send(embed=embed) + + async def create_user_embed(self, ctx: Context, user: Member) -> Embed: + """Creates an embed containing information on the `user`.""" created = time_since(user.created_at, max_units=3) name = str(user) if user.nick: name = f"{user.nick} ({name})" - # Member information joined = time_since(user.joined_at, precision="days") - - # You're welcome, Volcyyyyyyyyyyyyyyyy roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone") - # Infractions - infractions = await self.bot.api_client.get( - 'bot/infractions', - params={ - 'hidden': str(hidden), - 'user__id': str(user.id) - } - ) - - infr_total = 0 - infr_active = 0 - - # At least it's readable. - for infr in infractions: - if infr["active"]: - infr_active += 1 - - infr_total += 1 - - # Let's build the embed now - embed = Embed( - title=name, - description=textwrap.dedent(f""" + description = [ + textwrap.dedent(f""" **User Information** Created: {created} Profile: {user.mention} @@ -221,17 +201,109 @@ class Information(Cog): **Member Information** Joined: {joined} Roles: {roles or None} + """).strip() + ] - **Infractions** - Total: {infr_total} - Active: {infr_active} - """) + # Show more verbose output in moderation channels for infractions and nominations + if ctx.channel.id in constants.MODERATION_CHANNELS: + description.append(await self.expanded_user_infraction_counts(user)) + description.append(await self.user_nomination_counts(user)) + else: + description.append(await self.basic_user_infraction_counts(user)) + + # Let's build the embed now + embed = Embed( + title=name, + description="\n\n".join(description) ) embed.set_thumbnail(url=user.avatar_url_as(format="png")) embed.colour = user.top_role.colour if roles else Colour.blurple() - await ctx.send(embed=embed) + return embed + + async def basic_user_infraction_counts(self, member: Member) -> str: + """Gets the total and active infraction counts for the given `member`.""" + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'hidden': 'False', + 'user__id': str(member.id) + } + ) + + total_infractions = len(infractions) + active_infractions = sum(infraction['active'] for infraction in infractions) + + infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" + + return infraction_output + + async def expanded_user_infraction_counts(self, member: Member) -> str: + """ + Gets expanded infraction counts for the given `member`. + + The counts will be split by infraction type and the number of active infractions for each type will indicated + in the output as well. + """ + infractions = await self.bot.api_client.get( + 'bot/infractions', + params={ + 'user__id': str(member.id) + } + ) + + infraction_output = ["**Infractions**"] + if not infractions: + infraction_output.append("This user has never received an infraction.") + else: + # Count infractions split by `type` and `active` status for this user + infraction_types = set() + infraction_counter = defaultdict(int) + for infraction in infractions: + infraction_type = infraction["type"] + infraction_active = 'active' if infraction["active"] else 'inactive' + + infraction_types.add(infraction_type) + infraction_counter[f"{infraction_active} {infraction_type}"] += 1 + + # Format the output of the infraction counts + for infraction_type in sorted(infraction_types): + active_count = infraction_counter[f"active {infraction_type}"] + total_count = active_count + infraction_counter[f"inactive {infraction_type}"] + + line = f"{infraction_type.capitalize()}s: {total_count}" + if active_count: + line += f" ({active_count} active)" + + infraction_output.append(line) + + return "\n".join(infraction_output) + + async def user_nomination_counts(self, member: Member) -> str: + """Gets the active and historical nomination counts for the given `member`.""" + nominations = await self.bot.api_client.get( + 'bot/nominations', + params={ + 'user__id': str(member.id) + } + ) + + output = ["**Nominations**"] + + if not nominations: + output.append("This user has never been nominated.") + else: + count = len(nominations) + is_currently_nominated = any(nomination["active"] for nomination in nominations) + nomination_noun = "nomination" if count == 1 else "nominations" + + if is_currently_nominated: + output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") + else: + output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") + + return "\n".join(output) def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: """Format a mapping to be readable to a human.""" @@ -268,9 +340,9 @@ class Information(Cog): # remove trailing whitespace return out.rstrip() - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=STAFF_ROLES) + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) @group(invoke_without_command=True) - @in_channel(Channels.bot, bypass_roles=STAFF_ROLES) + @in_channel(constants.Channels.bot, bypass_roles=constants.STAFF_ROLES) async def raw(self, ctx: Context, *, message: discord.Message, json: bool = False) -> None: """Shows information about the raw API response.""" # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling 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) + + +@unittest.mock.patch("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) +@unittest.mock.patch("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") + + +@unittest.mock.patch("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() -- cgit v1.2.3 From d1b35becef79540954adec4078ce4a2a47b34cfa Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 1 Nov 2019 20:49:10 +0100 Subject: Write tests for `bot.utils`. Closes #604. --- tests/bot/test_utils.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/bot/test_utils.py (limited to 'tests') diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py new file mode 100644 index 000000000..0a32b8411 --- /dev/null +++ b/tests/bot/test_utils.py @@ -0,0 +1,49 @@ +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.pop(key), value) + + 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]]) -- cgit v1.2.3 From 9e825ed657cebc9f47208af7dc5fa46f31d9ef41 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sat, 2 Nov 2019 11:04:57 +0100 Subject: Use `casefold` in some cases. --- tests/bot/test_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'tests') diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py index 0a32b8411..58ae2a81a 100644 --- a/tests/bot/test_utils.py +++ b/tests/bot/test_utils.py @@ -16,7 +16,10 @@ class CaseInsensitiveDictTests(unittest.TestCase): instance[key] = value self.assertIn(key, instance) self.assertEqual(instance.get(key), value) - self.assertEqual(instance.pop(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] -- cgit v1.2.3