Python Automated Test
Posted on: 2025-03-05
I wrote about 200 automated (unit and integration) tests in the last few months in my Python Discord Bot, and I am delighted by the experience. The best is the extremely low friction to mock using pytest
and unit test.mock
.
Mocking Function
Mocking a function is as simple as using @patch.object
and the module (file) that contains the function to mock. The __name__
is used to get the function's name to mock and it helps when renaming using the refactoring tool of your editor. Here is an example that mocks two functions of the module bet_functions
:
@patch.object(bet_functions, bet_functions.data_access_create_bet_user_wallet_for_tournament.__name__)
@patch.object(bet_functions, bet_functions.data_access_get_bet_user_wallet_for_tournament.__name__)
def test_get_wallet_for_tournament_no_wallet_create_one(mock_get_user_wallet_for_tournament, mock_create_bet):
"""
Test the user scenario that a user does not have a wallet
"""
# Arrange
tournament = BetUserTournament(67, 44, 12, 100)
mock_get_user_wallet_for_tournament.side_effect = [None, tournament]
mock_create_bet.return_value = tournament
# Act
get_bet_user_wallet_for_tournament(44, 12)
# Assert
mock_create_bet.assert_called_once_with(44, 12, DEFAULT_MONEY)
mock_get_user_wallet_for_tournament.assert_called()
assert (
mock_get_user_wallet_for_tournament.call_count == 2
) # One time for the None and one time for the created wallet
Each @patch.object
decorator injects an object into the test function. You can use side_effect
to specify the result for each invocation.
mock_get_user_wallet_for_tournament.side_effect = [None, tournament]
The mock of the get user waller returns None
the first time and tournament
the second time. The assertion on the mock is also simple by calling the mock one of the many assertion methods. The test above calls three different assertions.
Mocking Time
Time is always tricky to test. I found along my journey as a developer that if you can have a wrapper around a function that returns the time, it simplifies testing. However, you can easily mock the date or datetime module using Python.
The format is different, but use the same idea that you specified for the module. This time, you can do it directly inside the test and use the with
statement to make sure that the mock is only for the test. Here is an example that mocks the datetime
module. Then, within the test you specify the value you want to return. For example, I was using the now
of the datetime
module to get the current time. I can replace the value from a test to another by creating an instance of datetime
and setting the value I want to return. The following test mocks the time used by two different functions with different times.
async def test_daily_registration_message_tournament_available_but_no_space():
"""
Create few tournaments and make sure the command to retrieve the registration works
"""
register_date_start = datetime(2024, 11, 1, 12, 30, 0, tzinfo=timezone.utc)
date_start = datetime(2024, 11, 2, 10, 30, 0, tzinfo=timezone.utc)
date_end = datetime(2024, 11, 3, 20, 30, 0, tzinfo=timezone.utc)
tournament_id = data_access_insert_tournament(
GUILD_ID,
"My Tournament",
register_date_start,
date_start,
date_end,
9,
2,
"villa,clubhouse,consulate,chalet,oregon,coastline,border",
)
with patch("deps.tournaments.tournament_data_access.datetime") as mock_datetime:
with patch("deps.tournaments.tournament_functions.datetime") as mock_datetime2:
mock_datetime.now.return_value = register_date_start
mock_datetime2.now.return_value = register_date_start
# Act
# ...
# Assert
# ...
Running and Debugging
Always a struggle using TypeScript with Node because of the transpilation and sometimes the breakpoint cannot be hit. With Python and VsCode, I had no issue since few months. Here is my VsCode configuration (settings.json):
{
"python.analysis.extraPaths": [
"./deps"
],
"python.testing.unittestArgs": [
"-v",
"-s",
"./tests",
"-p",
"*test.py"
],
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
"python.testing.pytestArgs": [
"tests"
]
}
I can run all unit tests with :
pytest -v -s ./tests/*_unit_test.py
All integration tests with:
pytest -v -s ./tests/*_integration_test.py
Get code coverage with:
coverage run --omit="./tests/*" -m pytest -v -s ./tests/*unit_test.py && coverage html
And debug is clicking the green triangle next to the function. It can run or debug to step in. The low friction makes the creation of the test easy and actually encourages the creation.
Database (system-test)
Few tests require the database because they integrate functions that load and save in the database. These tests are in a file with the suffix _integration_test.py
. The database is a SQLite database that is created and destroyed for each test. The database could be in memory, but it is not. The reason is it is easier to debug in case of failure by inspecting what is in the database. Each integration test file has a setup_and_teardown
fixture that sets the manager to the test copy database and a function to delete the database context. The database is created by the pytest
fixture db
that is used in the test.
@pytest.fixture(autouse=True)
def setup_and_teardown():
"""Setup and Teardown for the test"""
# Setup
database_manager.set_database_name(DATABASE_NAME_TEST)
delete_all_tables()
# Yield control to the test functions
yield
# Teardown
database_manager.set_database_name(DATABASE_NAME)
Conclusion
Working with Python has its pros and cons. The typing is far from excellent, but the ecosystem, large community, and testing are excellent.