Lately, I have been answering some questions in StackOverflow related to Unit Testing in Python with Mock and pytest. These are some of the questions I answered:
Going through the list, there are some common pitfalls that can be identified related to the misunderstanding of basic python unit tests concepts and pytest features and inner workings. Inspired by a similar post I recently bumped into, I will go through them in detail.
What does patch do?
Everything in Python is an object. As detailed in the Python docs the patch decorator/context manager allows to easily mock classes/objects. Any class/object can be replaced with either a mock, or in general any other object, during the test and restored afterwards.
Therefore, patch allows to mock objects/calls in order to return predefined objects/constants/whatnot. This is really useful when testing system that interact with third parties, allowing for the removal of dependencies during tests.
Patching external dependencies
The following question, highlights precisely the aforementioned usage:
from .models import ApplicationType
def get_application_type(self, value):
item_name = "Application Type"
self.base_details['application_type'] = None
try:
if value:
try:
result = ApplicationType.objects.get(title=value) # <= How do I avoid hitting this DB object?
self.base_details['application_type'] = result.id
return True
except ApplicationType.DoesNotExist:
[...]
else:
self.error_msg = "Blank Value: {}".format(item_name)
return False
except:
raise
During unit-testing it is a requirement to avoid the interaction with a third-party service. In order to do so, we can use patch as follows (notice that this is for illustrative purposes and I do not necessarily agree with what and how it is being tested):
@pytest.mark.parametrize("entry", ['Type 1', 'Type 2'])
@patch('ApplicationType.objects.get')
def test_get_application_type_populates_dict_when_value_provided_exists_in_database(self, db_mocked_call, entry):
mocked_db_object = {'id': 'test_id'}
db_mocked_call.return_value = mocked_db_object
application_type = ApplicationTypeFactory.build(title=entry)
assert self.base_info_values.get_application_type(entry) == True
assert self.base_info_values.base_details["application_type"] is not None
In the test above, it can be seen that the call to the database is being patched. Furthermore, a dictionary, mocked_db_object
, will be returned when the call is made.
This approach yields complete control over the inputs and outputs of our test and allowing to deterministically validate the code. The usage of parametrize
is reviewed in detail further below.
However, this does not imply that the integration with third-party services should not be tested through integration tests as I detailed in my previous post about test driven APIs
A similar example is the following one in which the calls to the Redis database required patching:
redispool = None
class myRedis(object):
def __init__(self, redisHost, redisPort, redisDBNum):
[...]
global redispool
redispool = redis.ConnectionPool(host=self._redishost,
port=self._redisport,
db=self._redisdb)
def write_redis(self, key, value):
retval = self._redis_instance.set(key, value)
LOGGER.info('Writing data to redis (%s, %s). Retval=%s', key, value, retval)
return retval
@mock.patch('redis.StrictRedis.set')
def test_myRedis_write(mock_strict_redis_set):
mock_strict_redis_set.return_value = {}
myRedisObj = myRedis('localhost', '8888', '11')
redis_connect = myRedisObj.redis_connect()
connect = myRedisObj.write_redis('1', '2')
assert connect == {}
As the previous examples show, one of the most important concepts is knowing where to patch, which the linked docs explain quite clearly.
Finally, check the following question showing that checking the output of the calls being tested is not always required. Instead, what should be checked is that the calls took place with functions like called_with
or call_count
:
Preset input for Unit tests in Python 3
Testing exceptions:
One of the key behaviors to test are exception handling. The way to test that exceptions are raised is by patching the call that can raise the exception. This can be achieved through the side_effect
function.
The next example shows how to force raising an exception to test that it is properly managed.
def validate_json_specifications(path_to_data_folder, json_file_path, json_data) -> None:
schema_file_path = os.path.join(path_to_data_folder, "schema", os.path.basename(json_file_path))
resolver = RefResolver('file://' + schema_file_path, None)
with open(schema_file_path) as schema_data:
try:
Draft4Validator(json.load(schema_data), resolver=resolver).validate(json_data)
except ValidationError as e:
print('...')
exit()
The non-working tests for this code were as follows:
@patch('builtins.open', mock_open(read_data={}))
@patch('myproject.common.helper.jsonschema', Draft4Validator())
def test_validate_json_specifications(mock_file_open, draft_4_validator_mock):
validate_json_specifications('foo_path_to_data', 'foo_json_file_path', {})
mock_file_open.assert_called_with('foo_path_to_data/schema/foo_json_file_path')
draft_4_validator_mock.assert_called()
The person asking was trying to use the patch wrong and not taking advantage of side_effect
. As it can be seen, every time the jsonschema
function of the module being tested was called, a Draft4Validator
object would have been created (had the class been correctly instantiated).
Instead, the Draft4Validator object is the one to be mocked and the relevant calls to any of its methods the ones to be patched. An example of such way to proceed can be found in the answer I posted:
@patch('sys.exit')
@patch('myproject.common.helper.jsonschema.Draft4Validator')
@patch('builtins.open')
def test_validate_json_specifications(mock_file_open, draft_4_validator_mock, exit_mock):
with pytest.raises(ValidationError):
mock_file_open.return_value = {}
draft_4_validator_mock = Mock()
draft_4_validator_mock.side_effect = ValidationError
validate_json_specifications('foo_path_to_data', 'foo_json_file_path', {})
assert draft_4_validator_mock.call_count == 1
assert draft_4_validator_mock.validate.assert_called_with({})
assert exit_mock.call_count == 1
In the previous test we can check again how easy it is to test the expected path for the exception through the creation of a mock object and associating a side_effect
to it.
pytest basic features:
Pytest is a very powerful testing framework and knowing about the feaures that it provides helps creating a robust and concise testing architecture for your project.
Some of the most common used features are the following:
Testing that exceptions are raised
Fortunately, pytest provides powerful features to test that exceptions are handled properly.
The next example shows how to force raising an exception and test that it actually raised:
class MyRequest(metaclass=Singleton):
def __init__(self, retry_tries=3, retry_backoff=0.1, retry_codes=None):
[...]
def request(self, request_method, request_url, **kwargs):
try:
return self.session.request(method=request_method, url=request_url, **kwargs)
except Exception as ex:
log.warning([...]])
raise
The test could look something similar to the following:
from requests.exceptions import ConnectTimeout, ReadTimeout, Timeout
from unittest.mock import patch
import pytest
class TestRequestService:
@pytest.mark.parametrize("expected_exception", [ConnectTimeout, ReadTimeout, Timeout])
@patch('path_to_module.MyRequest')
def test_custom_request(self, my_request_mock, expected_exception):
my_request_mock.request.side_effect = expected_exception
with pytest.raises(expected_exception):
my_request_mock.request(Mock(), Mock())
[...]
Notice how easily side_effect
allows for the testing of the proper management of the exception.
pytest parametrize:
One of the most sought after behaviors when testing is actually testing the correctness of a myriad of relevant inputs/outputs. For this purpose, pytest provides a feature that yields such flexibility in a compact way through parametrize
. The following questions are scenarios in which parametrize
is highly relevant:
def is_equal(a, b):
assert a == b
We can test such a simple scenario with some tests:
import pytest
class TestComplexScenario:
@pytest.mark.parametrize("my_integer", [0, 1, 2])
def test_complex(self, my_integer):
assert is_equal(my_integer, my_integer)
The sample output looks like this:
test_complex.py::TestComplexScenario::test_complex[0] PASSED
test_complex.py::TestComplexScenario::test_complex[1] PASSED
test_complex.py::TestComplexScenario::test_complex[2] PASSED
where it can be checked that all the inputs were tested.
Other references: