The Case:
I've a class View
in my code that creates an instance during execution, which one I want to mock.
I'm passing implementation of View
before running the code.
class View:
def __init__(arg):
self.arg = arg
self.inner_class = self.InnerClass()
item to test:
view.inner_class.some_method.assert_called_once()
The Problem:
I can't properly create a MockView
class to get correct mock_view_instance
during execution.
I've tried 1:
assert_called_once
method.mock_instance = Mock(wraps=View, spec=View, spec_set=True)
I've tried 2:
inner_class
not exists (no entering to __init__
method).mock_instance = Mock(spec=View, spec_set=True)
I've tried 3:
View
isnstance can be instantiated without arg
- it's error prone.arg
not exists at this moment, it will be defined dynamically during a test call itself.mock_instance = Mock(spec=View(arg='foo'), spec_set=True)
I've tried 4:
TypeError: 'View' object is not callable
.mock_instance = Mock(wraps=View(arg='foo'))
I've tried 5:
instance=True/False
)mock_instance = create_autospec(spec=View(arg='foo'))
My dirty solution:
# Real object with mock methods (wrapper)
wrapper_instance = Mock(wraps=View, spec_set=True)
# Return mocked object as instance
# (don't use "wrapper_instance" as spec, it will cause infinite recursion).
wrapper_instance.side_effect = Mock(spec=Mock(wraps=View))
P.S. I'm preferring not to use patch
because it's very implicit.
My architecture allows to set any required object during configuration.
Say we have the following code.py
:
class View:
class InnerClass:
def some_method(self) -> None:
print(self.__class__.__name__, "instance says hi")
def __init__(self, arg: str) -> None:
self.arg = arg
self.inner = self.InnerClass()
def f(view: View) -> int:
view.inner.some_method()
return 42
def g() -> str:
view = View(arg="foo")
view.inner.some_method()
return view.arg + "bar"
Here is how you might properly unit test the functions f
and g
:
from unittest import TestCase
from unittest.mock import MagicMock, patch
from . import code
class CodeTestCase(TestCase):
def test_f(self) -> None:
mock_some_method = MagicMock()
mock_view = MagicMock(
inner=MagicMock(some_method=mock_some_method)
)
output = code.f(mock_view)
self.assertEqual(42, output)
mock_some_method.assert_called_once_with()
@patch.object(code, "View")
def test_g(self, mock_view_cls: MagicMock) -> None:
mock_arg = "xyz"
mock_some_method = MagicMock()
mock_view_cls.return_value = MagicMock(
arg=mock_arg,
inner=MagicMock(some_method=mock_some_method),
)
output = code.g()
self.assertEqual(mock_arg + "bar", output)
mock_view_cls.assert_called_once_with(arg="foo")
mock_some_method.assert_called_once_with()
To test f
, we need to give it an argument that behaves like a View
instance in the context of f
. So all we need to do is construct a mock object that has all the View
attributes needed inside f
. The function relies on the inner
attribute of View
and the presence of some_method
on that inner
object. We want to ensure that f
actually calls that method. Note that what I did was more than necessary and done for readability only. We could have just written the test method like this:
def test_f(self) -> None:
mock_view = MagicMock()
output = code.f(mock_view)
self.assertEqual(42, output)
mock_view.inner.some_method.assert_called_once_with()
For testing g
we need to mock the entire View
class for the duration of the test since we don't want to rely on any implementation details of how it is instantiated. This is where patch
shines. We ensure that instead of actually calling View
the function calls a mock returning another mock that behaves like a View
instance in the context of g
. Same logic from then on.
In general this is the prudent approach to unit test. We mock out everything we wrote ourselves that is not part of the unit under testing. The test should be totally agnostic to any implementation details of other units.
It would be a different story, if View
was a third-party or built-in class. In that case we would (usually) use it as is under the assumption that the maintainers of that class do their own testing and that it works as advertised.