From 5d9fb17b8e30bb059e752a46eef4c52bbbca0915 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Fri, 12 Jun 2026 21:32:01 +0200 Subject: [PATCH 1/2] PoC: support fixtures as describe block arguments (#38) Proof of concept for injecting pytest fixtures declared as parameters of describe blocks, shared by all tests nested in the block: - At collection, the describe function is called with named placeholder objects, and the closure cells holding them are recorded. - A pytest_generate_tests hook adds the declared names to the fixture closure (names_closure + initialnames + arg2fixturedefs), so pytest itself resolves the fixtures and parametrized fixtures multiply tests. - Hook wrappers around test setup/teardown write the resolved fixture values into the closure cells and restore the placeholders afterwards. Unlike the earlier proof of concept in PR #39, test functions are not wrapped, so @pytest.mark.parametrize and signature introspection keep working. Verified with tox against pytest 7.0-9.0 and latest on CPython and against pytest 8.4 on PyPy; ruff and strict mypy pass. Known spike limitations: shared behaviors with arguments, misuse of placeholder values inside the describe body (no guard yet), transitive parametrized dependencies of injected fixtures, docs and full coverage. --- .gitignore | 1 + src/pytest_describe/plugin.py | 157 ++++++++++++++++++++++++++++- test/test_describe_args.py | 183 ++++++++++++++++++++++++++++++++++ 3 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 test/test_describe_args.py diff --git a/.gitignore b/.gitignore index 4a3a2a2..ee88efd 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ venv/ ENV/ env.bak/ venv.bak/ +uv.lock # IDEs .idea diff --git a/src/pytest_describe/plugin.py b/src/pytest_describe/plugin.py index 1e73285..f837b85 100644 --- a/src/pytest_describe/plugin.py +++ b/src/pytest_describe/plugin.py @@ -2,9 +2,10 @@ from __future__ import annotations +import inspect import sys import types -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator from typing import Any import pytest @@ -27,6 +28,58 @@ def is_fixture_function_definition(obj: object) -> bool: return isinstance(obj, FixtureFunctionDefinition) +class DescribeArgument: + """Placeholder for a fixture passed as argument to a describe block. + + Describe blocks run at collection time, when fixtures are not yet + available. The real fixture value is injected into the closure cells + before each test runs. + """ + + __slots__ = ("name",) + + def __init__(self, name: str) -> None: + self.name = name + + def __repr__(self) -> str: + return ( + f"" + ) + + +def get_describe_args(func: Callable[..., Any]) -> tuple[str, ...]: + """Get the fixture names a describe block declares as parameters.""" + return tuple( + name + for name, param in inspect.signature(func).parameters.items() + if param.kind + in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY, param.POSITIONAL_ONLY) + ) + + +def find_argument_cells(namespace: dict[str, Any]) -> dict[str, types.CellType]: + """Find closure cells holding describe arguments in collected functions. + + Cells are shared between all nested functions referencing the same + outer variable, so recording one cell per argument name is enough. + """ + cells: dict[str, types.CellType] = {} + for obj in namespace.values(): + # Unwrap fixture function definitions (pytest >= 8.4) + func = inspect.unwrap(obj) if callable(obj) else obj + if not isinstance(func, types.FunctionType): + continue + for cell in func.__closure__ or (): + try: + contents = cell.cell_contents + except ValueError: # pragma: no cover (empty cell) + continue + if isinstance(contents, DescribeArgument): + cells[contents.name] = cell + return cells + + def trace_function( func: Callable[..., Any], *args: Any, **kwargs: Any ) -> dict[str, Any]: @@ -54,7 +107,9 @@ def _trace_func( return f_locals -def make_module_from_function(func: types.FunctionType) -> types.ModuleType: +def make_module_from_function( + func: types.FunctionType, args: tuple[str, ...] = () +) -> types.ModuleType: """Evaluate the local scope of a function as if it was a module.""" module = types.ModuleType(func.__name__) @@ -64,8 +119,11 @@ def make_module_from_function(func: types.FunctionType) -> types.ModuleType: for shared_func in getattr(func, "_behaves_like", ()): module.__dict__.update(evaluate_shared_behavior(shared_func)) - # Import children - module.__dict__.update(trace_function(func)) + # Import children, passing placeholders for declared fixture arguments + funclocals = trace_function(func, **{name: DescribeArgument(name) for name in args}) + for name in args: + funclocals.pop(name, None) + module.__dict__.update(funclocals) return module @@ -96,6 +154,9 @@ class DescribeBlock(pytest.Module): # of type FunctionType, so we need to ignore some errors when using it. funcobj: types.FunctionType + describe_args: tuple[str, ...] + describe_cells: dict[str, types.CellType] + @classmethod def from_parent( # type: ignore[override] cls, parent: pytest.Collector, obj: types.FunctionType @@ -108,6 +169,8 @@ def from_parent( # type: ignore[override] ) self.name = name self.funcobj = obj # type: ignore[assignment] + self.describe_args = get_describe_args(obj) + self.describe_cells = {} return self def collect(self) -> Iterable[pytest.Item | pytest.Collector]: @@ -121,7 +184,11 @@ def _getobj(self) -> types.ModuleType: def _importtestmodule(self) -> types.ModuleType: """Import a describe block as if it was a module""" - module = make_module_from_function(self.funcobj) # type: ignore[arg-type] + module = make_module_from_function( + self.funcobj, # type: ignore[arg-type] + self.describe_args, + ) + self.describe_cells = find_argument_cells(module.__dict__) self.own_markers = getattr(self.funcobj, "pytestmark", []) return module @@ -151,6 +218,86 @@ def pytest_pycollect_makeitem( return None +# Since pytest 8.1, FixtureManager.getfixturedefs takes the node itself +# instead of its node id. +_getfixturedefs_takes_node = pytest.version_tuple >= (8, 1) + + +@pytest.hookimpl(tryfirst=True) +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Add fixtures declared as describe arguments to the fixture closure. + + This makes pytest resolve these fixtures for every test in the block, + including generating parametrized tests for parametrized fixtures. + """ + definition = metafunc.definition + arg_names = [ + name + for node in definition.listchain() + if isinstance(node, DescribeBlock) + for name in node.describe_args + ] + if not arg_names: + return + fixturemanager = definition.session._fixturemanager + fixtureinfo = definition._fixtureinfo + node = definition if _getfixturedefs_takes_node else definition.nodeid + for name in arg_names: + if name in metafunc.fixturenames: + continue + # metafunc.fixturenames is the same list as the names_closure + # of the fixture info used by the test item later + metafunc.fixturenames.append(name) + # the name must also be in initialnames, otherwise it is removed + # again by prune_dependency_tree() when the test is parametrized + # (since pytest 9, FuncFixtureInfo is a frozen dataclass) + object.__setattr__( + fixtureinfo, "initialnames", (*fixtureinfo.initialnames, name) + ) + fixturedefs = fixturemanager.getfixturedefs( + name, + node, # type: ignore[arg-type] # node id before pytest 8.1 + ) + if fixturedefs: + metafunc._arg2fixturedefs[name] = list(fixturedefs) + + +def gather_describe_cells(item: pytest.Function) -> dict[str, types.CellType]: + """Collect the argument cells of all describe blocks enclosing a test.""" + cells: dict[str, types.CellType] = {} + for node in item.listchain(): + if isinstance(node, DescribeBlock): + cells.update(node.describe_cells) + return cells + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_setup(item: pytest.Item) -> Iterator[None]: + """Inject fixture values into the closure cells of describe arguments.""" + yield # let pytest set up the fixtures first + funcargs = getattr(item, "funcargs", None) + if funcargs is None or not isinstance(item, pytest.Function): + return + saved: list[tuple[types.CellType, Any]] = [] + for name, cell in gather_describe_cells(item).items(): + if name in funcargs: + saved.append((cell, cell.cell_contents)) + cell.cell_contents = funcargs[name] + if saved: + item._describe_saved_cells = saved # type: ignore[attr-defined] + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_teardown(item: pytest.Item) -> Iterator[None]: + """Restore the placeholders in the closure cells after the test.""" + yield # let pytest tear down the fixtures first + saved = getattr(item, "_describe_saved_cells", None) + if saved: + for cell, old_value in reversed(saved): + cell.cell_contents = old_value + del item._describe_saved_cells # type: ignore[attr-defined] + + def pytest_addoption(parser: pytest.Parser) -> None: """Add configuration option describe_prefixes.""" parser.addini( diff --git a/test/test_describe_args.py b/test/test_describe_args.py new file mode 100644 index 0000000..4f616c0 --- /dev/null +++ b/test/test_describe_args.py @@ -0,0 +1,183 @@ +"""Test fixtures passed as describe block arguments (issue #38)""" + + +def test_module_fixture_as_describe_arg(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def thing(): + return 42 + + def describe_something(thing): + def thing_is_42(): + assert thing == 42 + + def thing_is_not_43(): + assert thing != 43 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=2) + + +def test_describe_arg_used_in_nested_block(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def thing(): + return 42 + + def describe_something(thing): + def describe_nested(): + def thing_is_42(): + assert thing == 42 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_local_fixture_as_nested_describe_arg(pytester): + pytester.makepyfile( + """ + import pytest + + def describe_something(): + @pytest.fixture + def thing(): + return 42 + + def describe_nested(thing): + def thing_is_42(): + assert thing == 42 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_parametrized_fixture_as_describe_arg(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(params=[1, 2, 3]) + def number(request): + return request.param + + def describe_something(number): + def number_is_positive(): + assert number > 0 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=3) + + +def test_mix_describe_args_and_test_args(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def user(): + return "alice" + + @pytest.fixture + def book(): + return "moby dick" + + def describe_create_book(user): + def with_book(book): + assert user == "alice" + assert book == "moby dick" + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_parametrize_mark_inside_describe_with_args(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def thing(): + return 42 + + def describe_something(thing): + @pytest.mark.parametrize("offset", [1, 2]) + def thing_plus_offset(offset): + assert thing + offset > 42 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=2) + + +def test_unused_describe_arg_is_still_requested(pytester): + pytester.makepyfile( + """ + import pytest + + added = [] + + @pytest.fixture + def effect(): + added.append(1) + + def describe_something(effect): + def effect_happened(): + assert added == [1] + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_missing_fixture_for_describe_arg(pytester): + pytester.makepyfile( + """ + def describe_something(does_not_exist): + def some_test(): + pass + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines(["*fixture 'does_not_exist' not found*"]) + + +def test_placeholder_is_restored_between_tests(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(params=["a", "b"]) + def letter(request): + return request.param + + def describe_something(letter): + def letter_is_valid(): + assert letter in ("a", "b") + + def letter_is_short(): + assert len(letter) == 1 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=4) From 7e5330be4ecd278f778096568d14b789e519a7d8 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Fri, 12 Jun 2026 21:59:05 +0200 Subject: [PATCH 2/2] Support fixtures as describe block arguments (#38) Fixtures declared as parameters of a describe block (or of a shared behavior) are now injected into all tests nested in the block: def describe_create_book(user): def with_valid_book(valid_book): ... # may use both the user and valid_book fixtures Implementation: - At collection, the describe function is called with named placeholder objects instead of failing, and the closure cells holding the placeholders are recorded on the DescribeBlock. The placeholders raise a descriptive error when used in the describe body itself. - A pytest_generate_tests hook adds the declared names and their transitive dependencies to the fixture closure of each test in the block, so pytest resolves the fixtures itself and parametrized fixtures multiply the tests as usual. - An autouse fixture, created lazily for each block with arguments, resolves the fixture values, writes them into the closure cells before other function-scoped fixtures of the block run, and restores the placeholders on teardown. This also allows fixtures defined in the block to use the describe arguments. - Parameter handling mirrors pytest's fixture name detection (mandatory positional-or-keyword and keyword-only parameters). Unlike the earlier proof of concept in PR #39, test functions are not wrapped, so @pytest.mark.parametrize and signature introspection keep working. Thanks to @ROpdebee for the original investigation. Closes #38 --- README.md | 48 ++++++++ src/pytest_describe/plugin.py | 178 ++++++++++++++++++-------- test/test_describe_args.py | 226 ++++++++++++++++++++++++++++++++++ 3 files changed, 398 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 3d52220..2344aad 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,54 @@ def describe_function(): ``` +## Fixtures as describe arguments + +When several tests in a describe-block need the same fixture, you can pass +the fixture name as an argument to the describe function instead of +repeating it in every test: + +```python +import pytest + + +@pytest.fixture +def user(): + return create_user() + + +def describe_create_book(user): + + def with_valid_book(valid_book): + # use the user and valid_book fixtures ... + + def with_invalid_book(invalid_book): + # use the user and invalid_book fixtures ... +``` + +This is functionally equivalent to declaring the fixture as an argument of +every test in the block. In particular, parametrized fixtures generate +multiple tests as usual, and fixtures defined inside the block can use the +describe arguments as well. Shared behaviors can also declare arguments, +which are then treated like arguments of the importing describe-blocks. + +Note that describe-blocks run when tests are *collected*, before any +fixture is set up. The arguments are therefore only placeholders during +collection, and the actual fixture values are injected when the tests run. +Using an argument in the describe-block body itself — anywhere outside a +test or fixture function — raises an error at collection time: + +```python +def describe_create_book(user): + name = user.name # error: user is only available inside the tests + + def with_valid_book(valid_book): + name = user.name # works fine +``` + +For the same reason, fixtures with a scope higher than `function` and +autouse fixtures should not use describe arguments, because they may be +set up before the values are injected. + ## Why bother? I've found that quite often my tests have one "dimension" more than my production diff --git a/src/pytest_describe/plugin.py b/src/pytest_describe/plugin.py index f837b85..9402300 100644 --- a/src/pytest_describe/plugin.py +++ b/src/pytest_describe/plugin.py @@ -6,7 +6,7 @@ import sys import types from collections.abc import Callable, Iterable, Iterator -from typing import Any +from typing import Any, NoReturn import pytest @@ -28,12 +28,16 @@ def is_fixture_function_definition(obj: object) -> bool: return isinstance(obj, FixtureFunctionDefinition) +class DescribeArgumentError(Exception): + """Error raised when a describe argument is used outside of tests.""" + + class DescribeArgument: """Placeholder for a fixture passed as argument to a describe block. Describe blocks run at collection time, when fixtures are not yet available. The real fixture value is injected into the closure cells - before each test runs. + before each test runs. Using the placeholder itself raises an error. """ __slots__ = ("name",) @@ -47,27 +51,46 @@ def __repr__(self) -> str: " (the fixture is only available inside tests)>" ) + def _used(self, *args: Any, **kwargs: Any) -> NoReturn: + raise DescribeArgumentError( + f"The argument {self.name!r} is a placeholder for a fixture" + " that is only available inside the tests of the describe" + " block, it cannot be used in the describe block itself." + ) + + __call__ = __getattr__ = __getitem__ = __setitem__ = __delitem__ = _used + __iter__ = __contains__ = __len__ = __bool__ = _used + __eq__ = __ne__ = __lt__ = __le__ = __gt__ = __ge__ = _used + __add__ = __sub__ = __mul__ = __truediv__ = _used + __hash__ = object.__hash__ + def get_describe_args(func: Callable[..., Any]) -> tuple[str, ...]: - """Get the fixture names a describe block declares as parameters.""" + """Get the fixture names a describe block declares as parameters. + + Mirroring how pytest determines fixture names for test functions, + only mandatory (non-defaulted) named parameters are considered. + """ return tuple( name for name, param in inspect.signature(func).parameters.items() - if param.kind - in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY, param.POSITIONAL_ONLY) + if param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY) + and param.default is param.empty ) -def find_argument_cells(namespace: dict[str, Any]) -> dict[str, types.CellType]: +def find_argument_cells(namespace: dict[str, Any]) -> dict[str, list[types.CellType]]: """Find closure cells holding describe arguments in collected functions. Cells are shared between all nested functions referencing the same - outer variable, so recording one cell per argument name is enough. + outer variable, but the same argument name can appear in multiple + cells, e.g. when a describe block and an imported shared behavior + both declare an argument with the same name. """ - cells: dict[str, types.CellType] = {} + cells: dict[str, list[types.CellType]] = {} for obj in namespace.values(): # Unwrap fixture function definitions (pytest >= 8.4) - func = inspect.unwrap(obj) if callable(obj) else obj + func = inspect.unwrap(obj) if is_fixture_function_definition(obj) else obj if not isinstance(func, types.FunctionType): continue for cell in func.__closure__ or (): @@ -76,7 +99,10 @@ def find_argument_cells(namespace: dict[str, Any]) -> dict[str, types.CellType]: except ValueError: # pragma: no cover (empty cell) continue if isinstance(contents, DescribeArgument): - cells[contents.name] = cell + name_cells = cells.setdefault(contents.name, []) + # check identity, equality would compare cell contents + if not any(c is cell for c in name_cells): + name_cells.append(cell) return cells @@ -119,10 +145,16 @@ def make_module_from_function( for shared_func in getattr(func, "_behaves_like", ()): module.__dict__.update(evaluate_shared_behavior(shared_func)) - # Import children, passing placeholders for declared fixture arguments - funclocals = trace_function(func, **{name: DescribeArgument(name) for name in args}) - for name in args: - funclocals.pop(name, None) + # Import children, passing placeholders for declared fixture arguments. + # Placeholders are removed from the namespace; note that this includes + # arguments of outer describe blocks appearing here as free variables. + funclocals = { + name: obj + for name, obj in trace_function( + func, **{name: DescribeArgument(name) for name in args} + ).items() + if not isinstance(obj, DescribeArgument) + } module.__dict__.update(funclocals) return module @@ -133,7 +165,11 @@ def evaluate_shared_behavior(func: types.FunctionType) -> dict[str, Any]: shared_functions: dict[str, Any] = func._shared_functions # type: ignore[attr-defined] except AttributeError: shared_functions = {} - for name, obj in trace_function(func).items(): + funclocals = trace_function( + func, + **{name: DescribeArgument(name) for name in get_describe_args(func)}, + ) + for name, obj in funclocals.items(): # Only functions and fixtures are relevant here if not is_function_or_fixture(obj): continue @@ -155,7 +191,7 @@ class DescribeBlock(pytest.Module): funcobj: types.FunctionType describe_args: tuple[str, ...] - describe_cells: dict[str, types.CellType] + describe_cells: dict[str, list[types.CellType]] @classmethod def from_parent( # type: ignore[override] @@ -188,7 +224,17 @@ def _importtestmodule(self) -> types.ModuleType: self.funcobj, # type: ignore[arg-type] self.describe_args, ) + # Arguments of imported shared behaviors are treated like + # arguments of the describe block that imports the behavior. + for shared_func in getattr(self.funcobj, "_behaves_like", ()): + for name in get_describe_args(shared_func): + if name not in self.describe_args: + self.describe_args += (name,) self.describe_cells = find_argument_cells(module.__dict__) + if self.describe_args: + # Provide the autouse fixture that injects the fixture values + # into the closure cells before each test in this block. + module.__dict__[INJECTOR_FIXTURE_NAME] = make_injector_fixture() self.own_markers = getattr(self.funcobj, "pytestmark", []) return module @@ -242,60 +288,84 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: fixturemanager = definition.session._fixturemanager fixtureinfo = definition._fixtureinfo node = definition if _getfixturedefs_takes_node else definition.nodeid - for name in arg_names: - if name in metafunc.fixturenames: + seen = set(metafunc.fixturenames) + for arg_name in arg_names: + if arg_name in seen: continue + seen.add(arg_name) # metafunc.fixturenames is the same list as the names_closure # of the fixture info used by the test item later - metafunc.fixturenames.append(name) + metafunc.fixturenames.append(arg_name) # the name must also be in initialnames, otherwise it is removed # again by prune_dependency_tree() when the test is parametrized # (since pytest 9, FuncFixtureInfo is a frozen dataclass) object.__setattr__( - fixtureinfo, "initialnames", (*fixtureinfo.initialnames, name) - ) - fixturedefs = fixturemanager.getfixturedefs( - name, - node, # type: ignore[arg-type] # node id before pytest 8.1 + fixtureinfo, "initialnames", (*fixtureinfo.initialnames, arg_name) ) - if fixturedefs: - metafunc._arg2fixturedefs[name] = list(fixturedefs) - - -def gather_describe_cells(item: pytest.Function) -> dict[str, types.CellType]: + # register the fixture definitions of the added fixture and its + # transitive dependencies, so that parametrized fixtures multiply + # the tests even when they are only used indirectly + pending = [arg_name] + while pending: + dep_name = pending.pop() + fixturedefs = fixturemanager.getfixturedefs( + dep_name, + node, # type: ignore[arg-type] # node id before pytest 8.1 + ) + if not fixturedefs: + continue # not a fixture (e.g. 'request'), fails at setup + metafunc._arg2fixturedefs[dep_name] = list(fixturedefs) + for dep in fixturedefs[-1].argnames: + if dep not in seen: + seen.add(dep) + metafunc.fixturenames.append(dep) + pending.append(dep) + + +def gather_describe_cells(item: pytest.Item) -> dict[str, list[types.CellType]]: """Collect the argument cells of all describe blocks enclosing a test.""" - cells: dict[str, types.CellType] = {} + cells: dict[str, list[types.CellType]] = {} for node in item.listchain(): if isinstance(node, DescribeBlock): - cells.update(node.describe_cells) + for name, name_cells in node.describe_cells.items(): + all_cells = cells.setdefault(name, []) + for cell in name_cells: + # check identity, equality would compare cell contents + if not any(c is cell for c in all_cells): + all_cells.append(cell) return cells -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_setup(item: pytest.Item) -> Iterator[None]: - """Inject fixture values into the closure cells of describe arguments.""" - yield # let pytest set up the fixtures first - funcargs = getattr(item, "funcargs", None) - if funcargs is None or not isinstance(item, pytest.Function): - return +INJECTOR_FIXTURE_NAME = "_pytest_describe_inject" + + +def inject_describe_fixtures(request: pytest.FixtureRequest) -> Iterator[None]: + """Inject fixture values into the closure cells of describe arguments. + + Used as an autouse fixture in describe blocks with arguments, so that + the values are already injected when other fixtures of the block run. + The placeholders are restored when the fixture is torn down. + """ saved: list[tuple[types.CellType, Any]] = [] - for name, cell in gather_describe_cells(item).items(): - if name in funcargs: + for name, name_cells in gather_describe_cells(request.node).items(): + value = request.getfixturevalue(name) + for cell in name_cells: saved.append((cell, cell.cell_contents)) - cell.cell_contents = funcargs[name] - if saved: - item._describe_saved_cells = saved # type: ignore[attr-defined] - - -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_teardown(item: pytest.Item) -> Iterator[None]: - """Restore the placeholders in the closure cells after the test.""" - yield # let pytest tear down the fixtures first - saved = getattr(item, "_describe_saved_cells", None) - if saved: - for cell, old_value in reversed(saved): - cell.cell_contents = old_value - del item._describe_saved_cells # type: ignore[attr-defined] + cell.cell_contents = value + yield + for cell, old_value in reversed(saved): + cell.cell_contents = old_value + + +def make_injector_fixture() -> Any: + """Create the autouse fixture that injects describe arguments. + + The fixture must be created lazily here, because fixtures defined in + the plugin module itself would be registered as global fixtures. + """ + return pytest.fixture(autouse=True, name=INJECTOR_FIXTURE_NAME)( + inject_describe_fixtures + ) def pytest_addoption(parser: pytest.Parser) -> None: diff --git a/test/test_describe_args.py b/test/test_describe_args.py index 4f616c0..1f68fa5 100644 --- a/test/test_describe_args.py +++ b/test/test_describe_args.py @@ -1,5 +1,9 @@ """Test fixtures passed as describe block arguments (issue #38)""" +import pytest + +from pytest_describe.plugin import DescribeArgument, DescribeArgumentError + def test_module_fixture_as_describe_arg(pytester): pytester.makepyfile( @@ -161,6 +165,228 @@ def some_test(): result.stdout.fnmatch_lines(["*fixture 'does_not_exist' not found*"]) +def test_using_describe_arg_in_describe_body_raises(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def thing(): + return 42 + + def describe_something(thing): + doubled = thing + thing + + def some_test(): + assert doubled == 84 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines( + ["*The argument 'thing' is a placeholder for a fixture*"] + ) + + +def test_describe_arg_with_default_is_not_a_fixture(pytester): + pytester.makepyfile( + """ + def describe_something(thing=42): + def thing_is_42(): + assert thing == 42 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_keyword_only_describe_arg(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def thing(): + return 42 + + def describe_something(*, thing): + def thing_is_42(): + assert thing == 42 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_shared_behavior_with_args(pytester): + pytester.makepyfile( + """ + import pytest + from pytest_describe import behaves_like + + def a_duck(sound): + def it_quacks(): + assert sound == "quack" + + @behaves_like(a_duck) + def describe_something_that_quacks(): + @pytest.fixture + def sound(): + return "quack" + + @behaves_like(a_duck) + def describe_something_that_barks(): + @pytest.fixture + def sound(): + return "bark" + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(failed=1, passed=1) + + +def test_transitive_parametrized_dependency(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(params=[1, 2, 3]) + def number(request): + return request.param + + @pytest.fixture + def double(number): + return 2 * number + + def describe_something(double): + def double_is_even(): + assert double % 2 == 0 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=3) + + +def test_same_fixture_as_describe_and_test_arg(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def thing(): + return 42 + + def describe_something(thing): + def thing_is_42(thing): + assert thing == 42 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_local_fixture_uses_describe_arg(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def number(): + return 21 + + def describe_something(number): + @pytest.fixture + def double(): + return 2 * number + + def double_is_42(double): + assert double == 42 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_multiple_describe_args(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def first(): + return 1 + + @pytest.fixture + def second(): + return 2 + + def describe_something(first, second): + def sum_is_3(): + assert first + second == 3 + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + +def test_missing_fixture_referenced_in_test(pytester): + pytester.makepyfile( + """ + def describe_something(does_not_exist): + def some_test(): + assert does_not_exist + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines(["*fixture 'does_not_exist' not found*"]) + + +def test_shared_behavior_arg_same_as_describe_arg(pytester): + pytester.makepyfile( + """ + import pytest + from pytest_describe import behaves_like + + def a_duck(sound): + def it_quacks(): + assert sound == "quack" + + @behaves_like(a_duck) + def describe_quacking(sound): + def it_still_quacks(): + assert sound == "quack" + + @pytest.fixture + def sound(): + return "quack" + """ + ) + + result = pytester.runpytest() + result.assert_outcomes(passed=2) + + +def test_placeholder_repr_and_misuse_message(): + placeholder = DescribeArgument("thing") + assert repr(placeholder) == ( + "" + ) + with pytest.raises(DescribeArgumentError, match="placeholder"): + _ = placeholder.some_attribute + with pytest.raises(DescribeArgumentError, match="cannot be used"): + placeholder() + + def test_placeholder_is_restored_between_tests(pytester): pytester.makepyfile( """