aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/README.md40
-rw-r--r--tests/bot/exts/filters/test_antimalware.py45
-rw-r--r--tests/bot/exts/moderation/infraction/test_utils.py4
-rw-r--r--tests/bot/exts/utils/test_jams.py137
-rw-r--r--tests/helpers.py22
5 files changed, 162 insertions, 86 deletions
diff --git a/tests/README.md b/tests/README.md
index 1a17c09bd..b7fddfaa2 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -4,6 +4,14 @@ Our bot is one of the most important tools we have for running our community. As
_**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 can take a look at the [Additional resources](#additional-resources) section at the bottom of this page._
+### Table of contents:
+- [Tools](#tools)
+- [Running tests](#running-tests)
+- [Writing tests](#writing-tests)
+- [Mocking](#mocking)
+- [Some considerations](#some-considerations)
+- [Additional resources](#additional-resources)
+
## Tools
We are using the following modules and packages for our unit tests:
@@ -11,15 +19,43 @@ 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/)
+- [pytest-cov](https://pytest-cov.readthedocs.io/en/latest/index.html)
+
+We also use the following package as a test runner:
+- [pytest](https://docs.pytest.org/en/6.2.x/)
-To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided two "scripts" shortcuts:
+To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided the following "script" shortcuts:
-- `poetry run task test` will run `unittest` with `coverage.py`
+- `poetry run task test-nocov` will run `pytest`.
+- `poetry run task test` will run `pytest` with `pytest-cov`.
- `poetry run task test path/to/test.py` will run a specific test.
- `poetry run task report` will generate a coverage report of the tests you've run with `poetry run task 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.
If you want a coverage report, make sure to run the tests with `poetry run task test` *first*.
+## Running tests
+There are multiple ways to run the tests, which one you use will be determined by your goal, and stage in development.
+
+When actively developing, you'll most likely be working on one portion of the codebase, and as a result, won't need to run the entire test suite.
+To run just one file, and save time, you can use the following command:
+```shell
+poetry run task test-nocov <path/to/file.py>
+```
+
+For example:
+```shell
+poetry run task test-nocov tests/bot/exts/test_cogs.py
+```
+will run the test suite in the `test_cogs` file.
+
+If you'd like to collect coverage as well, you can append `--cov` to the command above.
+
+
+If you're done and are preparing to commit and push your code, it's a good idea to run the entire test suite as a sanity check:
+```shell
+poetry run task test
+```
+
## Writing tests
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)).
diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py
index 3393c6cdc..06d78de9d 100644
--- a/tests/bot/exts/filters/test_antimalware.py
+++ b/tests/bot/exts/filters/test_antimalware.py
@@ -104,24 +104,39 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION)
async def test_txt_file_redirect_embed_description(self):
- """A message containing a .txt file should result in the correct embed."""
- attachment = MockAttachment(filename="python.txt")
- self.message.attachments = [attachment]
- self.message.channel.send = AsyncMock()
- antimalware.TXT_EMBED_DESCRIPTION = Mock()
- antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test"
-
- await self.cog.on_message(self.message)
- self.message.channel.send.assert_called_once()
- args, kwargs = self.message.channel.send.call_args
- embed = kwargs.pop("embed")
- cmd_channel = self.bot.get_channel(Channels.bot_commands)
+ """A message containing a .txt/.json/.csv file should result in the correct embed."""
+ test_values = (
+ ("text", ".txt"),
+ ("json", ".json"),
+ ("csv", ".csv"),
+ )
- self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value)
- antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention)
+ for file_name, disallowed_extension in test_values:
+ with self.subTest(file_name=file_name, disallowed_extension=disallowed_extension):
+
+ attachment = MockAttachment(filename=f"{file_name}{disallowed_extension}")
+ self.message.attachments = [attachment]
+ self.message.channel.send = AsyncMock()
+ antimalware.TXT_EMBED_DESCRIPTION = Mock()
+ antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test"
+
+ await self.cog.on_message(self.message)
+ self.message.channel.send.assert_called_once()
+ args, kwargs = self.message.channel.send.call_args
+ embed = kwargs.pop("embed")
+ cmd_channel = self.bot.get_channel(Channels.bot_commands)
+
+ self.assertEqual(
+ embed.description,
+ antimalware.TXT_EMBED_DESCRIPTION.format.return_value
+ )
+ antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(
+ blocked_extension=disallowed_extension,
+ cmd_channel_mention=cmd_channel.mention
+ )
async def test_other_disallowed_extension_embed_description(self):
- """Test the description for a non .py/.txt disallowed extension."""
+ """Test the description for a non .py/.txt/.json/.csv disallowed extension."""
attachment = MockAttachment(filename="python.disallowed")
self.message.attachments = [attachment]
self.message.channel.send = AsyncMock()
diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py
index ee9ff650c..50a717bb5 100644
--- a/tests/bot/exts/moderation/infraction/test_utils.py
+++ b/tests/bot/exts/moderation/infraction/test_utils.py
@@ -137,7 +137,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
title=utils.INFRACTION_TITLE,
description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
type="Ban",
- expires="2020-02-26 09:20 (23 hours and 59 minutes)",
+ expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC",
reason="No reason provided."
),
colour=Colours.soft_red,
@@ -193,7 +193,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
title=utils.INFRACTION_TITLE,
description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
type="Mute",
- expires="2020-02-26 09:20 (23 hours and 59 minutes)",
+ expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC",
reason="Test"
),
colour=Colours.soft_red,
diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py
index 85d6a1173..368a15476 100644
--- a/tests/bot/exts/utils/test_jams.py
+++ b/tests/bot/exts/utils/test_jams.py
@@ -2,10 +2,24 @@ import unittest
from unittest.mock import AsyncMock, MagicMock, create_autospec
from discord import CategoryChannel
+from discord.ext.commands import BadArgument
from bot.constants import Roles
from bot.exts.utils import jams
-from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel
+from tests.helpers import (
+ MockAttachment, MockBot, MockCategoryChannel, MockContext,
+ MockGuild, MockMember, MockRole, MockTextChannel
+)
+
+TEST_CSV = b"""\
+Team Name,Team Member Discord ID,Team Leader
+Annoyed Alligators,12345,Y
+Annoyed Alligators,54321,N
+Oscillating Otters,12358,Y
+Oscillating Otters,74832,N
+Oscillating Otters,19903,N
+Annoyed Alligators,11111,N
+"""
def get_mock_category(channel_count: int, name: str) -> CategoryChannel:
@@ -17,8 +31,8 @@ def get_mock_category(channel_count: int, name: str) -> CategoryChannel:
return category
-class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase):
- """Tests for `createteam` command."""
+class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for `codejam create` command."""
def setUp(self):
self.bot = MockBot()
@@ -28,60 +42,64 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase):
self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild)
self.cog = jams.CodeJams(self.bot)
- async def test_too_small_amount_of_team_members_passed(self):
- """Should `ctx.send` and exit early when too small amount of members."""
- for case in (1, 2):
- with self.subTest(amount_of_members=case):
- self.cog.create_channels = AsyncMock()
- self.cog.add_roles = AsyncMock()
+ async def test_message_without_attachments(self):
+ """If no link or attachments are provided, commands.BadArgument should be raised."""
+ self.ctx.message.attachments = []
- self.ctx.reset_mock()
- members = (MockMember() for _ in range(case))
- await self.cog.createteam(self.cog, self.ctx, "foo", members)
+ with self.assertRaises(BadArgument):
+ await self.cog.create(self.cog, self.ctx, None)
- self.ctx.send.assert_awaited_once()
- self.cog.create_channels.assert_not_awaited()
- self.cog.add_roles.assert_not_awaited()
+ async def test_result_sending(self):
+ """Should call `ctx.send` when everything goes right."""
+ self.ctx.message.attachments = [MockAttachment()]
+ self.ctx.message.attachments[0].read = AsyncMock()
+ self.ctx.message.attachments[0].read.return_value = TEST_CSV
+
+ team_leaders = MockRole()
+
+ self.guild.get_member.return_value = MockMember()
- async def test_duplicate_members_provided(self):
- """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member."""
- self.cog.create_channels = AsyncMock()
+ self.ctx.guild.create_role = AsyncMock()
+ self.ctx.guild.create_role.return_value = team_leaders
+ self.cog.create_team_channel = AsyncMock()
+ self.cog.create_team_leader_channel = AsyncMock()
self.cog.add_roles = AsyncMock()
- member = MockMember()
- await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5)))
+ await self.cog.create(self.cog, self.ctx, None)
+ self.cog.create_team_channel.assert_awaited()
+ self.cog.create_team_leader_channel.assert_awaited_once_with(
+ self.ctx.guild, team_leaders
+ )
self.ctx.send.assert_awaited_once()
- self.cog.create_channels.assert_not_awaited()
- self.cog.add_roles.assert_not_awaited()
-
- async def test_result_sending(self):
- """Should call `ctx.send` when everything goes right."""
- self.cog.create_channels = AsyncMock()
- self.cog.add_roles = AsyncMock()
- members = [MockMember() for _ in range(5)]
- await self.cog.createteam(self.cog, self.ctx, "foo", members)
+ async def test_link_returning_non_200_status(self):
+ """When the URL passed returns a non 200 status, it should send a message informing them."""
+ self.bot.http_session.get.return_value = mock = MagicMock()
+ mock.status = 404
+ await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com")
- self.cog.create_channels.assert_awaited_once()
- self.cog.add_roles.assert_awaited_once()
self.ctx.send.assert_awaited_once()
async def test_category_doesnt_exist(self):
"""Should create a new code jam category."""
subtests = (
[],
- [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)],
+ [get_mock_category(jams.MAX_CHANNELS, jams.CATEGORY_NAME)],
[get_mock_category(jams.MAX_CHANNELS - 2, "other")],
)
+ self.cog.send_status_update = AsyncMock()
+
for categories in subtests:
+ self.cog.send_status_update.reset_mock()
self.guild.reset_mock()
self.guild.categories = categories
with self.subTest(categories=categories):
actual_category = await self.cog.get_category(self.guild)
+ self.cog.send_status_update.assert_called_once()
self.guild.create_category_channel.assert_awaited_once()
category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"]
@@ -103,62 +121,47 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase):
async def test_channel_overwrites(self):
"""Should have correct permission overwrites for users and roles."""
- leader = MockMember()
- members = [leader] + [MockMember() for _ in range(4)]
+ leader = (MockMember(), True)
+ members = [leader] + [(MockMember(), False) for _ in range(4)]
overwrites = self.cog.get_overwrites(members, self.guild)
- # Leader permission overwrites
- self.assertTrue(overwrites[leader].manage_messages)
- self.assertTrue(overwrites[leader].read_messages)
- self.assertTrue(overwrites[leader].manage_webhooks)
- self.assertTrue(overwrites[leader].connect)
-
- # Other members permission overwrites
- for member in members[1:]:
+ for member, _ in members:
self.assertTrue(overwrites[member].read_messages)
- self.assertTrue(overwrites[member].connect)
-
- # Everyone role overwrite
- self.assertFalse(overwrites[self.guild.default_role].read_messages)
- self.assertFalse(overwrites[self.guild.default_role].connect)
async def test_team_channels_creation(self):
- """Should create new voice and text channel for team."""
- members = [MockMember() for _ in range(5)]
+ """Should create a text channel for a team."""
+ team_leaders = MockRole()
+ members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)]
+ category = MockCategoryChannel()
+ category.create_text_channel = AsyncMock()
self.cog.get_overwrites = MagicMock()
self.cog.get_category = AsyncMock()
- self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel")
- actual = await self.cog.create_channels(self.guild, "my-team", members)
+ self.cog.get_category.return_value = category
+ self.cog.add_team_leader_roles = AsyncMock()
- self.assertEqual("foobar-channel", actual)
+ await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders)
+ self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders)
self.cog.get_overwrites.assert_called_once_with(members, self.guild)
self.cog.get_category.assert_awaited_once_with(self.guild)
- self.guild.create_text_channel.assert_awaited_once_with(
+ category.create_text_channel.assert_awaited_once_with(
"my-team",
- overwrites=self.cog.get_overwrites.return_value,
- category=self.cog.get_category.return_value
- )
- self.guild.create_voice_channel.assert_awaited_once_with(
- "My Team",
- overwrites=self.cog.get_overwrites.return_value,
- category=self.cog.get_category.return_value
+ overwrites=self.cog.get_overwrites.return_value
)
async def test_jam_roles_adding(self):
"""Should add team leader role to leader and jam role to every team member."""
leader_role = MockRole(name="Team Leader")
- jam_role = MockRole(name="Jammer")
- self.guild.get_role.side_effect = [leader_role, jam_role]
leader = MockMember()
- members = [leader] + [MockMember() for _ in range(4)]
- await self.cog.add_roles(self.guild, members)
+ members = [(leader, True)] + [(MockMember(), False) for _ in range(4)]
+ await self.cog.add_team_leader_roles(members, leader_role)
- leader.add_roles.assert_any_await(leader_role)
- for member in members:
- member.add_roles.assert_any_await(jam_role)
+ leader.add_roles.assert_awaited_once_with(leader_role)
+ for member, is_leader in members:
+ if not is_leader:
+ member.add_roles.assert_not_awaited()
class CodeJamSetup(unittest.TestCase):
diff --git a/tests/helpers.py b/tests/helpers.py
index 86cc635f8..3978076ed 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -380,6 +380,27 @@ class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
super().__init__(**collections.ChainMap(kwargs, default_kwargs))
+# Create CategoryChannel instance to get a realistic MagicMock of `discord.CategoryChannel`
+category_channel_data = {
+ 'id': 1,
+ 'type': discord.ChannelType.category,
+ 'name': 'category',
+ 'position': 1,
+}
+
+state = unittest.mock.MagicMock()
+guild = unittest.mock.MagicMock()
+category_channel_instance = discord.CategoryChannel(
+ state=state, guild=guild, data=category_channel_data
+)
+
+
+class MockCategoryChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
+ def __init__(self, **kwargs) -> None:
+ default_kwargs = {'id': next(self.discord_id)}
+ super().__init__(**collections.ChainMap(default_kwargs, kwargs))
+
+
# Create a Message instance to get a realistic MagicMock of `discord.Message`
message_data = {
'id': 1,
@@ -422,6 +443,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock):
self.guild = kwargs.get('guild', MockGuild())
self.author = kwargs.get('author', MockMember())
self.channel = kwargs.get('channel', MockTextChannel())
+ self.message = kwargs.get('message', MockMessage())
self.invoked_from_error_handler = kwargs.get('invoked_from_error_handler', False)