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/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 1e73285..9402300 100644 --- a/src/pytest_describe/plugin.py +++ b/src/pytest_describe/plugin.py @@ -2,10 +2,11 @@ from __future__ import annotations +import inspect import sys import types -from collections.abc import Callable, Iterable -from typing import Any +from collections.abc import Callable, Iterable, Iterator +from typing import Any, NoReturn import pytest @@ -27,6 +28,84 @@ 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. Using the placeholder itself raises an error. + """ + + __slots__ = ("name",) + + def __init__(self, name: str) -> None: + self.name = name + + def __repr__(self) -> str: + return ( + f"" + ) + + 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. + + 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) + and param.default is param.empty + ) + + +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, 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, list[types.CellType]] = {} + for obj in namespace.values(): + # Unwrap fixture function definitions (pytest >= 8.4) + 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 (): + try: + contents = cell.cell_contents + except ValueError: # pragma: no cover (empty cell) + continue + if isinstance(contents, DescribeArgument): + 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 + + def trace_function( func: Callable[..., Any], *args: Any, **kwargs: Any ) -> dict[str, Any]: @@ -54,7 +133,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 +145,17 @@ 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. + # 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 @@ -75,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 @@ -96,6 +190,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, list[types.CellType]] + @classmethod def from_parent( # type: ignore[override] cls, parent: pytest.Collector, obj: types.FunctionType @@ -108,6 +205,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 +220,21 @@ 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, + ) + # 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 @@ -151,6 +264,110 @@ 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 + 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(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, arg_name) + ) + # 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, list[types.CellType]] = {} + for node in item.listchain(): + if isinstance(node, DescribeBlock): + 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 + + +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, 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 = 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: """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..1f68fa5 --- /dev/null +++ b/test/test_describe_args.py @@ -0,0 +1,409 @@ +"""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( + """ + 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_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( + """ + 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)