Unit-Testing With unittest.mock.patch()

Sometimes, you need to unit-test functions that call functions from the standard library that rely on side effects. In this post, I show a way of doing so in Python with unittest.mock.patch(). More specifically, we implement two context managers that use os.chdir() to perform actions in the given directory, and show a way of unit-testing them without relying on the file system.

Our Task

Consider the following function. It is a context manager that uses os.chdir() to perform actions in the given directory.

import contextlib
import os

@contextlib.contextmanager
def chdir(dir):
    """A context manager that performs actions in the given directory."""
    orig_cwd = os.getcwd()
    os.chdir(dir)
    try:
        yield
    finally:
        os.chdir(orig_cwd)

It simply stores the current working directory by calling os.getcwd(), changes the directory to the given directory by calling os.chdir(), and returns to the original working directory afterwards. To simplify the implementation, we utilize the contextlib.contextmanager decorator. In this way, we do not have to create a full-blown context manager (a class having the __enter__() and __exit__() methods).

The context manager may be used in the following way:

import os
print(os.getcwd())     # Prints e.g. "/"
with chdir('/tmp'):
    print(os.getcwd()) # Prints "/tmp"
print(os.getcwd())     # Prints "/"

Our task is to write unit tests for it that do not rely on the file system. That is, we want to be able to test it without a need of creating directories, then creating files in the with‘s body, and then checking that the files were created. This speeds up the tests and make them more robust as they do not rely on the file system (existence of directories, permissions, etc.).

Unit-Testing the Context Manager

First, we create a working skeleton of the unit tests:

import os
import unittest
from unittest import mock

from chdir import chdir

@mock.patch('os.chdir')
class ChdirTests(unittest.TestCase):
    """Tests for the chdir() context manager."""

    def setUp(self):
        self.orig_cwd = os.getcwd()
        self.dst_dir = 'test'

As you can see from the code, we utilize the standard unittest.mock module that is available since Python 3.3. More precisely, we use the unittest.mock.patch() decorator. In this way, in every test, we get a mocked instance of os.chdir, which we can setup and test our assertions.

As we will need the original current working directory and some destination directory in every test, we create them in the setUp() method, which is called prior to executing each test. Notice that we call os.getcwd() from the standard library, not a mocked version. Indeed, we have only mocked os.chdir(). We could have mocked also os.getcwd() if we wanted, but it is not necessary as there is always some working working directory and we do not really care which one is it. What we need is store it so we can use it later.

In the first test, we check that os.chdir() is called with the given directory upon entering the with‘s code block:

    def test_os_chdir_is_called_with_dst_dir_in_entry(self, mock_chdir):
        with chdir(self.dst_dir):
            mock_chdir.assert_called_once_with(self.dst_dir)

What it means is that when we use chdir(dir), the directory is entered. As you can see, apart from self, we also get a mock for os.chdir(). We call our context manager, and inside it’s code block, we validate that it was called once with the given destination directory.

In the second test, we validate that when the block for chdir(dir) ends, the original working directory is restored. In other words, we check that os.chdir() is called after the code block with the original working directory:

    def test_os_chdir_is_called_with_orig_cwd_in_exit(self, mock_chdir):
        with chdir(self.dst_dir):
            mock_chdir.reset_mock()
        mock_chdir.assert_called_once_with(self.orig_cwd)

Since the check that os.chdir() is called with the given directory upon entering the with‘s code block has already been tested in the previous test, we do not have to test this again. It is generally best to make tests validate a single assertion so when a test fails, there is only one reason for that fail. Therefore, we reset the mock in the with statement’s body. After the block ends, we assert that os.chdir() was called with the original working directory.

In the last test, we check that we return to the original working directory even if an exception is raised within the with statement’s body:

    def test_os_chdir_is_called_with_orig_cwd_in_exit_even_if_exception_occurs(
            self, mock_chdir):
        try:
            with chdir(self.dst_dir):
                mock_chdir.reset_mock()
                raise RuntimeError
        except RuntimeError:
            mock_chdir.assert_called_once_with(self.orig_cwd)

Once again, we reset the mock inside the body. After that, we raise an exception, and in the except clause, we validate that os.chdir() was called with the original working directory.

Another Implementation of the Context Manager

To see that our unit tests do not rely on a particular implementation, we may code another chdir() context manager, now without using contextlib.contextmanager:

class chdir():
	"""An alternative implementation of chdir() using a full-blown context
	manager.
	"""

    def __init__(self, dir):
        self.dir = dir

    def __enter__(self):
        self.orig_cwd = os.getcwd()
        os.chdir(self.dir)

    def __exit__(self, *exc_info):
        os.chdir(self.orig_cwd)

If you run the original tests with this new implementation, you will see that everything works as expected.

Final Notes

  • All the source code is available on GitHub.
  • Generally, instead of using unittest.mock.patch(), it is better to utilize Dependency Injection (DI) whenever possible. The reason is that by using dependency injection, we explicitly say that the class or function under test uses the given object to do its job, which leads to less fragile unit tests. However, as we have seen from the example above, patching sometimes may be a suitable way.

Leave a Comment.