Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I mock patch a class used in an isinstance test?

I want to test the function is_myclass. Please help me understand how to write a successful test.

def is_myclass(obj):
    """This absurd stub is a simplified version of the production code."""
    isinstance(obj, MyClass)
    MyClass()

Docs

The Python Docs for unittest.mock illustrate three ways of addressing the isinstance problem:

  • Set the spec parameter to the real class.
  • Assign the real class to the __class__ attribute.
  • Use spec in the patch of the real class.

__class__

Normally the __class__ attribute of an object will return its type. For a mock object with a spec, __class__ returns the spec class instead. This allows mock objects to pass isinstance() tests for the object they are replacing / masquerading as:

>>> mock = Mock(spec=3)
>>> isinstance(mock, int)
True

__class__ is assignable to, this allows a mock to pass an isinstance() check without forcing you to use a spec:

>>> mock = Mock()
>>> mock.__class__ = dict
>>> isinstance(mock, dict)
True

[...]

If you use spec or spec_set and patch() is replacing a class, then the return value of the created mock will have the same spec.

>>> Original = Class
>>> patcher = patch('__main__.Class', spec=True)
>>> MockClass = patcher.start()
>>> instance = MockClass()
>>> assert isinstance(instance, Original)
>>> patcher.stop()

Tests

I have written five tests each of which attempts firstly to reproduce each of the three solutions and secondly to carry out a realistic test of the target code. The typical pattern is assert isinstance followed by a call to is_myclass.

All tests fail.

Test 1

This is a close copy of the example provided in the docs for the use of spec. It differs from the docs by using spec=<class> instead of spec=<instance>. It passes a local assert test but the call to is_myclass fails because MyClass is not mocked.

This is equivalent to Michele d’Amico’s answer to the similar question in isinstance and Mocking .

Test 2

This is the patched equivalent of test 1. The spec argument fails to set the __class__ of the mocked MyClass and the test fails the local assert isinstance.

Test 3

This is a close copy of the example provided in the docs for the use of __class__. It passes a local assert test but the call to is_myclass fails because MyClass is not mocked.

Test 4

This is the patched equivalent of test 3. The assignment to __class__ does set the __class__ of the mocked MyClass but this does not change its type and so the test fails the local assert isinstance.

Test 5

This is a close copy of the use of spec in a call to patch. It passes the local assert test but only by virtue of accessing a local copy of MyClass. Since this local variable is not used within is_myclass the call fails.

Code

This code was written as a stand alone test module intended to be run in the PyCharm IDE. You may need to modify it to run in other test environments.

module temp2.py

import unittest
import unittest.mock as mock


class WrongCodeTested(Exception):
    pass


class MyClass:
    def __init__(self):
        """This is a simplified version of a production class which must be mocked for unittesting."""
        raise WrongCodeTested('Testing code in MyClass.__init__')


def is_myclass(obj):
    """This absurd stub is a simplified version of the production code."""
    isinstance(obj, MyClass)
    MyClass()


class ExamplesFromDocs(unittest.TestCase):
    def test_1_spec(self):
        obj = mock.Mock(spec=MyClass)
        print(type(MyClass))  # <class 'type'>
        assert isinstance(obj, MyClass)  # Local assert test passes
        is_myclass(obj)  # Fail: MyClass instantiated


    def test_2_spec_patch(self):
        with mock.patch('temp2.MyClass', spec=True) as mock_myclass:
            obj = mock_myclass()
            print(type(mock_myclass))  # <class 'unittest.mock.MagicMock'>
            print(type(MyClass))  # <class 'unittest.mock.MagicMock'>
            assert isinstance(obj, MyClass)  # Local assert test fails

    def test_3__class__(self):
        obj = mock.Mock()
        obj.__class__ = MyClass
        print(type(MyClass))  # <class 'type'>
        isinstance(obj, MyClass)  # Local assert test passes
        is_myclass(obj)  # Fail: MyClass instantiated

    def test_4__class__patch(self):
        Original = MyClass
        with mock.patch('temp2.MyClass') as mock_myclass:
            mock_myclass.__class__ = Original
            obj = mock_myclass()
            obj.__class__ = Original
            print(MyClass.__class__)  # <class 'temp2.MyClass'>
            print(type(MyClass))  # <class 'unittest.mock.MagicMock'>
            assert isinstance(obj, MyClass)  # Local assert test fails

    def test_5_patch_with_spec(self):
        Original = MyClass
        p = mock.patch('temp2.MyClass', spec=True)
        MockMyClass = p.start()
        obj = MockMyClass()
        print(type(Original))  # <class 'type'>
        print(type(MyClass))  # <class 'unittest.mock.MagicMock'>
        print(type(MockMyClass))  # <class 'unittest.mock.MagicMock'>
        assert isinstance(obj, Original)  # Local assert test passes
        is_myclass(obj)  # Fail: Bad type for MyClass
like image 564
lemi57ssss Avatar asked Apr 08 '18 13:04

lemi57ssss


1 Answers

You can't mock the second argument of isinstance(), no. The documentation you found concerns making a mock as the first argument pass the test. If you want to produce something that is acceptable as the second argument to isinstance(), you actually have to have a type, not an instance (and mocks are always instances).

You could use a subclass instead of MyClass instead, that'll definitely pass, and giving it a __new__ method lets you alter what is returned when you try to call it to create an instance:

class MockedSubClass(MyClass):
    def __new__(cls, *args, **kwargs):
        return mock.Mock(spec=cls)  # produce a mocked instance when called

and patch that in:

mock.patch('temp2.MyClass', new=MockedSubClass)

and use an instance of that class as the mock:

instance = mock.Mock(spec=MockedSubClass)

Or, and this is much simpler, just use Mock as the class, and have obj be an Mock instance:

with mock.patch('temp2.MyClass', new=mock.Mock) as mocked_class:
    is_myclass(mocked_class())

Either way, your test then passes:

>>> with mock.patch('temp2.MyClass', new=MockedSubClass) as mocked_class:
...     instance = mock.Mock(spec=MockedSubClass)
...     assert isinstance(instance, mocked_class)
...     is_myclass(instance)
...
>>> # no exceptions raised!
...
>>> with mock.patch('temp2.MyClass', new=mock.Mock) as mocked_class:
...     is_myclass(mocked_class())
...
>>> # no exceptions raised!
...

For your specific tests, here is why they fail:

  1. You never mocked MyClass, it still references the original class. The first line of is_myclass() succeeds, but the second line uses the original MyClass and it is booby trapped.
  2. MyClass is replaced with a mock.Mock instance, not an actual type, so isinstance() raises a TypeError: isinstance() arg 2 must be a type or tuple of types exception.
  3. Fails exactly the same way 1 failed, MyClass is left in-tact and is booby trapped.
  4. Fails the same way as 2 does. __class__ is an attribute only useful on instances. A class object doesn't use the __class__ attribute, you still have an instance instead of a class and isinstance() raises a type error.
  5. is essentially exactly the same as 4, only you started the patcher manually instead of having the context manager take care of it, and you used isinstance(obj, Original) to check the instance, so you never got the type error there. The type error is instead triggered in is_myclass().
like image 88
Martijn Pieters Avatar answered Sep 23 '22 11:09

Martijn Pieters