13

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.

Markus Meskanen
  • 19,939
  • 18
  • 80
  • 119
sihrc
  • 2,728
  • 2
  • 22
  • 43
  • 2
    This is probably where you wanna google ["python metaclasses"](https://www.google.fi/search?q=python+metaclasses) and keep on reading until you understand how they work. Metaclass is inherited by the subclasses, decorators only decorate the class they're used on. – Markus Meskanen Oct 06 '15 at 23:03
  • Ah, I think I sort of understand what you mean. Patches only occur on instances of classes? – sihrc Oct 06 '15 at 23:05
  • No, `patch` is a decorator which only takes the class directly underneath it and decorates that one. Now any subclasses will not be decorated, they will be just normal classes. Metaclasses control the behavior of how classes are made, and thus can patch a class when it's first created. Metaclasses also work on subclasses after the baseclass's metaclass is set, so subclasses will be patched too. – Markus Meskanen Oct 06 '15 at 23:06
  • Are you using Python 2 or Python 3? That's quite often a relevant tag to add. – Markus Meskanen Oct 06 '15 at 23:08
  • Thanks for the info. Python2. I'll look into it a little more – sihrc Oct 06 '15 at 23:09
  • @MarkusMeskanen -- While a metaclass _could_ work here, it seems way over complicated ... – mgilson Oct 06 '15 at 23:15
  • @mgilson I think you meant it *would* work. But yeah, you might be right. I just find them the best solution whenever I need multiple classes in the same inheritance tree to be decorated. – Markus Meskanen Oct 06 '15 at 23:17

2 Answers2

7

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.

mgilson
  • 300,191
  • 65
  • 633
  • 696
  • I think this has its merits as well. I've never seen patches done this way either. Thanks a bunch! – sihrc Oct 06 '15 at 23:23
7

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.

Markus Meskanen
  • 19,939
  • 18
  • 80
  • 119
  • Ah! I just came to a similar conclusion, but instead I went through all the functions that started with `test` and mocked those. Yours seems more elegant. Are there advantages / disadvantages to either? – sihrc Oct 06 '15 at 23:18
  • Is it possible to have more than 1 metaclass? Or do I need to sort of inception it? – sihrc Oct 06 '15 at 23:21
  • @sihrc Why do you want more tha none metaclass? I have never encountered such need, you might be doing something wrong here. I don't think there are really that many disadvantages. – Markus Meskanen Oct 06 '15 at 23:22
  • Ah, I bet I could add another metaclass that takes in a bunch of patches to make and produces a PatchClass that does all of them? – sihrc Oct 06 '15 at 23:22
  • I was just thinking I'd want a PatchClass that could be general enough to handle multiple combinations of patches. – sihrc Oct 06 '15 at 23:23
  • @sihrc What's wrong with the current metaclass? I think it can handle any amount of combinations. Notice the latest edit I made, with `patch_args` added. – Markus Meskanen Oct 06 '15 at 23:24
  • @sihrc -- Metaclasses can be "composed" by inhertance. e.g. You could create another metaclass that inherits from `PatchMeta` and extends the `__new__` method (or `__init__` -- it doesn't make much difference in this case). – mgilson Oct 06 '15 at 23:25
  • Ah, I didn't realize patch could take in any number of patch pairs. – sihrc Oct 06 '15 at 23:25
  • @mgilson It does matter, you can't use `__init__` here. We have to replace the new class with the patched one, thus we have to use `__new__`. If we were to patch it in `__init__`, it would only change the `__init__`'s local `self` and not replace the actual class itself. – Markus Meskanen Oct 06 '15 at 23:26
  • @sihrc I don't think I'm quite following here. What do you mean any number of patch pairs? Does it all work or are you encountering an issue? – Markus Meskanen Oct 06 '15 at 23:28
  • @MarkusMeskanen -- Isn't the instance (`self`) of the metaclass the class object itself? – mgilson Oct 06 '15 at 23:28
  • @mgilson Yes, it is! But when you do `self = blabla()` in the `__init__` method, you're just rebinding the local name `self` to whatever `blabla()` returns. You're not changing the actual class that `self` was first bound to. – Markus Meskanen Oct 06 '15 at 23:29
  • @MarkusMeskanen It works! I was simply considering if I had more than 1 patch to be made whether or not PatchMeta chain those patches. And I can see how it definitely can. I can take it from here on my own. Thanks again. – sihrc Oct 06 '15 at 23:31
  • 1
    Ahh, I think I know where the confusion is arising. The `patch` decorator does not return a _new_ class. It returns the old class with all methods that start with `test` patched. I'm not recommending you do `self = patch(...)(self)` -- That's just confusing. I'm recommending just `patch(...)(self)` which works because the returned value from `patch` operates on the class _in place_ (and then returns it cuz that's what decorators do). – mgilson Oct 06 '15 at 23:31
  • @mgilson I don't know what I was thinking, I've never used the `patch` method myself but of course that's how it works... mgilson 1, Markus Meskanen 0 – Markus Meskanen Oct 06 '15 at 23:33
  • Well, if we go by upvotes, it's Markus 3, Matt 1 ;-). – mgilson Oct 06 '15 at 23:34
  • Also, OP tagged this with `python2.7`, so you'll probably want python2.7's version of `super` ... `super(PatchMeta, self).__init__(*args, **kwargs)` – mgilson Oct 06 '15 at 23:50