Paul H
Paul H

Reputation: 68186

Passing (yield) fixtures as test parameters (with a temp directory)

Question

Is it possible to pass yielding pytest fixtures (for setup and teardown) as parameters to test functions?

Context

I'm testing an object that reads and writes data from/to files in a single directory. That path of that directory is saved as an attribute of the object.

I'm having trouble with the following:

  1. using a temporary directory with my test; and
  2. ensuring that the directory is removed after each test.

Example

Consider the following (test_yieldfixtures.py):

import pytest, tempfile, os, shutil
from contextlib import contextmanager
    
@contextmanager
def data():
    datadir = tempfile.mkdtemp()  # setup
    yield datadir
    shutil.rmtree(datadir)        # teardown
    
class Thing:
    def __init__(self, datadir, errorfile):
        self.datadir = datadir
        self.errorfile = errorfile

    
@pytest.fixture
def thing1():
    with data() as datadir:
        errorfile = os.path.join(datadir, 'testlog1.log')
        yield Thing(datadir=datadir, errorfile=errorfile)

@pytest.fixture
def thing2():
    with data() as datadir:
        errorfile = os.path.join(datadir, 'testlog2.log')
        yield Thing(datadir=datadir, errorfile=errorfile)

@pytest.mark.parametrize('thing', [thing1, thing2])
def test_attr(thing):
    print(thing.datadir)
    assert os.path.exists(thing.datadir)

Running pytest test_yieldfixtures.py outputs the following:

================================== FAILURES ===================================
______________________________ test_attr[thing0] ______________________________

thing = <generator object thing1 at 0x0000017B50C61BF8>

    @pytest.mark.parametrize('thing', [thing1, thing2])
    def test_attr(thing):
>        print(thing.datadir)
E       AttributeError: 'function' object has no attribute 'props'

test_mod.py:39: AttributeError

OK. So fixture functions don't have a the properties of my class. Fair enough.

Attempt 1

A function won't have the properties, so I tried calling that functions to actually get the objects. However, that just

@pytest.mark.parametrize('thing', [thing1(), thing2()])
def test_attr(thing):
    print(thing.props['datadir'])
    assert os.path.exists(thing.get('datadir'))

Results in:

================================== FAILURES ===================================
______________________________ test_attr[thing0] ______________________________

thing = <generator object thing1 at 0x0000017B50C61BF8>

    @pytest.mark.parametrize('thing', [thing1(), thing2()])
    def test_attr(thing):
>       print(thing.datadir)
E       AttributeError: 'generator' object has no attribute 'props'

test_mod.py:39: AttributeError

Attempt 2

I also tried using return instead of yield in the thing1/2 fixtures, but that kicks me out of the data context manager and removes the directory:

================================== FAILURES ===================================
______________________________ test_attr[thing0] ______________________________

thing = <test_mod.Thing object at 0x000001C528F05358>

    @pytest.mark.parametrize('thing', [thing1(), thing2()])
    def test_attr(thing):
        print(thing.datadir)
>       assert os.path.exists(thing.datadir)

Closing

To restate the question: Is there anyway to pass these fixtures as parameters and maintain the cleanup of the temporary directory?

Upvotes: 5

Views: 7783

Answers (4)

user3592428
user3592428

Reputation: 1

Are we making things a little complex? I've understand the core need as:

  • Executing some code before and after a test, in order to create and then destroy a directory
  • Pass 2 or more parameters to the test, containing the directory and error file path

Based on: https://docs.pytest.org/en/stable/how-to/fixtures.html#teardown-cleanup-aka-fixture-finalization

This test passes (Python 3.13.0, pytest-8.3.3) and creates and destroys the directory successfully:

import os
import shutil
import pytest
from os.path import join, isfile
from pathlib import Path


@pytest.fixture()
def create_run_folder():
    # Setup
    data_directory: str = 'tmp_data'
    error_file: str = 'error.log'
    os.mkdir(data_directory)
    Path(join(data_directory, error_file)).touch()
    # Yield
    yield data_directory, error_file
    # Teardown
    shutil.rmtree(data_directory)


def test_doing_a_thing(create_run_folder):
    data_directory, error_file = create_run_folder
    print(data_directory)
    assert data_directory == 'tmp_data'
    assert isfile(join(data_directory, error_file))

Also I echo the comments about checking out https://docs.pytest.org/en/latest/how-to/tmp_path.html

Upvotes: 0

Okken
Okken

Reputation: 2796

Temporary directories and files are handled by pytest using the built in fixtures tmpdir and tmpdir_factory.

For this usage, tmpdir should be sufficient: https://docs.pytest.org/en/latest/tmpdir.html

Also, paramertrized fixtures would work well for this example.
These are documented here: https://docs.pytest.org/en/latest/fixture.html#fixture-parametrize

import os
import pytest


class Thing:
    def __init__(self, datadir, errorfile):
        self.datadir = datadir
        self.errorfile = errorfile


@pytest.fixture(params=(1, 2))
def thing(request, tmpdir):
    errorfile_name = 'testlog{}.log'.format(request.param)
    errorfile = tmpdir.join(errorfile_name)
    return Thing(datadir=str(tmpdir), errorfile=str(errorfile))


def test_attr(request, thing):
    assert os.path.exists(thing.datadir)

BTW, In Python Testing with pytest, parametrized fixtures are covered in ch3. tmpdir and other built in fixtures are covered in ch4.

Upvotes: 2

Frank T
Frank T

Reputation: 9046

Try making your data function / generator into a fixture. Then use request.getfixturevalue() to dynamically run the named fixture.

import pytest, tempfile, os, shutil
from contextlib import contextmanager

@pytest.fixture # This works with pytest>3.0, on pytest<3.0 use yield_fixture
def datadir():
    datadir = tempfile.mkdtemp()  # setup
    yield datadir
    shutil.rmtree(datadir)        # teardown

class Thing:
    def __init__(self, datadir, errorfile):
        self.datadir = datadir
        self.errorfile = errorfile


@pytest.fixture
def thing1(datadir):
    errorfile = os.path.join(datadir, 'testlog1.log')
    yield Thing(datadir=datadir, errorfile=errorfile)

@pytest.fixture
def thing2(datadir):
    errorfile = os.path.join(datadir, 'testlog2.log')
    yield Thing(datadir=datadir, errorfile=errorfile)

@pytest.mark.parametrize('thing_fixture_name', ['thing1', 'thing2'])
def test_attr(request, thing):
    thing = request.getfixturevalue(thing) # This works with pytest>3.0, on pytest<3.0 use getfuncargvalue
    print(thing.datadir)
    assert os.path.exists(thing.datadir)

Going one step futher, you can parametrize the thing fixtures like so:

class Thing:
    def __init__(self, datadir, errorfile):
        self.datadir = datadir
        self.errorfile = errorfile

@pytest.fixture(params=['test1.log', 'test2.log'])
def thing(request):
    with tempfile.TemporaryDirectory() as datadir:
        errorfile = os.path.join(datadir, request.param)
        yield Thing(datadir=datadir, errorfile=errorfile)

def test_thing_datadir(thing):
    assert os.path.exists(thing.datadir)

Upvotes: 3

Paul Cornelius
Paul Cornelius

Reputation: 10946

I see your problem but I'm not sure about the solution. The problem:

Your functions thing1 and thing2 contain yield statements. When you call a function like that, the returned value is a "generator object." It's an iterator - a sequence of values, which is of course not the same thing as the first value of yield, or any one particular value.

Those are the objects being passed to your test_attr function. The test environment is doing that for you automagically, or at least I think that's how it works.

What you really want is the object created in your yield expression, in other words, Thing(datadir=datadir, errorfile=errorfile). There are three ways to get a generator to emit its individual values: by calling next(iter), by calling iter.__next__() or by using the iterator in a loop with an in expression.

One possibility is to iterate the generator once. Like this:

def test_attr(thing):
    first_thing = next(thing)
    print(first_thing.datadir)
    assert os.path.exists(first_thing.datadir)

first_thing will be the object you want to test, i.e., Thing(datadir=datadir, errorfile=errorfile).

But this is only the first hurdle. The generator function is not finished. Its internal "program counter" is just after the yield statement. So you haven't exited the context manager and haven't deleted your temporary directory yet. To do this you must call next(thing) again and catch a StopIteration exception.

Alternatively I think this will work:

def test_attr(thing):
    for a_thing in thing:
        print(a_thing.datadir)
        assert os.path.exists(a_thing.datadir)

The in expression loops through all the items in the iterator (there's only one) and exits gracefully when StopIteration occurs. The function exits from the context manager and your work is done.

To me it's an open question whether this makes your code more or less readable and maintainable. It's a bit clumsy.

Upvotes: 1

Related Questions