Skip to content

Commit 0b8ea18

Browse files
authored
Support fixtures as describe block arguments (#54)
Fixtures declared as parameters of a describe block (or shared behavior) are injected into all tests nested in the block. Unlike the earlier PoC, test functions are not wrapped: the declared names are added to each test's fixture closure in a pytest_generate_tests hook (so pytest resolves them itself and parametrized fixtures multiply tests, @pytest.mark.parametrize keeps working), and an autouse fixture writes the resolved values into the closure cells before each test, restoring placeholders on teardown. Using an argument in the describe body itself raises a descriptive collection-time error.
1 parent 40238f7 commit 0b8ea18

4 files changed

Lines changed: 682 additions & 7 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ venv/
5151
ENV/
5252
env.bak/
5353
venv.bak/
54+
uv.lock
5455

5556
# IDEs
5657
.idea

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,54 @@ def describe_function():
127127
```
128128

129129

130+
## Fixtures as describe arguments
131+
132+
When several tests in a describe-block need the same fixture, you can pass
133+
the fixture name as an argument to the describe function instead of
134+
repeating it in every test:
135+
136+
```python
137+
import pytest
138+
139+
140+
@pytest.fixture
141+
def user():
142+
return create_user()
143+
144+
145+
def describe_create_book(user):
146+
147+
def with_valid_book(valid_book):
148+
# use the user and valid_book fixtures ...
149+
150+
def with_invalid_book(invalid_book):
151+
# use the user and invalid_book fixtures ...
152+
```
153+
154+
This is functionally equivalent to declaring the fixture as an argument of
155+
every test in the block. In particular, parametrized fixtures generate
156+
multiple tests as usual, and fixtures defined inside the block can use the
157+
describe arguments as well. Shared behaviors can also declare arguments,
158+
which are then treated like arguments of the importing describe-blocks.
159+
160+
Note that describe-blocks run when tests are *collected*, before any
161+
fixture is set up. The arguments are therefore only placeholders during
162+
collection, and the actual fixture values are injected when the tests run.
163+
Using an argument in the describe-block body itself — anywhere outside a
164+
test or fixture function — raises an error at collection time:
165+
166+
```python
167+
def describe_create_book(user):
168+
name = user.name # error: user is only available inside the tests
169+
170+
def with_valid_book(valid_book):
171+
name = user.name # works fine
172+
```
173+
174+
For the same reason, fixtures with a scope higher than `function` and
175+
autouse fixtures should not use describe arguments, because they may be
176+
set up before the values are injected.
177+
130178
## Why bother?
131179

132180
I've found that quite often my tests have one "dimension" more than my production

src/pytest_describe/plugin.py

Lines changed: 224 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
from __future__ import annotations
44

5+
import inspect
56
import sys
67
import types
7-
from collections.abc import Callable, Iterable
8-
from typing import Any
8+
from collections.abc import Callable, Iterable, Iterator
9+
from typing import Any, NoReturn
910

1011
import pytest
1112

@@ -27,6 +28,84 @@ def is_fixture_function_definition(obj: object) -> bool:
2728
return isinstance(obj, FixtureFunctionDefinition)
2829

2930

31+
class DescribeArgumentError(Exception):
32+
"""Error raised when a describe argument is used outside of tests."""
33+
34+
35+
class DescribeArgument:
36+
"""Placeholder for a fixture passed as argument to a describe block.
37+
38+
Describe blocks run at collection time, when fixtures are not yet
39+
available. The real fixture value is injected into the closure cells
40+
before each test runs. Using the placeholder itself raises an error.
41+
"""
42+
43+
__slots__ = ("name",)
44+
45+
def __init__(self, name: str) -> None:
46+
self.name = name
47+
48+
def __repr__(self) -> str:
49+
return (
50+
f"<describe argument {self.name!r}"
51+
" (the fixture is only available inside tests)>"
52+
)
53+
54+
def _used(self, *args: Any, **kwargs: Any) -> NoReturn:
55+
raise DescribeArgumentError(
56+
f"The argument {self.name!r} is a placeholder for a fixture"
57+
" that is only available inside the tests of the describe"
58+
" block, it cannot be used in the describe block itself."
59+
)
60+
61+
__call__ = __getattr__ = __getitem__ = __setitem__ = __delitem__ = _used
62+
__iter__ = __contains__ = __len__ = __bool__ = _used
63+
__eq__ = __ne__ = __lt__ = __le__ = __gt__ = __ge__ = _used
64+
__add__ = __sub__ = __mul__ = __truediv__ = _used
65+
__hash__ = object.__hash__
66+
67+
68+
def get_describe_args(func: Callable[..., Any]) -> tuple[str, ...]:
69+
"""Get the fixture names a describe block declares as parameters.
70+
71+
Mirroring how pytest determines fixture names for test functions,
72+
only mandatory (non-defaulted) named parameters are considered.
73+
"""
74+
return tuple(
75+
name
76+
for name, param in inspect.signature(func).parameters.items()
77+
if param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY)
78+
and param.default is param.empty
79+
)
80+
81+
82+
def find_argument_cells(namespace: dict[str, Any]) -> dict[str, list[types.CellType]]:
83+
"""Find closure cells holding describe arguments in collected functions.
84+
85+
Cells are shared between all nested functions referencing the same
86+
outer variable, but the same argument name can appear in multiple
87+
cells, e.g. when a describe block and an imported shared behavior
88+
both declare an argument with the same name.
89+
"""
90+
cells: dict[str, list[types.CellType]] = {}
91+
for obj in namespace.values():
92+
# Unwrap fixture function definitions (pytest >= 8.4)
93+
func = inspect.unwrap(obj) if is_fixture_function_definition(obj) else obj
94+
if not isinstance(func, types.FunctionType):
95+
continue
96+
for cell in func.__closure__ or ():
97+
try:
98+
contents = cell.cell_contents
99+
except ValueError: # pragma: no cover (empty cell)
100+
continue
101+
if isinstance(contents, DescribeArgument):
102+
name_cells = cells.setdefault(contents.name, [])
103+
# check identity, equality would compare cell contents
104+
if not any(c is cell for c in name_cells):
105+
name_cells.append(cell)
106+
return cells
107+
108+
30109
def trace_function(
31110
func: Callable[..., Any], *args: Any, **kwargs: Any
32111
) -> dict[str, Any]:
@@ -54,7 +133,9 @@ def _trace_func(
54133
return f_locals
55134

56135

57-
def make_module_from_function(func: types.FunctionType) -> types.ModuleType:
136+
def make_module_from_function(
137+
func: types.FunctionType, args: tuple[str, ...] = ()
138+
) -> types.ModuleType:
58139
"""Evaluate the local scope of a function as if it was a module."""
59140
module = types.ModuleType(func.__name__)
60141

@@ -64,8 +145,17 @@ def make_module_from_function(func: types.FunctionType) -> types.ModuleType:
64145
for shared_func in getattr(func, "_behaves_like", ()):
65146
module.__dict__.update(evaluate_shared_behavior(shared_func))
66147

67-
# Import children
68-
module.__dict__.update(trace_function(func))
148+
# Import children, passing placeholders for declared fixture arguments.
149+
# Placeholders are removed from the namespace; note that this includes
150+
# arguments of outer describe blocks appearing here as free variables.
151+
funclocals = {
152+
name: obj
153+
for name, obj in trace_function(
154+
func, **{name: DescribeArgument(name) for name in args}
155+
).items()
156+
if not isinstance(obj, DescribeArgument)
157+
}
158+
module.__dict__.update(funclocals)
69159
return module
70160

71161

@@ -75,7 +165,11 @@ def evaluate_shared_behavior(func: types.FunctionType) -> dict[str, Any]:
75165
shared_functions: dict[str, Any] = func._shared_functions # type: ignore[attr-defined]
76166
except AttributeError:
77167
shared_functions = {}
78-
for name, obj in trace_function(func).items():
168+
funclocals = trace_function(
169+
func,
170+
**{name: DescribeArgument(name) for name in get_describe_args(func)},
171+
)
172+
for name, obj in funclocals.items():
79173
# Only functions and fixtures are relevant here
80174
if not is_function_or_fixture(obj):
81175
continue
@@ -96,6 +190,9 @@ class DescribeBlock(pytest.Module):
96190
# of type FunctionType, so we need to ignore some errors when using it.
97191
funcobj: types.FunctionType
98192

193+
describe_args: tuple[str, ...]
194+
describe_cells: dict[str, list[types.CellType]]
195+
99196
@classmethod
100197
def from_parent( # type: ignore[override]
101198
cls, parent: pytest.Collector, obj: types.FunctionType
@@ -108,6 +205,8 @@ def from_parent( # type: ignore[override]
108205
)
109206
self.name = name
110207
self.funcobj = obj # type: ignore[assignment]
208+
self.describe_args = get_describe_args(obj)
209+
self.describe_cells = {}
111210
return self
112211

113212
def collect(self) -> Iterable[pytest.Item | pytest.Collector]:
@@ -121,7 +220,21 @@ def _getobj(self) -> types.ModuleType:
121220

122221
def _importtestmodule(self) -> types.ModuleType:
123222
"""Import a describe block as if it was a module"""
124-
module = make_module_from_function(self.funcobj) # type: ignore[arg-type]
223+
module = make_module_from_function(
224+
self.funcobj, # type: ignore[arg-type]
225+
self.describe_args,
226+
)
227+
# Arguments of imported shared behaviors are treated like
228+
# arguments of the describe block that imports the behavior.
229+
for shared_func in getattr(self.funcobj, "_behaves_like", ()):
230+
for name in get_describe_args(shared_func):
231+
if name not in self.describe_args:
232+
self.describe_args += (name,)
233+
self.describe_cells = find_argument_cells(module.__dict__)
234+
if self.describe_args:
235+
# Provide the autouse fixture that injects the fixture values
236+
# into the closure cells before each test in this block.
237+
module.__dict__[INJECTOR_FIXTURE_NAME] = make_injector_fixture()
125238
self.own_markers = getattr(self.funcobj, "pytestmark", [])
126239
return module
127240

@@ -151,6 +264,110 @@ def pytest_pycollect_makeitem(
151264
return None
152265

153266

267+
# Since pytest 8.1, FixtureManager.getfixturedefs takes the node itself
268+
# instead of its node id.
269+
_getfixturedefs_takes_node = pytest.version_tuple >= (8, 1)
270+
271+
272+
@pytest.hookimpl(tryfirst=True)
273+
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
274+
"""Add fixtures declared as describe arguments to the fixture closure.
275+
276+
This makes pytest resolve these fixtures for every test in the block,
277+
including generating parametrized tests for parametrized fixtures.
278+
"""
279+
definition = metafunc.definition
280+
arg_names = [
281+
name
282+
for node in definition.listchain()
283+
if isinstance(node, DescribeBlock)
284+
for name in node.describe_args
285+
]
286+
if not arg_names:
287+
return
288+
fixturemanager = definition.session._fixturemanager
289+
fixtureinfo = definition._fixtureinfo
290+
node = definition if _getfixturedefs_takes_node else definition.nodeid
291+
seen = set(metafunc.fixturenames)
292+
for arg_name in arg_names:
293+
if arg_name in seen:
294+
continue
295+
seen.add(arg_name)
296+
# metafunc.fixturenames is the same list as the names_closure
297+
# of the fixture info used by the test item later
298+
metafunc.fixturenames.append(arg_name)
299+
# the name must also be in initialnames, otherwise it is removed
300+
# again by prune_dependency_tree() when the test is parametrized
301+
# (since pytest 9, FuncFixtureInfo is a frozen dataclass)
302+
object.__setattr__(
303+
fixtureinfo, "initialnames", (*fixtureinfo.initialnames, arg_name)
304+
)
305+
# register the fixture definitions of the added fixture and its
306+
# transitive dependencies, so that parametrized fixtures multiply
307+
# the tests even when they are only used indirectly
308+
pending = [arg_name]
309+
while pending:
310+
dep_name = pending.pop()
311+
fixturedefs = fixturemanager.getfixturedefs(
312+
dep_name,
313+
node, # type: ignore[arg-type] # node id before pytest 8.1
314+
)
315+
if not fixturedefs:
316+
continue # not a fixture (e.g. 'request'), fails at setup
317+
metafunc._arg2fixturedefs[dep_name] = list(fixturedefs)
318+
for dep in fixturedefs[-1].argnames:
319+
if dep not in seen:
320+
seen.add(dep)
321+
metafunc.fixturenames.append(dep)
322+
pending.append(dep)
323+
324+
325+
def gather_describe_cells(item: pytest.Item) -> dict[str, list[types.CellType]]:
326+
"""Collect the argument cells of all describe blocks enclosing a test."""
327+
cells: dict[str, list[types.CellType]] = {}
328+
for node in item.listchain():
329+
if isinstance(node, DescribeBlock):
330+
for name, name_cells in node.describe_cells.items():
331+
all_cells = cells.setdefault(name, [])
332+
for cell in name_cells:
333+
# check identity, equality would compare cell contents
334+
if not any(c is cell for c in all_cells):
335+
all_cells.append(cell)
336+
return cells
337+
338+
339+
INJECTOR_FIXTURE_NAME = "_pytest_describe_inject"
340+
341+
342+
def inject_describe_fixtures(request: pytest.FixtureRequest) -> Iterator[None]:
343+
"""Inject fixture values into the closure cells of describe arguments.
344+
345+
Used as an autouse fixture in describe blocks with arguments, so that
346+
the values are already injected when other fixtures of the block run.
347+
The placeholders are restored when the fixture is torn down.
348+
"""
349+
saved: list[tuple[types.CellType, Any]] = []
350+
for name, name_cells in gather_describe_cells(request.node).items():
351+
value = request.getfixturevalue(name)
352+
for cell in name_cells:
353+
saved.append((cell, cell.cell_contents))
354+
cell.cell_contents = value
355+
yield
356+
for cell, old_value in reversed(saved):
357+
cell.cell_contents = old_value
358+
359+
360+
def make_injector_fixture() -> Any:
361+
"""Create the autouse fixture that injects describe arguments.
362+
363+
The fixture must be created lazily here, because fixtures defined in
364+
the plugin module itself would be registered as global fixtures.
365+
"""
366+
return pytest.fixture(autouse=True, name=INJECTOR_FIXTURE_NAME)(
367+
inject_describe_fixtures
368+
)
369+
370+
154371
def pytest_addoption(parser: pytest.Parser) -> None:
155372
"""Add configuration option describe_prefixes."""
156373
parser.addini(

0 commit comments

Comments
 (0)