Better unit tests with pytest-mock and monkeypatch
Whenever we make changes to a system, it's essential to ensure that these alterations don't disrupt existing functionality and that they perform as intended. This underscores the critical role of testing in software development. Tests serve a dual purpose; not only do they validate the behaviour of the code, but they also serve as documentation, providing insights into how methods and classes are used. This documentation aspect is particularly valuable for fostering collaboration among developers and ensuring that your future self can understand and maintain the code.
One widely adopted testing framework for Python is pytest, which streamlines the process of creating and executing tests.
This post explores how unit tests can be written with pytest and its extension, pytest-mock, across various scenarios and levels of complexity. We begin with foundational tests and subsequently explore the concepts of patching and mocking. By understanding these concepts, we can elevate the quality of our unit tests and bolster the robustness of our code.
Please note that there are different ways to write tests in Python and that there are not clear guidelines on which tool to use for what. That is what motivated me to write this post, to document how I write tests in different scenarios.
You can find the code here if you want to follow along.
Basics of a test
Before delving into the specifics of unit testing with Python, let's take a moment to revisit the fundamental concepts of testing and its inherent structure. While software testing encompasses a broad spectrum of practices, this post focuses on unit testing, which targets individual units of code.
A typical test comprises four distinct phases:
Arrange - Preparation for the test, for example instantiating required objects.
Act - Perform the state-changing action for the test, for example calling the function we want to test.
Assert - Check the result of the state-changing action, for example the output of a function or the value of an attribute.
Cleanup - Removal of remains from the test so other tests are not influenced.
For more information see here. Now, let’s write some tests.
Pytest basic test
We begin with a basic test. We want to test the method double_pos_int()
from the FirstClass
class.
def double_pos_int(self, pos_value: int) -> int:
if pos_value <= 0:
raise RuntimeError("Pos value should be greater 0.")
return pos_value * 2
Because it takes and returns an integer it can be tested by calling it with integers and checking the outputs. To create a test in pytest
, we write a function in the form test_*.py
or *_test.py
. Additionally, I like to group related tests in classes like I did in the example in TestExampleBasic
. That said, let’s look at our first test.
def test_double_pos_val_pass(self):
first_class = FirstClass()
res = first_class.double_pos_int(2)
assert res == 4
Here, we simply assert the output in the last line.
If we want to perform this test with different input values we can parametrize the test with pytest. Here, we create three tests without code duplication
@pytest.mark.parametrize("val_in, val_should", [(1, 2), (3, 6), (10, 20)])
def test_double_pos_val_pass(self, val_in, val_should):
first_class = FirstClass()
res = first_class.double_pos_int(val_in)
assert res == val_should
Function which raises an exception
Our function is supposed to work on positive integers only which is why there is a check of the input value in the first line, which raises a RuntimeError
for invalid inputs. To test this codepath, we can call the method with an invalid value and assert that the exception was raised. To do so, we wrap the method call with pytest.raises(<Exception>)
def test_double_pos_val_fail(self):
first_class = FirstClass()
with pytest.raises(RuntimeError):
_ = first_class.double_pos_int(-1)
This test passes if the RuntimeError
is raised.
Making code modular with fixtures
So far we have written two tests (plus one with parametrization). In all of these test we perform the same setup. We initialize a first_class
object in the arrange step. To make the tests more modular, we can move this into a fixture and use it in the test.
A fixture is a feature in pytest which is used to set-up and tear-down resources for a tests. We create a fixture by defining a function and decorating it with @pytest.fixture
. To use it, just pass it as an argument to your test function. Then, the code of the fixture is executed first, and then the test. It is time for an example.
@pytest.fixture
def setup_first_class(self):
return FirstClass()
Now, we can re-write the first test like so
def test_double_pos_val_fixture_pass(self, setup_first_class):
res = setup_first_class.double_pos_int(2)
assert res == 4
This might not look like much, but if you imagine that the setup requires setting multiple attributes etc. this is very useful. Additionally, fixtures can import fixtures.
Parametrizing tests
We can also use fixtures in combination with parametrization. Then, we have to pass in the name of the fixture as string, and extract the value of the fixture in a separate step using request.getfixturevalue()
. Here is an example
@pytest.fixture
def input_data(self):
return {"val1": 1, "val2": 2, "val3": 3}
@pytest.mark.parametrize("input, expected", [("input_data", [1, 2, 3])])
def test_unpack_dict(self, setup_first_class, request, input, expected):
input_dict = request.getfixturevalue(input)
res = setup_first_class.unpack_dict(input_dict)
assert res == expected
Since this was a brief introduction to fixtures, it's important to note two additional aspects. First, fixtures can be configured with varying scopes, including function or class-level scopes, allowing flexibility in their application. Second, fixtures are invaluable when it comes to managing the cleanup phase of a test, a topic that is extensive enough to warrant a dedicated discussion of its own. For further insights on specifically this subject, see yield fixtures, and fixtures in general see the pytest documentation.
Patching and mocking
Up to this point, the methods we've examined have been independent of both internal and external functions or classes. However, real-world scenarios often involve dependencies, like network access or file operations. For instance, consider the following class:
class SecondClass:
def __init__(self, url):
self.url = url
def add_to_remote_number(self, num: int, key: str) -> Optional[int]:
"""Gets the number which corresponds to `key` from the endpoint and adds num to it."""
res = self._get_request(self.url)
remote_number = res.get(key)
return remote_number + num if remote_number else None
def _get_request(self, url: str) -> dict[str, int]:
"""Example return data: {'ten': 10, 'three': 3}"""
res = requests.get(url=url, params={"key": "value"})
res.raise_for_status()
return res.json()
In this scenario, our goal is to conduct a unit test for the add_to_remote_number()
method. This method relies on the _get_request()
method to retrieve a dictionary mapping strings to integers from a network endpoint. Subsequently, it extracts an integer from this dictionary and combines it with the integer provided as an argument to the method.
In the context of unit testing, it's important to avoid reliance on network calls due to their inherent slowness and the lack of control over external resources. To address this, we must patch the network call within the add_to_remote_number()
method and supply a predetermined response. This enables us to assess the logic of add_to_remote_number()
independently from the behaviour of _get_request()
.
In this context, patching refers to the dynamic replacement of a method or class at runtime.
In Python there are multiple ways to achieve this. I personally like to work with monkeypatch
and the mocker
fixture. The latter is available through the extension pytest-mock
, which is a wrapper around unittest.mock
. We will see both in action below. Note that there are no clear guidelines on when to use which.
It is time for some examples.
Patching with pytest-mock
To start, let’s replace the return value of _get_request()
with a dictionary {“three”: 3}
so that we can test add_to_remote_number()
. The package pytest-mock
provides us with the fixture mocker
which we can use to accomplish that.
To use mocker
, just pass it to your test as an argument. Then, you can call mocker.patch()
to patch a method like so.
def test_add_to_remote_number(self, mocker):
second_class = SecondClass("https://url.com/fake")
mocker.patch("my_code.SecondClass._get_request", return_value={"three": 3})
res = second_class.add_to_remote_number(5, "three")
assert res == 8
Note that the path to the function is the path to where you use it, not where the function is defined. For more information on which path to use and why see here.
Now that we have tested add_to_remote_number()
let’s test the method we have patched before next. We can patch request.get()
and return a fixed value. There are multiple ways to accomplish this. One way is to use mocker.Mock()
to mock the object.
def test_get_request_mocker_with_mock(self, mocker):
mock_get = mocker.patch("requests.get", return_value=mocker.Mock())
mock_get.return_value.json.return_value = {"a": 1}
mock_get.return_value.status_code = 200
second_class = SecondClass("https://url.com/fake")
res = second_class._get_request("fake")
assert res == {"a": 1}
We can also create a mock object by ourselves like this:
def test_get_request_mocker(self, mocker):
second_class = SecondClass("https://url.com/fake")
class MockGet:
def __init__(self):
pass
def raise_for_status(self):
pass
def json(self):
return {"a": 1}
mocker.patch("requests.get", return_value=MockGet())
res = second_class._get_request("fake")
assert res == {"a": 1}
I prefer using Mock()
over the second approach as it is more concise, but both versions are fine.
Patching with monkeypatch
Instead of using mocker
we can also test add_to_remote_number()
by patching with monkeypatch. While this makes the test very concise, it changes the behaviour of SecondClass
globally in the test environment during runtime, which we may not want as it might interfere with other tests.
To use monkeypatch we have to create a function which is called instead of _get_request()
, which allows us to return whatever we want. Here, we call it mock_request()
.
def test_add_to_remote_number_monkeypatch(self, monkeypatch):
second_class = SecondClass("https://url.com/fake")
def mock_request(*args):
return {"three": 3}
monkeypatch.setattr(SecondClass, "_get_request", mock_request)
res = second_class.add_to_remote_number(5, "three")
assert res == 8
So should I use mocker
or monkeypatch
? Given that using mocker
allows me to streamline the code when replacing a function, I tend to favour it over monkeypatch
for such situations. Additionally, the mocker fixture has many more features which we don't discuss here, for example call tracking, see documentation. However, this is my personal preference and there is no right or wrong way to do it here.
Testing file operations
As a general practice, I tend to avoid writing files during unit tests since this can potentially impact the test's speed, especially when dealing with large files or a high volume of write operations. Nevertheless, I'll provide an example of a csv_writer()
function along with a corresponding test. To ensure that the test maintains cleanliness, we use the tempfile library within a context manager, ensuring that any temporary files created are automatically cleaned up after the test is executed.
import tempfile
class ThirdClass:
# Omitted code
def write_to_csv(self, filename: str, data: list[list[str]]) -> None:
"""Writes data to a csv file."""
with open(filename, "w", encoding="utf-8") as filehandler:
writer = csv.writer(filehandler)
for row in data:
writer.writerow(row)
The test looks like this:
def test_write_to_csv(self):
third_class = ThirdClass()
data = [["A", "2"], ["B", "3"]]
with tempfile.TemporaryDirectory() as temp_dir:
filename = temp_dir + "my_file.csv"
third_class.write_to_csv(filename, data)
# Read file content
with open(filename, "r", encoding="utf-8") as filehandler:
reader = csv.reader(filehandler)
saved_data = []
for row in reader:
saved_data.append(row)
assert saved_data == data
After the file has been written, it is read back, and finally its content checked. Once the test has completed the file is removed thanks to tempfile
and the context manager.
Conclusion
In this post we have explored the creation of unit tests for scenarios of varying complexity. For basic functions that just return values and lack external dependencies, testing involves straightforward calls and validation of return values. However, when dealing with more complex functions, the need arises to temporarily substitute dependencies during testing. We have seen how to accomplish that with monkeypatch
and mocker.patch
from pytest-mock
.
It's important to note that there are multiple approaches to write tests in Python, and the choice of method often lacks clear-cut guidelines to dictate when one should be preferred over another. I hope with this post I can help you tackle your unit tests.
0 Comments