我正在尝试为 pytest 创建一个动态夹具加载器,它在测试产生值后执行一些工作。一开始,我不需要做任何复杂的事情,所以我只是在准备好后归还夹具:
from pathlib import Path
import shutil
from pytest import CaptureFixture
import pytest
from .tests import testutils
FIXTURES_ROOT = Path(__file__).parent / "fixtures"
INBOX = Path(__file__).parent / "inbox"
CONVERTED = Path(__file__).parent / "converted"
class TestItem:
converted_dir: Path
def __init__(self, inbox_dir: Path):
self.inbox_dir = inbox_dir
self.converted_dir = CONVERTED / inbox_dir.name
def load_test_fixture(
name: str,
*,
exclusive: bool = False,
override_name: str | None = None,
match_filter: str | None = None,
cleanup_inbox: bool = False,
):
src = FIXTURES_ROOT / name
if not src.exists():
raise FileNotFoundError(
f"Fixture {name} not found. Does it exist in {FIXTURES_ROOT}?"
)
dst = INBOX / (override_name or name)
dst.mkdir(parents=True, exist_ok=True)
for f in src.glob("**/*"):
dst_f = dst / f.relative_to(src)
if f.is_file() and not dst_f.exists():
dst_f.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(f, dst_f)
# if any files in dst are not in src, delete them
for f in dst.glob("**/*"):
src_f = src / f.relative_to(dst)
if f.is_file() and not src_f.exists():
f.unlink()
if exclusive or match_filter is not None:
testutils.set_match_filter(match_filter or name)
converted_dir = CONVERTED / (override_name or name)
shutil.rmtree(converted_dir, ignore_errors=True)
return TestItem(dst)
这效果很好 - 夹具动态加载,我可以用它创建一堆单行夹具:
@pytest.fixture(scope="function")
def basic_fixture():
return load_test_fixture("basic_fixture", exclusive=True)
def test_converted_dir_exists(
basic_fixture: TestItem, capfd: CaptureFixture[str]
):
assert basic_fixture.converted_dir.exists()
我还创建了一个多夹具加载器,它返回一组夹具:
def load_test_fixtures(
*names: str,
exclusive: bool = False,
override_names: list[str] | None = None,
match_filter: str | None = None,
):
if exclusive:
match_filter = match_filter or rf"^({'|'.join(override_names or names)})"
return [
load_test_fixture(name, override_name=override, match_filter=match_filter)
for (name, override) in zip(names, override_names or names)
]
但现在我想让加载器更像一个固定装置,它可以产生项目,然后自行清理。这对于单个加载器来说效果很好:
def load_test_fixture(
name: str,
*,
exclusive: bool = False,
override_name: str | None = None,
match_filter: str | None = None,
cleanup_inbox: bool = False,
):
# ...
# instead of returning, yield the item
yield TestItem(dst)
if cleanup_inbox:
shutil.rmtree(dst, ignore_errors=True)
@pytest.fixture(scope="function")
def basic_fixture():
yield from load_test_fixture("basic_fixture", exclusive=True)
但我无法让它与多重加载器一起工作,因为它返回一个生成器的生成器,或一个生成器列表。我也尝试将它们转换为 @pytest.fixtures ,但似乎无法让列表版本产生其项目并正确等待测试完成后进行清理,并且作为固定装置,我添加了处理作为间接参数或参数传递参数的问题(ew)。
def load_test_fixtures(
*names: str,
exclusive: bool = False,
override_names: list[str] | None = None,
match_filter: str | None = None,
):
if exclusive:
match_filter = match_filter or rf"^({'|'.join(override_names or names)})"
yield from (
load_test_fixture(name, match_filter=match_filter, override_name=override)
for name, override in zip(names, override_names or names)
)
# tried all combinations of list, tuple, and yield here, as well as
# yield (next(load_test_fixture(name, match_filter=match_filter, override_name=override))
# for name, override in zip(names, override_names or names)
我什至尝试过这个,这有点有效,但不等待测试结束来运行终结器:
@pytest.fixture(scope="function")
def multi_fixtures():
fixtures = []
for f in [
"basic_fixture",
"fancy_fixture",
"tasty_fixture",
"smart_fixture",
]:
fixtures.extend(
load_test_fixture(f, match_filter=match_filter, cleanup_inbox=True)
)
yield fixtures
(如果我在这里通过
cleanup_inbox=True
,它会在测试运行之前删除文件)。
好吧,这有点恶心,因为它需要从夹具传递
request
参数,但最终这样做是为了解决这个问题。如果有更优雅的方式,我很想知道。
def rm_from_inbox(*names: str):
for name in names:
inbox = INBOX / name
shutil.rmtree(inbox, ignore_errors=True)
testutils.print(f"Cleaning up {inbox}")
# Updated `load_test_fixture` to also call `rm_from_inbox`:
# ...
# if cleanup_inbox:
# rm_from_inbox(name)
def load_test_fixtures(
*names: str,
exclusive: bool = False,
override_names: list[str] | None = None,
match_filter: str | None = None,
cleanup_inbox: bool = False,
request: pytest.FixtureRequest | None = None,
):
if exclusive:
match_filter = match_filter or rf"^({'|'.join(override_names or names)})"
fixtures: list[TestItem] = []
for name, override in zip(names, override_names or names):
fixtures.extend(
load_test_fixture(name, match_filter=match_filter, override_name=override)
)
if cleanup_inbox:
if not request:
raise ValueError(
"cleanup_inbox requires `request` to be a pytest.FixtureRequest"
)
request.addfinalizer(lambda: rm_from_inbox(*names))
return fixtures
@pytest.fixture(scope="function")
def all_hardy_boys(request: pytest.FixtureRequest):
return load_test_fixtures(
"basic_fixture",
"fancy_fixture",
"tasty_fixture",
"smart_fixture",
exclusive=True,
request=request,
cleanup_inbox=True,
)