Mocking in python tests: practical examples

What is mocking when it comes to python tests? Mocking is a way to simulate and control the behavior of functions or objects. Typically this type of control is necessary when unit testing code that deals with systems external to our own code.

Why mock external systems?

The main issue is with external systems unpredictability, we have limited control over the state of these external systems, making tests potentially very brittle.

For example when code reads data from disk or a database. If there is a piece of functionality that reads and modifies files on disk we would have to create a isolated location for the files needed for the test and have a way to restore them to the pre-test state. This is feasible on a small scale but hard to maintain if something needs to change in the set up. It is much easier to simulate the function that reads or writes with a mock so the tests can focus on testing the logic in the code surrounding those disk operations.

We will be using the mock class from the unittest library extensively in these examples. For more information on the mock class, click here.

You can find a repository with the examples of mocking in python tests for this article on my github, here.

Examples

Let us go through some examples of mocking in python tests to get a better understanding about why mocking is useful. The following are some examples of how to mock certain common external systems.

Example files

In following examples we’re going to test functions for a simple project with a manager.py file, containing the main entry point, and a file_manager.py file, containing some operations.

manager.py:

import sys

from src import file_utils    

def main():    
    concatenated_text = file_utils.concat_files_text(".y", ".txt")

    if not concatenated_text:
        return None
    return len(concatenated_text)

def init():
    if __name__ == "__main__":
        sys.exit(main())

init()

Notice the init() function which wraps around the commonly used if __name__ == "__main__:" code. Using a function to wrap these lines of code allows us to use it in a unit test, and achieve 100% code coverage. This would otherwise not be possible, if if __name__ == "__main__:" sits outside of a function.

file_utils.py:

from pathlib import Path

def get_file_text(file_path: str) -> str:
    file_text = None
    with open(file_path, "r") as file:
        file_text = file.readlines()

    return file_text

def concat_files_text(path: str, extension: str) -> str:
    file_names = sorted(Path(path).rglob(f"*{extension}"))

    if not file_names:
        return None

    texts = []
    concated_texts = ""
    for file_path in file_names:
        texts.append(get_file_text(file_path))

    concated_texts = " ".join([str(text) for text in texts])
    return concated_texts

def append_user_to_list(username: str, usernumber: int, file_path: str):
    line_to_save = "{}: {}".format(usernumber, username)

    with open(file_path, "a") as file:
        file.write(line_to_save)

We will now examine how we can test all these functions. Since these functions deal with file operations we have to use mocking to isolate the code we want to test from external systems. Often mocking is used to mock a function that goes out to an external system to retrieve some data and return that data. Since we do not want to go out to an external system for our unit tests, we have to create a fake function to replace the real function and return predetermined data or a value.

Mocking function return value

The first module we will test is the manager.py module. This module calls functions from file_manager.py but what if we only want to test the functions in manager.py and do not want to touch anything from file_manager.py? We can mock the functions and return values of the file_manager.py functions. First to test the init() function.

test_manager.py:

from src import manager
from unittest import mock
import pytest

def test_manager_init():
    with mock.patch.object(manager, "main", return_value=1) as mock_main:
        with mock.patch.object(manager, "__name__", "__main__"):
            with mock.patch.object(manager.sys, "exit") as mock_exit:
                manager.init()

                mock_main.assert_called()
                assert mock_exit.call_args[0][0] == 1

Here we’re mocking the main function and giving it a return value of 1. Then, we patch the __name__ object to have the value __main__, and finaly we patch the exit function on the sys library in manager.py. Important with mocking is that we indicate the correct path to the object or function relative to the location it is being used. Since we want to mock the exit function on the sys in manager.py we have to specify manager.sys there.

Using with blocks here ensures that the objects are only mocked within the scope of the with blocks.

We then call the function we want to test the behavior of. Finally, we assert that the main function was called and the correct exit value was returned.

Since we used a mock for the main() function it is not actually tested, so, let us write a test to cover main() now:

@mock.patch("src.manager.file_utils.concat_files_text")
def test_manager_main(mock_concat_files_text):    
    mock_concat_files_text.return_value = "pie"

    actual_result = manager.main()
    expected_result = 3

    mock_concat_files_text.assert_called()
    assert actual_result == expected_result

@mock.patch("src.manager.file_utils.concat_files_text")
def test_manager_main_no_files(mock_concat_files_text):
   mock_concat_files_text.return_value = None
   actual_result = manager.main()
   expected_result = None

   mock_concat_files_text.assert_called()
   assert actual_result is expected_result

For this test we mocking a function for the scope of the while test, using a decorator function. Here, we mock the concat_files_text function from the file_utils module. Again, in order to isolate the logic in manager.py for our test. We set the return value and then call the main() function.

Because there is a conditional statement there are two paths the logic can take. Therefor we can cover both tests by writing two tests. Having to write tests for each path like this is not very efficient, to help us out we can use pytest parametrize:

@pytest.mark.parametrize("concat_files_text_return, expected_result", [("pie", 3), (None, None)])
@mock.patch("src.manager.file_utils.concat_files_text")
def test_manager_main(mock_concat_files_text, concat_files_text_return, expected_result):    
    mock_concat_files_text.return_value = concat_files_text_return

    actual_result = manager.main()    

    mock_concat_files_text.assert_called()
    assert actual_result == expected_result

With parametrize we have to define the variable names the test parameters will be mapped to in a comma separated string, and then set the values in an array of tuples. We then have to add the parameters to the test function definition. Having done all that we can easily use the values in our test.

Mocking function return values with “side effect”

We do not always want to return the same value from a mocked function. When we want to mock a function that returns a different value or object based on an input parameter due to some condition inside that function. We can use the “side effect” functionality. The “side effect” allows us to replace one function body with a function body made for testing purposes.

Let us consider the following code from file_utils.py:

from pathlib import Path

def get_file_text(file_path: str) -> str:
    file_text = None
    with open(file_path, "r") as file:
        file_text = file.readlines()

    return file_text

def concat_files_text(path: str, extension: str) -> str:
    file_names = sorted(Path(path).rglob(f"*{extension}"))

    if not file_names:
        return None

    texts = []
    concated_texts = ""
    for file_path in file_names:
        texts.append(get_file_text(file_path))

    concated_texts = " ".join([str(text) for text in texts])
    return concated_texts
...

The function concat_files_text gets a list of file names, calls get_file_text to get the text from the files, and then concatenates the texts. We want to mock retrieving the file names (rglob) and getting the text from the files (get_file_text). As these are not relevant for testing the logic of the concat_files_text.

We can mock rglob the same way we did before with mock.patch and setting return_value. There is also get_file_text which will return the text from a file, so this could be a different result for different files. In order to deal with this, we will use a side effect function here to simulate that effect:

test_file_utils.py:

from src import file_utils
from unittest import mock


def get_file_text_side_effect(file_path: str) -> str:
    file_texts = {
        "test1.txt": "hi\ntester",
        "test2.txt": "ho"
    }
    return file_texts.get(file_path)

def test_concat_file():
    with mock.patch("src.file_utils.Path.rglob", return_value=["test1.txt", "test2.txt"]):
        with mock.patch("src.file_utils.get_file_text", side_effect=get_file_text_side_effect) as mock_get_file_text:

            expected_result = "hi\ntester ho"
            actual_result = file_utils.concat_files_text("./data", ".txt")

            mock_get_file_text.assert_has_calls([mock.call("test2.txt"),mock.call("test1.txt")], any_order=True)
            assert expected_result == actual_result

The get_file_text_side_effect function is what will replace the real get_file_text as we can see with the line with mock.patch("src.file_utils.get_file_text", side_effect=get_file_text_side_effect):. We use a dictionary with file names as keys to simulate retrieving text for different files.

We have taken care of the test set up, but how are we going to assert some meaningful things about the test results? Other than asserting that the return value of the function is what we expect, we call also check that certain functions that are called inside the function we are testing, are called with the expected values. We will do that for get_file_text which is being mocked. Since get_file_text is going to be called twice, we have to use assert_has_calls with the option any_order=True.

Mocking disk operations

Often our python programs will read files or write to files on disk. If we actually had tests writing files to disk, it could get messy if we don’t clean up properly. As mentioned, using mocking in python tests avoids situations like that. Let us take a look at the append_user_to_list function in file_utils.py. This function does some data manipulation, and then saves a file to disk:

def append_user_to_list(username: str, usernumber: int, file_path: str):
    line_to_save = "{}: {}".format(usernumber, username)

    with open(file_path, "a") as file:
        file.write(line_to_save)

We have a function here that creates a new text string from parameters and then writes the string to disk.

How are we going to test this function? It has no return value. The main logic we are interested in is that line_to_save is constructed properly. Which should get written to the file. However, we do not want to create a file on disk every time we run our tests.

The following test code shows an example of how this function can be tested using mocks:

from src import file_manager
from unittest import mock

def test_append_user_to_list():
    with mock.patch("builtins.open", mock.mock_open()) as mocked_open:
        actual_result = file_manager.append_user_to_list("testuser", "2","test1.txt")

        mocked_open.assert_called_once_with("test1.txt", "a")
        handle = mocked_open()
        handle.write.assert_called_once_with("2: testuser")

We patch the open function and essentially replace it with mock_open. Which is a helper function for replacing open. Again, the use of a with block here will ensure that the mock gets cleaned up after the test has run. Mocks like this have to be cleaned up, or else they can affect other tests.

When open is called now, the mock function gets called instead and nothing actually happens on disk. The mock object (mocked_open) then allows us to examine aspects of what happened with it in the call. For example we can determine how many times it was called and what the parameters were.

In the test we assert that the function was called only once with a specific set of parameters. We then get a handle to the file handler object and can check to see what parameters write() was called with. This allows us to verify that the logic of the function produces the expected text string for writing to a file.

Mocking datetime

Another common external system that is often used in code is the system clock. When writing data to disk or to a database timestamps are often used to keep track of when something was created or updated, etc.

If a test compares expected output to actual output of functions that deal with time this can cause problems. We want our scenario to be based on reproducable values. In other words, the values being tested and compared should stay the same everytime the test runs. In such a scenario mocking can again help us in our python tests. Let us look at an example.

First we will add a module time_manager.py in our project and the code:

from datetime import datetime, timedelta

def get_future_date(days_in_future: int) -> datetime:    
    future_date = datetime.utcnow() + timedelta(days=days_in_future)

    return future_date

In the get_future_date function, we return a date and time, x number of days in the future, based on the current utc date and time.

How would we set up a test scenario for this? We could try something like this:

from datetime import datetime

from src import time_manager

def test_get_future_date():
    expected_result = datetime(2020,9,11)
    actual_result = time_manager.get_future_date(2)

    assert actual_result == expected_result

Even if the date of today is 2020-09-09. This test will fail because the expected result has the time set to 0, while the actual resulting datetime object has the time component set to the current time down to the miliseconds. We could compare only the date components and ignore the time component in asserts for the test, but the test will obviously still break if it is ran any day other than 2020-09-09. We could use utcnow() and add days in our test set up to set the expected result, but that would essentially be replicating the functionality we’re trying to test.

To make things easier when dealing with this problem we are going to use an external library: freezegun.

Install freezegun now with the following command:

pip install freezegun

This library allows us to decorate a test and freeze time to a specific date and time.

With freezegun we can set up the test as follows:

from datetime import datetime

from freezegun import freeze_time
import pytest

from src import time_manager

@pytest.mark.parametrize("test_input, expected_result", [(2,datetime(2020,9,6)),(4, datetime(2020,9,8))])
@freeze_time("2020-09-4")
def test_get_future_date(test_input, expected_result):
    actual_result = time_manager.get_future_date(test_input)

    assert actual_result == expected_result

Here we use freeze_time from the freezegun package to set a specific date for this test, all datetime return values will be set to this date for this test. We can now easily test multiple scenarios for our datetime based function using pytest parameters.

Conclusion

We have seen some practical examples of how to use unittest.mock for mocking things in python tests, such as file operations and how to handle date time values in unit tests. We also learned about using mocked return values and side effect functions.

You can find a repository with the examples of mocking in python tests for this article on my github, here.

For information on the basics of running tests, read the article on this blog: “Continuous testing python code with pytest: how to“.

Leave a Reply

Your email address will not be published. Required fields are marked *