All Articles

Python mocks can be better than just Mock()

Some mocks are less convincing than others.

I’ve seen a lot of examples where mock objects are badly used in Python unit tests. A lot of people seem to be guilty of only reading the first few paragraphs of the unittest.mock documentation, resulting in some very fragile tests that end up just being a cause of frustration.

My background is primarily as a Java developer, so when I picked up Python I was quite shocked by how lax Python mocks appeared to be, if you feel the same then this may help.

Disclaimer

Some people will strongly disagree with mocking in this way, and that's ok! I believe there's a time and a place for mocking to this extent, and that it's a judgement call as to whether you use autospecced mocks, a more convincing test double, or maybe even the real object.

While on the topic of testing, I strongly recommend Obey the Testing Goat

Lets get into it…

So we have an object we want to use, perhaps it’s from a library and changes often but currently it looks like this:

from abc import abstractmethod
class SomeClassOutOfOurControl:
@abstractmethod
def add(self, a, b):
raise NotImplementedError("We don't care about this")

We also have our object that we want to test:

from src.imported_class import SomeClassOutOfOurControl
class SomeObjectToTest:
some_property = 5
some_other_property = 10
def __init__(self):
self.calculator = SomeClassOutOfOurControl()
def do_something(self, custom_calculator):
return custom_calculator.add(self.some_property, self.some_other_property)
def do_something_else(self):
return self.calculator.add(self.some_property, self.some_other_property)

Looks like we’re going to need to mock SomeClassOutOfOurControl. Here’s an example of the most common ways I see it done, probably because it’s how a lot of other blogs tell you to do it:

from unittest.mock import patch, Mock
from pytest import fixture
from src.example import SomeObjectToTest
class TestSomeObjectToTest:
@fixture
def expected_result(self):
return 400
def test_do_something(self, expected_result):
object_under_test = SomeObjectToTest()
mocked_calculator = Mock()
mocked_calculator.add.return_value = expected_result
result = object_under_test.do_something(mocked_calculator)
assert result == expected_result
mocked_calculator.add.assert_called_once_with(5, 10)
@patch("src.example.SomeClassOutOfOurControl")
def test_do_something_else(self, mocked_class, expected_result):
mocked_class.return_value.add.return_value = expected_result
object_under_test = SomeObjectToTest()
result = object_under_test.do_something_else()
assert result == expected_result
object_under_test.calculator.add.assert_called_once_with(5, 10)

Yay mocks! But this is fragile… If there is a typo in our class or an interface change in the imported class then the tests can still pass. Swap out the imported class for this one and watch as magically nothing changes:

from abc import abstractmethod
class SomeClassOutOfOurControl:
@abstractmethod
def add(self, items):
raise NotImplementedError("We don't care about this")

Despite the fact we call SomeClassOutOfOurControl.add() with 2 arguments the tests continue to pass when it only accepts one.

How can we fix this?

There’s a super simple solution to this, and it’s called autospeccing. This means that your mocks are limited to have only the method which exist on the class being mocked, including the number of arguments. It can also handle attributes, but those are a special case, as those defined in functions aren’t included so I’d strongly recommend reading the docs.

from unittest.mock import patch, Mock, create_autospec
from pytest import fixture
from service.example import SomeObjectToTest
from service.imported_class import SomeClassOutOfOurControl
class TestSomeObjectToTest:
@fixture
def expected_result(self):
return 400
def test_do_something(self, expected_result):
object_under_test = SomeObjectToTest()
mocked_calculator = create_autospec(SomeClassOutOfOurControl)
mocked_calculator.add.return_value = expected_result
result = object_under_test.do_something(mocked_calculator)
assert result == expected_result
mocked_calculator.add.assert_called_once_with(5, 10)
@patch("service.example.SomeClassOutOfOurControl", autospec=True)
def test_do_something_else(self, mocked_class, expected_result):
mocked_class.return_value.add.return_value = expected_result
object_under_test = SomeObjectToTest()
result = object_under_test.do_something_else()
assert result == expected_result
object_under_test.calculator.add.assert_called_once_with(5, 10)

Now we have mocks that are slightly more resilient! This of course doesn’t change the need for other levels of testing (integration or component level testing) but it makes our unit tests a bit more sensible.

If this seems like it would be useful to you then I highly recommend reading the unittest.mock documentation thoroughly. If you’ve received a technical test and there’s a chance I will be reviewing it then I’d definitely recommend reading the documentation, bonus points if you post a correction to this blog :).


Cover photo by Maarten van den Heuvel on Unsplash