Is there a way to run a method automatically on th

2019-07-11 04:18发布

问题:

I am writing some unit tests with Pytest. If I want them to be collected automatically, I have to avoid the __init__ constructor. (If there's a way to make Pytest collect tests with the __init__ constructor I'd take that as an alternate useful answer.)

My unit tests have some variables and methods in common. Right now I have base test class TestFoo, and child test class TestBar(TestFoo), and grandchild test class TestBaz(TestBar). Since I can't have an init method, right now I'm calling a setup() method that assigns a bunch of variables to the class instance as a part of every single test method.

It looks like:

Class TestBaz(TestBar):
    def setup():
        super().setup()
        # do some other stuff

    def test_that_my_program_works(self):
        self.setup()
        my_program_works = do_stuff()
        assert my_program_works

But this is ugly and I was wondering if there was a way to get around it. One thing I got working -- I made this decorator function to decorate every method:

def setup(cls):
    def inner_function(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            cls.set_up()
            return func(*args, **kwargs)
        return wrapper
    return inner_function

but

@setup
def test_that_my_program_works():

is not that much better. I was sort of in the weeds reading about metaclasses and trying to figure out how I would more silently wrap every method when I realized that fundamentally I don't want or need to wrap every method. I just want a method that executes automatically on class initialization. I want __init__ without __init__.

Is there a way to do this?

回答1:

As you had seem, py.test have other means to run a setup for class-scoped methods. You'd probably run those, as they are guaranteed to be run at the right points between each (test) method call - as one won't have control on when py.test instantiate such a class.

For the record, just add a setup method to the class (the method name is all-lower case), like in:

class Test1:
    def setup(self):
        self.a = 1
    def test_blah(self):
        assert self.a == 1

However, as you asked about metaclasses, yes, a metaclass can work to create a "custom method equivalent to __init__".

When a new object is created, that is, when class is instantiated in Python, it is as though the class itself was called. What happens internally is that the __call__ method for the metaclass is called, with the parameters passed to create the instance.

This method then runs the class' __new__ and __init__ methods passing those parameters, and returns the value returned by __new__.

A metaclass inheriting from type can override __call__ to add extra __init__- like calls, and the code for that is just:

class Meta(type):
    def __call__(cls, *args, **kw):
        instance = super().__call__(*args, **kw)
        custom_init = getattr(instance, "__custom_init__", None)
        if callable(custom_init):
            custom_init(*args, **kw)

        return instance

I've tried this with a small class in a file I run with pytest, and it just works:

class Test2(metaclass=Meta):
    def __custom_init__(self):
        self.a = 1
    def test_blah(self):
        assert self.a == 1


回答2:

Fixtures

You can use autouse fixtures also for method-level setup/teardown. I would prefer using fixtures because of their flexibility - you can define class-specific method setup/teardown (running for every test method) or method-specific setup/teardown (running for a speficic test only) if/when needed. Examples:

import pytest


class TestFoo:
    @pytest.fixture(autouse=True)
    def foo(self):
        print('\nTestFoo instance setting up')
        yield
        print('TestFoo instance tearing down')


class TestBar(TestFoo):
    @pytest.fixture(autouse=True)
    def bar(self, foo):
        print('TestBar instance setting up')
        yield
        print('TestBar instance tearing down')


class TestBaz(TestBar):
    @pytest.fixture(autouse=True)
    def baz(self, bar):
        print('TestBaz instance setting up')
        yield
        print('\nTestBaz instance tearing down')

    def test_eggs(self):
        assert True

    def test_bacon(self):
        assert True

Test execution yields:

collected 2 items

test_spam.py::TestBaz::test_eggs
TestFoo instance setting up
TestBar instance setting up
TestBaz instance setting up
PASSED
TestBaz instance tearing down
TestBar instance tearing down
TestFoo instance tearing down

test_spam.py::TestBaz::test_bacon
TestFoo instance setting up
TestBar instance setting up
TestBaz instance setting up
PASSED
TestBaz instance tearing down
TestBar instance tearing down
TestFoo instance tearing down

Notice that I specify fixture execution order via arg dependencies (e.g. def bar(self, foo): so bar is executed after foo); if you omit the arguments, the execution order foo -> bar -> baz is not guaranteed. If you don't need the explicit ordering, you can safely omit the fixture args.

The above example, extended with a setup/teardown specific for TestBaz::test_bacon only:

class TestBaz(TestBar):
    @pytest.fixture(autouse=True)
    def baz(self, bar):
        print('TestBaz instance setting up')
        yield
        print('\nTestBaz instance tearing down')

    @pytest.fixture
    def bacon_specific(self):
        print('bacon specific test setup')
        yield
        print('\nbacon specific teardown')

    def test_eggs(self):
        assert True

    @pytest.mark.usefixtures('bacon_specific')
    def test_bacon(self):
        assert True

Execution yields:

...

test_spam.py::TestBaz::test_bacon 
TestFoo instance setting up
TestBar instance setting up
TestBaz instance setting up
bacon specific test setup
PASSED
bacon specific teardown    
TestBaz instance tearing down
TestBar instance tearing down
TestFoo instance tearing down

One-time setup/teardown per class is achieved by adjusting the fixture scope to class:

class TestFoo:
    @pytest.fixture(autouse=True, scope='class')
    def foo(self):
        print('\nTestFoo instance setting up')
        yield
        print('TestFoo instance tearing down')


class TestBar(TestFoo):
    @pytest.fixture(autouse=True, scope='class')
    def bar(self, foo):
        print('TestBar instance setting up')
        yield
        print('TestBar instance tearing down')


class TestBaz(TestBar):
    @pytest.fixture(autouse=True, scope='class')
    def baz(self, bar):
        print('TestBaz instance setting up')
        yield
        print('\nTestBaz instance tearing down')

    def test_eggs(self):
        assert True

    def test_bacon(self):
        assert True

Execution:

collected 2 items

test_spam2.py::TestBaz::test_eggs
TestFoo instance setting up
TestBar instance setting up
TestBaz instance setting up
PASSED
test_spam2.py::TestBaz::test_bacon PASSED
TestBaz instance tearing down
TestBar instance tearing down
TestFoo instance tearing down

xUnit method setup/teardown

You can use the xUnit-style setup, in particular the Method and function level setup/teardown; these are usual class methods and support inheritance. Example:

class TestFoo:
    def setup_method(self):
        print('\nTestFoo::setup_method called')
    def teardown_method(self):
        print('TestFoo::teardown_method called')


class TestBar(TestFoo):
    def setup_method(self):
        super().setup_method()
        print('TestBar::setup_method called')

    def teardown_method(self):
        print('TestBar::teardown_method called')
        super().teardown_method()


class TestBaz(TestBar):
    def setup_method(self):
        super().setup_method()
        print('TestBaz::setup_method called')

    def teardown_method(self):
        print('\nTestBaz::teardown_method called')
        super().teardown_method()

    def test_eggs(self):
        assert True

    def test_bacon(self):
        assert True

Test execution yields:

collected 2 items

test_spam.py::TestBaz::test_eggs 
TestFoo::setup_method called
TestBar::setup_method called
TestBaz::setup_method called
PASSED
TestBaz::teardown_method called
TestBar::teardown_method called
TestFoo::teardown_method called

test_spam.py::TestBaz::test_bacon 
TestFoo::setup_method called
TestBar::setup_method called
TestBaz::setup_method called
PASSED
TestBaz::teardown_method called
TestBar::teardown_method called
TestFoo::teardown_method called