Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Inheriting a patched class

I have a base class extending unittest.TestCase, and I want to patch that base class, such that classes extending this base class will have the patches applied as well.

Code Example:

@patch("some.core.function", mocked_method)
class BaseTest(unittest.TestCase):
      #methods
      pass

class TestFunctions(BaseTest):
      #methods
      pass

Patching the TestFunctions class directly works, but patching the BaseTest class does not change the functionality of some.core.function in TestFunctions.

like image 494
sihrc Avatar asked Oct 06 '15 22:10

sihrc


2 Answers

You probably want a metaclass here: a metaclass simply defines how a class is created. By default, all classes are created using Python's built-in class type:

>>> class Foo:
...     pass
...
>>> type(Foo)
<class 'type'>
>>> isinstance(Foo, type)
True

So classes are actually instances of type. Now, we can subclass type to create a custom metaclass (a class that creates classes):

class PatchMeta(type):
    """A metaclass to patch all inherited classes."""

We need to control the creation of our classes, so we wanna override the type.__new__ here, and use the patch decorator on all new instances:

class PatchMeta(type):
    """A metaclass to patch all inherited classes."""

    def __new__(meta, name, bases, attrs):
        cls = type.__new__(meta, name, bases, attrs)
        cls = patch("some.core.function", mocked_method)(cls)
        return cls

And now you simply set the metaclass using __metaclass__ = PatchMeta:

class BaseTest(unittest.TestCase):
    __metaclass__ = PatchMeta
    # methods

The issue is this line:

cls = patch("some.core.function", mocked_method)(cls)

So currently we always decorate with arguments "some.core.function" and mocked_method. Instead you could make it so that it uses the class's attributes, like so:

cls = patch(*cls.patch_args)(cls)

And then add patch_args to your classes:

class BaseTest(unittest.TestCase):
    __metaclass__ = PatchMeta
    patch_args = ("some.core.function", mocked_method)

Edit: As @mgilson mentioned in the comments, patch() modifies the class's methods in place, instead of returning a new class. Because of this, we can replace the __new__ with this __init__:

class PatchMeta(type):
    """A metaclass to patch all inherited classes."""

    def __init__(cls, *args, **kwargs):
        super(PatchMeta, self).__init__(*args, **kwargs)
        patch(*cls.patch_args)(cls)

Which is quite unarguably cleaner.

like image 165
Markus Meskanen Avatar answered Oct 23 '22 02:10

Markus Meskanen


Generally, I prefer to do this sort of thing in setUp. You can make sure that the patch gets cleaned up after the test is completed by making use of the tearDown method (or alternatively, registering a the patch's stop method with addCleanup):

class BaseTest(unittest.TestCase):
      def setUp(self):
            super(BaseTest, self).setUp()
            my_patch = patch("some.core.function", mocked_method)
            my_patch.start()
            self.addCleanup(my_patch.stop)

class TestFunctions(BaseTest):
      #methods
      pass

Provided that you're disciplined enough to always call super in your overridden setUp methods, it should work just fine.

like image 20
mgilson Avatar answered Oct 23 '22 02:10

mgilson