Python and the Infinite void of Mocks in Unit Test

THIS ENTIRE POST IS PURELY EXPERIMENTAL AND SHOULD NOT BE USED FOR ANYTHING PRODUCTIVE

I firmly believe that any test that has an external dependency except for the function under test is not a Unit Test. It violates the entire principle on which the unit tests are designed. Unit Test is supposed to help you tests smaller pieces of your code to account for all the corner cases and the code-flow paths. In order for you to be able to successfully achieve that, there is but one way. MOCK EVERYTHING.

A mock in PyTest or any other framework is a way to patch the functions/variables/dependencies in order to return appropriate values which will ensure you can test all possible code-flow path for your function.

Writing a Custom Mocker

Before we dig into the inner workings of PyTest and how to use it, let us first take a look and try to see if we can make sense of how one can go about mocking things in python. Yes, you are free to call this #MonkeyPatching

Class To be Tested

class MyClass:
    def __init__(self, value):
        self._value = values
        
    def do_something(self):
        """For all practical reasons, let us assume this method will do some DB query and return an integer."""
        return 10
        
    def my_function(self):
        """This is the function I want to Unit Test"""
        if self.do_something() % 2 == 0:
            print("Yay!")
            return "Yay!"
        else:
            print("Nay!")
            return "Nay!"

In the above class, I want to test the behavior of my_function via a Unit-Test. But it internally invoke a private method do_something which does some database Operations and returns something. Let us use this as an example to see how easy it is to achieve mocking in python.

Writing a Custom Mocker Context Manager

When mocking anything for unit tests, the following is incredibly important to keep in mind.

  1. Patching of methods or objects are and should be valid only for the lifecycle of the Test. i.e. Once the test finishes in a successful or failed state the mock has to be reset and the object should be back to it’s original format.
Method Mocker

This class provides a basic implementation of __call__ method so that we can control the return values from the mocked method.

class _Mock:
    def __init__(self, return_value=None):
        self._return_value = return_value

    def __call__(self, *args, **kwargs):
        return self._return_value

Mock Context Manager

This class provides a primitive implementation of Context manager that will mock a method on an object using _Mock class defined above with the return values specified while creating the context.

class MyMocker:
    def __init__(self, entity, method, return_value=None):
        self._entity = entity
        self._method = method
        self._return_value = return_value
        self._original_method = None

    def __enter__(self):
        # In order to reset the context at the end of Mocking, we need to ensure we have
        # backed up the original method spec on the self._entity object
        self._original_method = getattr(self._entity, self._method)
        
        # Generate an object of _Mock with return values specified so that when you invoke the
        # self._method on self._entity, the self._return_value would be sent back
        _mocked_method = _Mock(return_value=self._return_value)
        
        # Patch the self._entity to use the mocked method.
        setattr(self._entity, self._method, _mocked_method)
        
        # Return the self._entity so that the methods can be invoked on it
        return self._entity

    def __exit__(self, exc_type, exc_val, exc_tb):
        setattr(self._entity, self._method, self._original_method)

And that is about it. This is all you need to make sure you mock your class to get what you want. Classes have inline documentation explaining the purpose and behavior of specific code block for additional detail and context.

Writing Tests

Now that we have the base infra for mocking setup, let us go ahead and write a few tests and see how that goes.

All tests are run as pytest with no exception.

class TestMyClass:
    def test_my_function_yay(self):
        with MyMocker(entity=MyClass(value=10), method="do_something", return_value=10) as mc:
            assert mc.my_function() == "Yay!"

    def test_my_function_ohh(self):
        with MyMocker(entity=MyClass(value=10), method="do_something", return_value=11) as mc:
            assert mc.my_function() == "Nay!"

=================================== test session starts ====================================
platform darwin -- Python 3.7.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1 -- /Users/timelord/.virtualenvs/mock-tests/bin/python
cachedir: .pytest_cache
rootdir: /Users/timelord/PycharmProjects/mock-tests
collected 2 items

test_my_class.py::TestMyClass::test_my_function_yay PASSED                           [ 50%]
test_my_class.py::TestMyClass::test_my_function_ohh PASSED                           [100%]

==================================== 2 passed in 0.01s =====================================

The above example should setup enough context for you to understand the way a simple mock works in python. Yes, it is indeed that simple. The mocking libraries are built around these fundamental ideas. Just that they provide an incredibly large number of utilities and behaviors that you will never have to resort to writing a custom mocks on your own.

Side Effects a.k.a Exceptions

As a programmer if you are not embracing the Exception you are doing it all wrong. They are the best part of your code and a well written exception handling means that the programmer has put in enough efforts and thoughts into figuring out the possible code-flow paths. This is a great quality in a programmer and makes reviewing Code with well written Unit Tests incredibly fun.

So, how can I go about mocking an Exception? That is pretty easy too. Let’s take the same item from above and make a few tweaks.

Mocking Side-Effects

Method Mocker

Let the mock class take a side_effect argument and then we can conditionally decide if we want to raise an exception or simply return the values back.

class _Mock:
    def __init__(self, return_value=None, side_effect=None):
        self._return_value = return_value
        self._side_effect = side_effect

    def __call__(self, *args, **kwargs):
        if self._side_effect:
            if isinstance(self._side_effect, Exception):
                raise self._side_effect
            else:
                return self._side_effect
        return self._return_value

Mock Context Manager

The __exit__ part of the context manager has not changed.

class MyMocker:
    def __init__(self, entity, method, return_value=None, side_effect=None):
        self._entity = entity
        self._method = method
        self._return_value = return_value
        self._original_method = None
        self._side_effect = side_effect

    def __enter__(self):
        self._original_method = getattr(self._entity, self._method)
        _mocked_method = _Mock(return_value=self._return_value, side_effect=self._side_effect)
        setattr(self._entity, self._method, _mocked_method)
        return self._entity

Testing Side Effects

class TestMyClass:
    def test_my_function_exception(self):
        with MyMocker(entity=MyClass(value=10), method="do_something", side_effect=ValueError()) as mc:
            assert mc.my_function() == "ValueError"
=================================== test session starts ====================================
platform darwin -- Python 3.7.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1 -- /Users/timelord/.virtualenvs/mock-tests/bin/python
cachedir: .pytest_cache
rootdir: /Users/timelord/PycharmProjects/mock-tests
collected 1 item

test_my_class.py::TestMyClass::test_my_function_exception PASSED                     [100%]

==================================== 1 passed in 0.01s =====================================

Why do I need to understand this?

As a programmer, it is always essential that you understand the fundamental building blocks of things that you are working on. Yes, there are tools and libraries available to do all of what I explained above and Yes, they do a million times better job. But, writing hacks like this will enable you to get a better grasp on the programming language and understand them better. They might not be production ready. But, Hey! you just learnt something new.