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.
- 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.