Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add unit tests #161

Merged
merged 26 commits into from
Nov 13, 2024
Merged

Add unit tests #161

merged 26 commits into from
Nov 13, 2024

Conversation

super-cooper
Copy link
Owner

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

@super-cooper super-cooper self-assigned this Jun 7, 2023
@super-cooper super-cooper requested a review from nhawke June 7, 2023 11:05
Copy link
Collaborator

@nhawke nhawke left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When trying to run this, i ran into an issue trying to install emoji 2.0 (per requirements.txt) but i was able to install 2.8. We might need to bunp that version in the requirements.

if TYPE_CHECKING:
from discord.types.interactions import InteractionData
pass
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can just remove this if entirely we don't need the type-check-specific import

memebot/main.py Outdated

import config
import memebot
config.populate_config_from_command_line()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to go before the other imports or in main.py at all? Could this be an import side effect by putting this in the config module's init.py (or wherever's most appropriate)?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modified the client to be created lazily on first use, rather than at module import. Should prevent us from having to worry about side effects, similar to what was done with the logging package.

await commands.hello.callback(mock_interaction)

mock_interaction.response.send_message.assert_awaited_once_with(
f"Hello, {mock_interaction.user.display_name}!"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think this is right. The Mock here is empty, so the mock_interaction.user.display_name will also be empty. While this test technically produces the right result, I think it would be better to populate that field so we can ensure the sent message actually accesses the property rather than something else that gives a None.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the name would have been a unique MagicMock, but set it to a string.

@pytest.mark.parametrize(
["a1", "a2", "a3", "a4", "a5"],
# Create a matrix of all possible answer inputs that contain only 1 real answer
[["Mock answer" if i == j else None for i in range(5)] for j in range(5)],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you expand this out so the test cases are more explicit?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. (Thanks ChatGPT)

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]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't running all the tests for me:

$ docker run --rm -it --entrypoint python3 memebot:test -m pytest
================================================= test session starts ==================================================
platform linux -- Python 3.9.6, pytest-7.1.2, pluggy-1.0.0
rootdir: /opt/memebot
collected 1 item

tests/test_pytest.py .                                                                                           [100%]

================================================== 1 passed in 0.01s ===================================================

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by copying the tests into the test image

Instead of creating the client on load of `client.py` module, the client is instead instantiated with the first call to a new
function `get_memebot()`. This eliminates side effects related to module load order.
@super-cooper super-cooper requested a review from nhawke May 6, 2024 00:29
This was referenced Sep 3, 2024
[None, None, "Mock answer", None, None],
[None, None, None, "Mock answer", None],
[None, None, None, None, "Mock answer"],
],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each of these should be parameters at the top level, not nested inside an array.

Also you can lose the comment because the cases are self-explanatory now that the list comp is gone.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each of these should be parameters at the top level, not nested inside an array.

What is meant by this? pytest.mark.parameterize accepts a list of parameter configurations for the test.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh my bad, parameterize just has a really stupid interface. Shoulda known better than to assume anything in python testing was sane...

Comment on lines 31 to 44
async def do_role_test(
mock_interaction: mock.Mock,
mock_guild: Optional[mock.Mock],
command: discord.ext.commands.Command,
*args: Any,
**kwargs: Any,
) -> None:
"""
Helper function for common role test pattern
"""
mock_interaction.guild = mock_guild

await command.callback(mock_interaction, *args, **kwargs)
mock_interaction.response.send_message.assert_awaited_once()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't need to be a helper. Better to explicitly do this in each case that uses it so it's more obvious what's happening in the test.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

"""
Test failure to create a role with an invalid name
"""
await do_role_create_failure(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There shouldn't be this many layers of indirection for a test. It makes it hard to understand what the test is doing. Better for test logic to be repetitive but explicit.

Same goes for the other tests in this file. Helpers would make sense for test setup but for the critical logic section of the test it should just be explicity in the test funciton.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is why I hate writing tests.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are this and the twitter fixture actually used anywhere? I might just be missing something but I don't see the usage.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are. Fixtures are a pytest feature and they get imported and used automatically. For example, the fixture defined by

def mock_discord_client(MockClient: type[mock.Mock]) -> mock.Mock:
    return MockClient()

gets called and the return value passed to every test or fixture which has a parameter with the name mock_discord_client. It's a simple way of defining repeatable test fixtures without needing to call helper functions to use them all in every test.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love implicit behavior and mocks !! Python tests and readability remain enemies


mock_interaction.response.send_message.assert_awaited_once()
do_poll_embed_test(mock_interaction, question, expected_fields)
await do_poll_votes_test(mock_interaction, [], yes_no=True)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this test be combined with the other test which uses these helpers? then the helpers can just be inlined to avoid test logic indireciton.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather not. The test entrypoints exist to clarify what is being tested. If we combined them, we'd just need to gate it all with if-statements anyway. I think this is fine. I'm also gonna delete the poll command and all the tests after we merge this lmao

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree but moot anyway

Copy link
Owner Author

@super-cooper super-cooper left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed issues. Dependency stuff can get resolved after this is merged. Could be a python version incompatibility on your end, but once tests are merged, I can enable dependabot for Python packages which should handle issues like this going forward.

[None, None, "Mock answer", None, None],
[None, None, None, "Mock answer", None],
[None, None, None, None, "Mock answer"],
],
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each of these should be parameters at the top level, not nested inside an array.

What is meant by this? pytest.mark.parameterize accepts a list of parameter configurations for the test.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are. Fixtures are a pytest feature and they get imported and used automatically. For example, the fixture defined by

def mock_discord_client(MockClient: type[mock.Mock]) -> mock.Mock:
    return MockClient()

gets called and the return value passed to every test or fixture which has a parameter with the name mock_discord_client. It's a simple way of defining repeatable test fixtures without needing to call helper functions to use them all in every test.


mock_interaction.response.send_message.assert_awaited_once()
do_poll_embed_test(mock_interaction, question, expected_fields)
await do_poll_votes_test(mock_interaction, [], yes_no=True)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather not. The test entrypoints exist to clarify what is being tested. If we combined them, we'd just need to gate it all with if-statements anyway. I think this is fine. I'm also gonna delete the poll command and all the tests after we merge this lmao

Comment on lines 31 to 44
async def do_role_test(
mock_interaction: mock.Mock,
mock_guild: Optional[mock.Mock],
command: discord.ext.commands.Command,
*args: Any,
**kwargs: Any,
) -> None:
"""
Helper function for common role test pattern
"""
mock_interaction.guild = mock_guild

await command.callback(mock_interaction, *args, **kwargs)
mock_interaction.response.send_message.assert_awaited_once()
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

"""
Test failure to create a role with an invalid name
"""
await do_role_create_failure(
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is why I hate writing tests.

Copy link
Collaborator

@nhawke nhawke left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks python to me


mock_interaction.response.send_message.assert_awaited_once()
do_poll_embed_test(mock_interaction, question, expected_fields)
await do_poll_votes_test(mock_interaction, [], yes_no=True)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree but moot anyway

[None, None, "Mock answer", None, None],
[None, None, None, "Mock answer", None],
[None, None, None, None, "Mock answer"],
],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh my bad, parameterize just has a really stupid interface. Shoulda known better than to assume anything in python testing was sane...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love implicit behavior and mocks !! Python tests and readability remain enemies

@super-cooper super-cooper merged commit 98b18b8 into master Nov 13, 2024
11 checks passed
@super-cooper super-cooper deleted the unit-test branch November 13, 2024 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

Add automated testing infrastructure
2 participants