DEV Community

Cover image for Python mocks can be better than just Mock()
Merlin Gough
Merlin Gough

Posted on

3 1

Python mocks can be better than just Mock()

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.

Lets get right 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

AWS Q Developer image

Your AI Code Assistant

Automate your code reviews. Catch bugs before your coworkers. Fix security issues in your code. Built to handle large projects, Amazon Q Developer works alongside you from idea to production code.

Get started free in your IDE

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more