Overview
It's been 8 years since I started playing with Python. The recent trend for me is to properly write unit tests for the Python functions I create. This ensures that my functions are reliable. I had also dabbled in unit testing about 6 years ago...
Back then, I was using nose
. But for my hobby-level projects, the standard unittest
module is more than enough.
In this article, I'll be compiling practical examples of unit tests using mocks.
Function to be Tested (my_module)
import logging
logger = logging.getLogger(__name__)
def _bar():
raise NotImplementedError()
def foo(i: int) -> list[int | None]:
"""The target function for playing with unit tests.
Calls _bar as many times as specified and returns the results as a list[int | None].
If _bar throws an Exception, None is added to the list.
Args:
i (int): The number of times to call _bar.
"""
results: list[int | None] = []
for _ in range(i):
try:
# Add the result of _bar.
results.append(_bar())
except Exception:
# If an error occurs, log it and add None.
logger.exception("An exception occurred! Logging and continuing the process.")
results.append(None)
return results
Since I want to have fun with mocks this time, I've created a foo
function that allows mocks to shine. The function _bar
that's being called within foo
is not implemented, so calling foo
directly will definitely result in an error. Let's create a test to mock and replace _bar
.
Unit Tests
Level 1
Let's make _bar
return 15
.
from unittest import TestCase
from unittest.mock import patch, MagicMock
class TestFoo(TestCase):
# I want _bar to return 15. -> Confirm that [15] is returned.
@patch('my_module._bar', MagicMock(return_value=15, autospec=True))
def test_foo(self):
actual = foo(1)
self.assertEqual(actual, [15])
This is Level 1. It's easy to read. Just by looking at the decorator, you can understand:
_bar
is being patched,- It's being replaced with
MagicMock
, - It's set to return
15
, autospec
is used to inherit the signature (like the number of arguments) of the real_bar
.
Level 2
Well, the above approach is fine, but if you define MagicMock
within the decorator, you'll hit a wall when you want to replace it with a slightly more complex mock function in the future. You can't do complicated stuff within a decorator.
So, let's create the function we want to replace with, using the def
format, and set it as the side_effect
of MagicMock
.
from unittest import TestCase
from unittest.mock import patch, MagicMock
class TestFoo(TestCase):
# I want to standardize the writing style using side_effect.
@patch('my_module._bar', autospec=True)
def test_foo(self, mock_bar: MagicMock):
def custom_bar(*args, **kwargs):
return 15
mock_bar.side_effect = custom_bar
actual = foo(1)
self.assertEqual(actual, [15])
I find this pretty easy to read.
_bar
is being patched,- The
MagicMock
to replace it with can be received within the test function, - You can define what function to replace it with as the
side_effect
ofMagicMock
,
It's all clear.
By the way, isn't it strange that the name of the process being replaced is called "side effect"? It feels more like a "main effect," doesn't it? Here's how to make sense of it:
- This "side effect" is a side effect of
MagicMock
, - The main purpose of
MagicMock
is to replace something that would normally do something (_bar
, for example) with something that does nothing and returns nothing, - If you consider that the main purpose of the mock, then returning any specific value is a side effect of
MagicMock
.
Level 3
The pattern I mentioned in Level 2, where you want to replace with a "slightly more complex mock function," is this one.
- You want
_bar
to be called multiple times and return different values each time, - You want it to throw an
Exception
a few times,
In such a setup, you'd create the mock's side effect like this:
from unittest import TestCase
from unittest.mock import call, patch, MagicMock
class TestFoo(TestCase):
# I want _bar to return 15, Exception, 8, Exception, 4 in sequence.
# This is where side_effect really shines.
@patch('my_module._bar', autospec=True)
@patch('my_module.logger.exception', autospec=True)
def test_foo(self, mock_logger: MagicMock, mock_bar: MagicMock):
def custom_bar(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return 15
elif call_count == 3:
return 8
elif call_count == 5:
return 4
else:
raise Exception()
call_count = 0
mock_bar.side_effect = custom_bar
actual = foo(5)
self.assertEqual(actual, [15, None, 8, None, 4])
expected_calls = [
call("An exception occurred! Logging while continuing the process."),
call("An exception occurred! Logging while continuing the process.")
]
mock_logger.assert_has_calls(expected_calls)
I'm also confirming that the logger is called twice. Yes, MagicMock
can also detect when it's been called.