Reputation: 68186
Is it possible to pass yielding pytest fixtures (for setup and teardown) as parameters to test functions?
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:
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.
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
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)
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
Reputation: 1
Are we making things a little complex? I've understand the core need as:
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
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
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
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