Skip to content

Commit

Permalink
Add unit tests (#161)
Browse files Browse the repository at this point in the history
This patch fully implements `pytest`-based unit tests for memebot. It
currently tests all the commands plus Twitter integration. Most of the
tests are fairly thorough.

In order for the tests to run correctly, Memebot's code has been moved
from the `src` directory to a root `memebot` package, as is typical for
Python projects. The docker-compose file and README have been updated to
reflect this as well.

Resolves #31
  • Loading branch information
super-cooper authored Nov 13, 2024
1 parent 0f208ea commit 98b18b8
Show file tree
Hide file tree
Showing 40 changed files with 1,482 additions and 101 deletions.
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@
.mypy_cache/
data/
docker/
install.bash
venv/
14 changes: 2 additions & 12 deletions .github/workflows/pytest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,9 @@ on:
pull_request:
branches: ["master"]

permissions:
contents: read

jobs:
build:

test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- name: Set up Python 3.9
Expand All @@ -27,12 +22,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
pip install -r requirements.txt -r tests/requirements.txt
- name: Test with pytest
env:
MEMEBOT_DISCORD_CLIENT_TOKEN: ${{ secrets.MEMEBOT_DISCORD_CLIENT_TOKEN }}
MEMEBOT_TWITTER_CONSUMER_KEY: ${{ secrets.MEMEBOT_TWITTER_CONSUMER_KEY }}
MEMEBOT_TWITTER_CONSUMER_SECRET: ${{ secrets.MEMEBOT_TWITTER_CONSUMER_SECRET }}
run: |
pytest
pytest --tb=long -v
34 changes: 28 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,28 @@ Leaving variables empty just means that default values will be used.

## Tests

### pytest

Memebot has a suite of unit tests based on [`pytest`](https://pytest.org). The test code
is located in the [tests](./tests) directory. Running the tests is straightforward:

```shell
$ python3 -m pytest [/path/to/test/package/or/module]
```

Running the above from the root of the repository with no path(s) specified will run
all the tests.

The tests can also be run from the _test_ Docker image:

```shell
$ docker run --rm -it --entrypoint python3 memebot:test -m pytest [/path/to/test/package/or/module]

# OR

$ docker-compose run --rm --entrypoint python3 bot -m pytest [/path/to/test/package/or/module]
```

### mypy

Memebot uses static type checking from [mypy](http://mypy-lang.org) to improve code correctness. The config
Expand All @@ -90,31 +112,31 @@ To run mypy locally, ensure it is installed to the same python environment as al
Memebot dependencies, and then run it using the proper interpreter.

```shell
$ venv/bin/mypy src
$ venv/bin/mypy memebot

# OR

$ source venv/bin/activate
$ mypy src
$ mypy memebot
```

To run mypy in Docker, ensure you are using an image built from the `test` target.

```shell
$ docker run --rm -it --entrypoint mypy memebot:test src
$ docker run --rm -it --entrypoint mypy memebot:test memebot

# OR

$ docker-compose run --rm --entrypoint mypy bot src
$ docker-compose run --rm --entrypoint mypy bot memebot
```

You can speed up subsequent runs of mypy by mounting the `.mypy-cache` directory as a volume.
This way, mypy can reuse the cache it generates inside the container on the next run.

```shell
$ docker run --rm --volume "$(pwd)/.mypy_cache:/opt/memebot/.mypy_cache" --entrypoint mypy -it memebot:test src
$ docker run --rm --volume "$(pwd)/.mypy_cache:/opt/memebot/.mypy_cache" --entrypoint mypy -it memebot:test memebot

# OR

$ docker-compose run --rm --volume "$(pwd)/.mypy_cache:/opt/memebot/.mypy_cache" --entrypoint mypy bot src
$ docker-compose run --rm --volume "$(pwd)/.mypy_cache:/opt/memebot/.mypy_cache" --entrypoint mypy bot memebot
```
2 changes: 1 addition & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ services:
restart: always
volumes:
- ./data/db:/data/db
- ./src/config/mongod.yaml:/etc/mongo/mongod.yaml:ro
- ./memebot/config/mongod.yaml:/etc/mongo/mongod.yaml:ro
networks:
default:
dns:
Expand Down
5 changes: 3 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ RUN python3 -m pip install --no-cache-dir -r tests/requirements.txt
FROM python:3.9.6-slim as run-base

Check warning on line 23 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / build

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/

Check warning on line 23 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / build

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/
WORKDIR /opt/memebot/
ENV PATH="/opt/venv/bin:$PATH"
COPY src src
ENTRYPOINT ["python3", "src/main.py"]
COPY memebot memebot
ENTRYPOINT ["python3", "memebot/main.py"]

# Run release build.
FROM run-base as release

Check warning on line 30 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / build

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/

Check warning on line 30 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / build

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/
COPY --from=build /opt/venv /opt/venv

# Run test build.
FROM run-base as test

Check warning on line 34 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / build

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/

Check warning on line 34 in docker/Dockerfile

View workflow job for this annotation

GitHub Actions / build

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/
COPY tests tests
COPY --from=build-test /opt/venv /opt/venv
File renamed without changes.
48 changes: 26 additions & 22 deletions src/memebot.py → memebot/client.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import functools
import logging

import discord
import discord.ext.commands

import commands
import config
import db
import log
from integrations import twitter
from lib import exception, util
from memebot import commands
from memebot import config
from memebot import db
from memebot import log
from memebot.integrations import twitter
from memebot.lib import exception, util


async def on_ready() -> None:
"""
Determines what the bot does as soon as it is logged into discord
"""
memebot = get_memebot()
if not memebot.user:
raise exception.MemebotInternalError("Memebot is not logged in to Discord")
log.info(f"Logged in as {memebot.user}")
Expand All @@ -36,7 +38,7 @@ async def on_interaction(interaction: discord.Interaction) -> None:
"""
log.interaction(
interaction,
f"{util.parse_invocation(interaction.data)} from {interaction.user}",
f"{util.parse_invocation(interaction)} from {interaction.user}",
)


Expand All @@ -48,7 +50,7 @@ async def on_command_error(
log.exception(error)
return

invocation = util.parse_invocation(interaction.data)
invocation = util.parse_invocation(interaction)

if isinstance(error, exception.MemebotInternalError):
# For intentionally thrown internal errors
Expand Down Expand Up @@ -84,20 +86,22 @@ async def on_command_error(
)


memebot = discord.ext.commands.Bot(
command_prefix="!",
intents=discord.Intents().all(),
activity=discord.Game(name="• /hello"),
)
@functools.cache
def get_memebot() -> discord.ext.commands.Bot:
new_memebot = discord.ext.commands.Bot(
command_prefix="/",
intents=discord.Intents().all(),
activity=discord.Game(name="• /hello"),
)

memebot.tree.add_command(commands.hello)
memebot.tree.add_command(commands.poll)
memebot.tree.add_command(commands.role)
new_memebot.tree.add_command(commands.hello)
new_memebot.tree.add_command(commands.poll)
new_memebot.tree.add_command(commands.role)

memebot.add_listener(on_ready)
memebot.add_listener(on_interaction)
memebot.tree.error(on_command_error)
if config.twitter_enabled:
memebot.add_listener(twitter.process_message_for_interaction, "on_message")
new_memebot.add_listener(on_ready)
new_memebot.add_listener(on_interaction)
new_memebot.tree.error(on_command_error)
if config.twitter_enabled:
new_memebot.add_listener(twitter.process_message_for_interaction, "on_message")

run = memebot.run
return new_memebot
File renamed without changes.
File renamed without changes.
5 changes: 3 additions & 2 deletions src/commands/poll.py → memebot/commands/poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import discord.ext.commands
import emoji

from lib import constants, exception
from memebot.lib import constants, exception


@discord.app_commands.command(
Expand Down Expand Up @@ -35,7 +35,8 @@ async def poll(
yes_no = False
if len(choices) == 1:
raise exception.MemebotUserError(
f"_Only 1 choice provided. {interaction.command.qualified_name} requires either 0 or 2+ choices!_"
f"_Only 1 choice provided. {interaction.command.qualified_name} "
f"requires either 0 or 2+ choices!_"
)
elif len(choices) == 0 or [c.lower() for c in choices] in (
["yes", "no"],
Expand Down
30 changes: 21 additions & 9 deletions src/commands/role.py → memebot/commands/role.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from code import interact
from typing import Optional, Any, Union, Annotated
from typing import Optional, Any, Union

import discord

from lib import exception
from memebot.lib import exception


class RoleActionError(exception.MemebotUserError):
Expand Down Expand Up @@ -37,6 +36,18 @@ def __init__(self, action: str, target_name: str, *args: Any) -> None:
)


class RoleFailure(exception.MemebotInternalError):
def __init__(self, action: str, target_name: str, *args: Any) -> None:
"""
Unexpected, but handled internal failure
"""
super(RoleFailure, self).__init__(
f"Failed to {action} role `@{target_name}. "
f"It seems that Discord's API is having problems.",
*args,
)


class RoleLocationError(exception.MemebotUserError):
"""
Generic message for role commands which are executed in DMs
Expand Down Expand Up @@ -106,8 +117,8 @@ async def create(interaction: discord.Interaction, role_name: str) -> None:
)
except discord.Forbidden:
raise RolePermissionError("create", target_name)
except (discord.HTTPException, TypeError):
raise RoleActionError("create", target_name)
except discord.HTTPException:
raise RoleFailure("create", target_name)

await interaction.response.send_message(f"Created new role {new_role.mention}!")

Expand All @@ -133,7 +144,7 @@ async def delete(
except discord.Forbidden:
raise RolePermissionError("delete", target_role.name)
except discord.HTTPException:
raise RoleActionError("delete", target_role.name)
raise RoleFailure("delete", target_role.name)

await interaction.response.send_message(f"Deleted role `@{target_role.name}`")

Expand Down Expand Up @@ -161,7 +172,7 @@ async def join(
except discord.Forbidden:
raise RolePermissionError("join", target_role.name)
except discord.HTTPException:
raise RoleActionError("join", target_role.name)
raise RoleFailure("join", target_role.name)

await interaction.response.send_message(
f"{author.name} successfully joined `@{target_role.name}`"
Expand Down Expand Up @@ -192,7 +203,7 @@ async def leave(
except discord.Forbidden:
raise RolePermissionError("leave", target_role.name)
except discord.HTTPException:
raise RoleActionError("leave", target_role.name)
raise RoleFailure("leave", target_role.name)

await interaction.response.send_message(
f"{author.name} successfully left `@{target_role.name}`"
Expand All @@ -205,7 +216,8 @@ async def role_list(
target: Optional[Union[discord.Role, discord.Member]],
) -> None:
"""
List all roles managed by Memebot, or all members of a role managed by Memebot.
List all roles managed by Memebot, all managed roles of which a user is a member,
or all members of a role managed by Memebot.
"""
if not interaction.guild:
raise RoleLocationError
Expand Down
5 changes: 1 addition & 4 deletions src/config/__init__.py → memebot/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import os
import urllib.parse

from config import validators
from memebot.config import validators

# Discord API token
discord_api_token: str
Expand Down Expand Up @@ -148,6 +148,3 @@ def populate_config_from_command_line() -> None:
global database_uri
database_enabled = args.database_enabled
database_uri = args.database_uri


populate_config_from_command_line()
File renamed without changes.
1 change: 1 addition & 0 deletions src/config/validators.py → memebot/config/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Functions that will validate string input from the command line and convert them into appropriate data for use
by the config module.
"""

import collections
import logging
import logging.handlers
Expand Down
2 changes: 1 addition & 1 deletion src/db/__init__.py → memebot/db/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import config
from memebot import config
from .internals import DatabaseInternals

db_internals = DatabaseInternals()
Expand Down
2 changes: 1 addition & 1 deletion src/db/internals.py → memebot/db/internals.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pymongo as mongo

import config
from memebot import config


class DatabaseInternals:
Expand Down
File renamed without changes.
Loading

0 comments on commit 98b18b8

Please sign in to comment.