Search code examples
pythonpython-unittestpython-decorators

Decorator factory on a unittest method


def register_processor2(processor_name='SomeProcessor'):
    def decorator(func):
        class SomeProcessor(GenericPaymentProcessor, TriggeredProcessorMixin):
            name = processor_name
            transaction_class = Transaction

            @staticmethod
            def setup(data=None):
                pass

        @wraps(func)
        def func_wrapper(*args, **kwargs):
            PaymentProcessorManager.register(SomeProcessor)
            result = func(*args, **kwargs)
            PaymentProcessorManager.unregister(SomeProcessor)
            return result

        return func_wrapper
    return decorator


def register_processor(func):
    class SomeProcessor(GenericPaymentProcessor, TriggeredProcessorMixin):
         name = 'SomeProcessor'
         transaction_class = Transaction

         @staticmethod
         def setup(data=None):
             pass

    @wraps(func)
    def func_wrapper(*args, **kwargs):
        PaymentProcessorManager.register(SomeProcessor)
        result = func(*args, **kwargs)
        PaymentProcessorManager.unregister(SomeProcessor)
        return result

    return func_wrapper


class TestPaymentMethodEndpoints(APITestCase):
    @register_processor
    def test_put_detail_cannot_change_processor(self):
        self.assertEqual(True, False)

Ok so the decorator register_processor works as expected. And the test fails, but I want to make the name of the inner class customizable so I went for a decorator factory implementation instead.

The thing is when running the test decorated with register_processor2 I get the following:

AttributeError: 'TestPaymentMethodEndpoints' object has no attribute '__name__'

This is from @wraps(func), my question is why is func here an instance of TestPaymentMethodEndpoints, and not the bound method?

Also if I remove the @wraps decorator then the test runs and passes. I'd expect that the test would not be discovered as func_wrapper does not start with test_* and even if it is discovered then it should fail.

Any insight on what is happening and how I'd go about doing this?

EDIT

So I figured it out even if the decorator factory has arguments that have default values you still need to place () when calling it.

But would still love to hear an explanation of what happened in case of the tests passing / getting discovered in the first place.

class TestPaymentMethodEndpoints(APITestCase):
    @register_processor()
    def test_put_detail_cannot_change_processor(self):
        self.assertEqual(True, False)

Makes sense now that I think about it :D, gosh you learn something new each day!


Solution

  • I think you're now asking "how come the unittest module can find test cases that have been wrapped in functions with names that don't start test?"

    The answer to that is because unittest doesn't use the names of the functions to find the methods to run, it uses the attribute names of the test case classes to find them.

    So try running the following code:

    from unittest import TestCase
    
    def apply_fixture(func):
    
        def wrap_with_fixture(self):
            print('setting up fixture...')
            try:
                func(self)
            finally:
                print('tearing down fixture')
    
        return wrap_with_fixture
    
    
    class MyTestCase(TestCase):
    
        @apply_fixture
        def test_something(self):
            print('run test')
    
    
    print('Attributes of MyTestCase: %s' % dir(MyTestCase))
    print('test_something method: %s' % MyTestCase.test_something)
    
    mtc = MyTestCase()
    mtc.test_something()
    

    You will see that the output from dir contains the name test_something:

    Attributes of MyTestCase: ['__call__', ...lots of things..., 'test_something']
    

    but that the value of that attribute is the wrapping function wrap_with_fixture:

    test_something method: <function apply_fixture.<locals>.wrap_with_fixture at 0x10d90aea0>
    

    This makes sense when you consider that when you create a function you are both creating a function with the name provided and a local variable with the same name, and that the decorator @ syntax is just syntactic sugar, so the following would have been an equally valid albeit longer-winded way of creating your test case class:

    class MyTestCase(TestCase):
    
        def test_something(self):
            print('run test')
        # Overwrite existing 'local' (or 'class' variable in this context) 
        # with a new value. We haven't deleted the test_something function
        # which still exists but now is owned by the function we've created.
        test_something = apply_fixture(test_something)