3

When using inversion of control in python, we may have a class such as:

class A:
    def __init__(self, b, c, d):
        self.b = b
        self.c = c
        self.d = d

Where b, c and d are instances of (or at least behave like) class B, class C and class D. When writing unit tests for A, we would like to mock these objects with mocks that "behave like" the instances of the mentioned classes. When creating mocks as Mock(spec=an_object) then the mock will answer to the same calls as an_object. In our case we would have to call their respective constructors doing something similar to this (referred to as "spec-object" ahead):

b = Mock(spec=B(...))
c = Mock(spec=C(...))
d = Mock(spec=D(...))

But this goes against the idea of avoiding the use of the real classes and its instances. If the constructors change tomorrow, for example by adding new parameters, then these mocks would have to be updated to include those new parameters, resulting in a waste of effort.

If the mocks are created without specifying a spec, then the mocks would be completely independent to changes in the real classes' constructors. For example, we could do something like this (referred to as "no-spec" ahead):

b = Mock()
c = Mock()
d = Mock()

Nevertheless, these mocks are as "dumb" as they could be as they can be called in any possible way and they won't break the test. That is, b doesn't behave like a B instance and neither do c and d with their respective classes. This is troublesome as the unit tests don't cover the "correct calls" to these objects and won't fail with the expected AttributeError Exception. If we're lucky, these invalid calls would be caught by (hypothetical) integration tests but they should've been the unit tests' responsibility.

A middle ground would be to hard-code the calls we expect b, c and d to respond to. For example, in the following way (referred to as "spec-list" ahead)

b = Mock(spec=["method_b_1", "method_b_2"])
c = Mock(spec=["method_c_1"])
d = Mock(spec=["method_d_42"])

This way, if inside A we do something like self.b.invalid_method then an AttributeError will be raised because the mock was not configured to respond to that call. The downsides of this approach are:

  1. If tomorrow we refactor B renaming method_b_1 to something else (assuming that A's usage of b was properly refactored too) then this test would fail. We would need to manually rename the mock's spec calls. If we were using the "spec-object" approach from above, then we wouldn't have to do this manual fix.
  2. When defining the mock we have to manually specify all the calls that we expect to do to it. This can get really big and time consuming.

Finally, a commenter on this answer could say that I can simply pass None to B's, C's and D's constructors when using the "spec-object" approach. For example (referred to as "spec-object-none" ahead):

b = Mock(spec=B(None, None ...)]
c = Mock(spec=B(None, None ...))
d = Mock(spec=B(None, None ...))

But this approach may be passing invalid objects to these constructors and as we are calling the real constructors then they may (and probably will) break if they assume that one or more parameters are not None. For example, if B's constructor is something like the following then this test will break:

class A:
    def __init__(self, e):
        self.something = e.something

In conclusion, none of the ways I found to mock objects is as good as the ones in other languages (for example, in Java when creating mocks you don't have to call the real constructors and the mocks answer only to valid methods, throwing an Exception otherwise). The disadvantages for each approach were:

  • spec-object: the test has to call the real class constructor passing valid parameters
  • no-spec: the mocks can be called with invalid calls and the test will pass
  • spec-list:
    • the test has to be manually fixed when the real class methods are refactored
    • the valid calls have to be manually specified when creating the mock
  • spec-object-none: the test would break if the real class constructor assumes the parameters are not None

Finally, we arrive to my question: is there a way to create mocks representing instances of a class without having to manually instantiate said class's objects?

EDIT: using create_autospec

@wim suggested using mock.create_autospec with instance=True and this "kind of works" but it's not "perfect". For example:

In [1]: import unittest.mock

In [2]: class A:
   ...:     def __init__(self, b):
   ...:         self.b = b
   ...:
   ...:     def get_b(self):
   ...:         return self.b
   ...:

In [3]: mock = unittest.mock.create_autospec(spec=A, instance=True)

In [4]: mock.get_b()
Out[4]: <MagicMock name='mock.get_b()' id='140598183546064'>

In [5]: mock.invalid_call()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-5-09fd5f1726b9> in <module>
----> 1 mock.invalid_call()

~/anaconda3/lib/python3.7/unittest/mock.py in __getattr__(self, name)
    596         elif self._mock_methods is not None:
    597             if name not in self._mock_methods or name in _all_magics:
--> 598                 raise AttributeError("Mock object has no attribute %r" % name)
    599         elif _is_magic(name):
    600             raise AttributeError(name)

AttributeError: Mock object has no attribute 'invalid_call'

In [6]: mock.b
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-6-26e5746cc8da> in <module>
----> 1 mock.b

~/anaconda3/lib/python3.7/unittest/mock.py in __getattr__(self, name)
    596         elif self._mock_methods is not None:
    597             if name not in self._mock_methods or name in _all_magics:
--> 598                 raise AttributeError("Mock object has no attribute %r" % name)
    599         elif _is_magic(name):
    600             raise AttributeError(name)

AttributeError: Mock object has no attribute 'b'

In [7]: a = A(42)      

In [8]: a.b            
Out[8]: 42

When using this approach, the mock...

  • ... will not raise an Exception if a valid method call is made (Good)
  • ... will raise Exception if an invalid method call is made (Good)
  • ... will raise Exception if we try to acces a valid attribute directly (Bad)

EDIT: workaround for dataclasses

In this answer by @mohi666 in another question they showed a way to create mocks of instances of a dataclass that have all the valid attributes accessible while throwing exceptions for invalid accesses. For example:

from unittest.mock import Mock
from dataclasses import fields, dataclass


def create_dataclass_mock(obj):
    return Mock(spec=[field.name for field in fields(obj)])


@dataclass
class A:
    aaa: str
    bbb: int


basicMock = Mock()
specialMock = create_dataclass_mock(A)

basicMock.aaa   # returns a mock (Good)
specialMock.aaa # returns a mock (Good)

basicMock.attribute_that_should_not_exist   # returns a mock (Bad)
specialMock.attribute_that_should_not_exist # raises an exception (Good)
Alechan
  • 817
  • 1
  • 10
  • 24
  • Have you tried [autospec](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.create_autospec)? If a class is used as a spec then the return value of the mock (the instance of the class) will have the same spec. You can use a class as the spec for an instance object by passing instance=True. – wim Jun 04 '20 at 03:58
  • @wim, I updated my answer with an example of creating mocks using `create_autospec`. It's not perfect as valid attribute accesses raise exceptions but it may be the "lesser evil" :shrugs: – Alechan Jun 04 '20 at 04:22
  • The "valid attribute" doesn't actually exist unless a real instance is created (`A.__init__` called), so I'm not sure what mock could even do there, you may be asking for a bit much. Imagine that line was conditional such as `if random.random() < 0.5: self.b = b`. Do A instances have a b attribute or not? Who knows? – wim Jun 04 '20 at 04:26
  • Maybe mocks are not the best choice for your use case, you could consider using [fakes](https://www.youtube.com/watch?v=rk-f3B-eMkI) instead. – wim Jun 04 '20 at 04:30
  • I realized that [my question has already been asked](https://stackoverflow.com/questions/24630720/strict-mock-in-python) (I found it by making a very specific search in Google after your comment). Unfortunately, the answers in that question are the ones I mentioned above as "approaches". Anyway, I will take a look at the fakes. Thanks! – Alechan Jun 04 '20 at 04:38

0 Answers0