pythonpython-3.xpython-unittestpython-unittest.mockmagicmock

Child Class from MagicMock object has weird spec='str' and can't use or mock methods of the class


When a class is created deriving from a MagicMock() object it has an unwanted spec='str'. Does anyone know why this happens? Does anyone know any operations that could be done to the MagicMock() object in this case such that it doesn't have the spec='str' or can use methods of the class?

from unittest.mock import MagicMock

a = MagicMock()

class b():
    @staticmethod
    def x():
        return 1

class c(a):
    @staticmethod
    def x():
        return 1
print(a)
print(b)
print(c)
print(a.x())
print(b.x())
print(c.x())

which returns

MagicMock id='140670188364408'>
<class '__main__.b'>
<MagicMock spec='str' id='140670220499320'>
<MagicMock name='mock.x()' id='140670220574848'>
1
Traceback (most recent call last):
    File "/xyz/test.py", line 19, in <module>
        print(c.x())
    File "/xyz/lib/python3.7/unittest/mock.py", line 580, in _getattr_
        raise AttributeError("Mock object has no attribute %r" % name)
AttributeError: Mock object has no attribute 'x'

Basically I need the AttributeError to not be here. Is there something I can do to 'a' such that c.x() is valid?

edit - the issue seems to be with _mock_add_spec in mock.py still not sure how to fix this.


Solution

  • In Python, classes are actually instances of the type class. A class statement like this:

    class c(a):
        @staticmethod
        def x():
            return 1
    

    is really syntactic sugar of calling type with the name of the class, the base classes and the class members:

    c = type('c', (a,), {'x': staticmethod(lambda: 1)})
    

    The above statement would go through the given base classes and call the __new__ method of the type of the first base class with the __new__ method defined, which in this case is a. The return value gets assigned to c to become a new class.

    Normally, a would be an actual class--an instance of type or a subclass of type. But in this case, a is not an instance of type, but rather an instance of MagicMock, so MagicMock.__new__, instead of type.__new__, is called with these 3 arguments.

    And here lies the problem: MagicMock is not a subclass of type, so its __new__ method is not meant to take the same arguments as type.__new__. And yet, when MagicMock.__new__ is called with these 3 arguments, it takes them without complaint anyway because according to the signature of MagicMock's constructor (which is the same as Mock's):

    class unittest.mock.Mock(spec=None, side_effect=None,
        return_value=DEFAULT, wraps=None, name=None, spec_set=None,
        unsafe=False, **kwargs)
    

    MagicMock.__new__ would assign the 3 positional arguments as spec, side_effect and return_value, respectively. As you now see, the first argument, the class name ('c' in this case), an instance of str, becomes spec, which is why your class c becomes an instance of MagicMock with a spec of str.

    The solution

    Luckily, a magic method named __mro_entries__ was introduced since Python 3.7 that can solve this problem by providing a non-class base class with a substitute base class, so that when a, an instance of MagicMock, is used as a base class, we can use __mro_entries__ to force its child class to instead use a's class, MagicMock (or SubclassableMagicMock in the following example), as a base class:

    from unittest.mock import MagicMock
    
    class SubclassableMagicMock(MagicMock):
        def __mro_entries__(self, bases):
            return self.__class__,
    

    so that:

    a = SubclassableMagicMock()
    
    class b():
        @staticmethod
        def x():
            return 1
    
    class c(a):
        @staticmethod
        def x():
            return 1
    
    print(a)
    print(b)
    print(c)
    print(a.x())
    print(b.x())
    print(c.x())
    

    outputs:

    <SubclassableMagicMock id='140127365021408'>
    <class '__main__.b'>
    <class '__main__.c'>
    <SubclassableMagicMock name='mock.x()' id='140127351680080'>
    1
    1
    

    Demo: https://replit.com/@blhsing/HotAcademicCases