Search code examples
pythonpytestdecorator

Pytest decorator for fixture maker pattern


I find myself frequently using the following pattern for pytest fixtures:

@pytest.fixture()
def make_stuff(fix1, fix2):
    def _fn(arg1, arg2):
        ... do something ...
    return _fn

...

def test_me(make_stuff):
    make_stuff(1, 2)

My idea was to create a maker decorator to DRY it out.

@pytest.fixture()
@pytest.maker(fix1, fix2)
def make_stuff(arg1, arg2):
    ... do something ...

I don't see a good way to plug this idea into the pytest framework. Ideas would be appreciated.


Solution

  • What makes your idea particularly tricky is the fact that you're trying to turn fix1 and fix2, originally available to the inner function through closure:

    @pytest.fixture
    def make_stuff(fix1, fix2):
        def _fn(arg1, arg2):
            print(fix1) # fix1 is bound the argument fix1 passed to make_stuff
        return _fn
    

    into arguments that get passed to a decorator factory, making them inaccessible within the decorated function:

    @pytest.fixture
    def fix1():
        ...
    
    @pytest.fixture
    @magic_decorator(fix1, fix2)
    def make_stuff(arg1, arg2):
        print(fix1) # fix1 is now bound to the global function fix1--not what you want
    

    To make arguments passed to the decorator factory available to the decorated function, one approach would be to programmatically refactor the latter pattern of code into the former through manipulation of AST, which can be done by parsing the function source into AST and using ast.NodeTransformer with a function visitor to enclose the decorated function in an outer function defined with arguments named the same as those passed to the decorator factory. Once transformed, compile the AST into a code object and execute it to produce the desired function object to replace the decorated function:

    import ast
    import inspect
    from textwrap import dedent
    
    class AddClosure(ast.NodeTransformer):
        def __init__(self, names):
            self.names = names
    
        def visit_FunctionDef(self, node):
            node.decorator_list = [
                d for d in node.decorator_list
                if not isinstance(d, ast.Call) or d.func.id != 'with_fixtures'
            ]
            self.generic_visit(node)
            return ast.FunctionDef(
                name=node.name,
                args=ast.arguments(
                    args=[ast.arg(arg=name) for name in self.names],
                    posonlyargs=[], kwonlyargs=[], kw_defaults=[], defaults=[]
                ),
                body=[node, ast.Return(value=ast.Name(id=node.name, ctx=ast.Load()))],
                decorator_list = []
            )
    
    def with_fixtures(*fixtures):
        def decorator(func):
            tree = AddClosure(f.__name__ for f in fixtures).visit(
                ast.parse(dedent(inspect.getsource(func))))
            ast.fix_missing_locations(tree)
            scope = {}
            exec(compile(tree, inspect.getfile(func), "exec"), func.__globals__, scope)
            return pytest.fixture(scope[func.__name__])
        return decorator
    

    so that:

    @pytest.fixture
    def fix1():
        return 'a'
    
    @pytest.fixture
    def fix2():
        return 'b'
    
    @with_fixtures(fix1, fix2)
    def make_stuff(arg1, arg2):
        return fix1, fix2, arg1, arg2
    
    def test_make_stuff(make_stuff):
        assert make_stuff(1, 2) == ('a', 'b', 1, 2)
    

    would pass the test.

    Demo (switch to the Shell tab and run pytest main.py): https://replit.com/@blhsing1/DarkorangeDimpledGreenware