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
Top comments (0)