Skip to content

Commit eb1fcb0

Browse files
committed
@use pytest fixtures by name rather than reference
Using by reference was problematic because the implementation did a fixture lookup using the function name, which could return an entirely different fixture than what was passed in.
1 parent dfd1536 commit eb1fcb0

File tree

3 files changed

+103
-55
lines changed

3 files changed

+103
-55
lines changed

README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,17 +170,19 @@ def test_output():
170170
assert captured.out == "hello\n"
171171
```
172172

173-
### Pytest fixtures can be applied with `@use`
173+
### `@use` pytest fixtures
174+
175+
Fixtures defined with `@pytest.fixture` can be applied to a test or other
176+
fixture by passing the fixture name to `@use`. None of the built-in fixtures
177+
provided by pytest make sense to use this way, but it is a useful technique for
178+
fixtures that have side effects, such as pytest-django's `db` fixture.
174179

175180
```py
176-
from _pytest.capture import capsys
177181
from unmagic import use
178182

179-
@use(capsys)
180-
def test_output(capsys):
181-
print("world")
182-
captured = capsys.readouterr()
183-
assert captured.out == "world\n"
183+
@use("db")
184+
def test_database():
185+
...
184186
```
185187

186188
## Running the `unmagic` test suite

src/unmagic/fixtures.py

Lines changed: 62 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ def fixture(func=None, /, scope="function", autouse=False):
2727
teardown. Fixtures may be passed to `@use()` or applied directly as
2828
a decorator to a test or other fixture.
2929
30+
This function also accepts context managers and strings as the first
31+
argument. A string will create a fixture that looks up the
32+
`pytest.fixture` with that name.
33+
3034
A fixture can be assigned a scope. It will be setup for the first
3135
test that uses it and torn down at the end of its scope.
3236
@@ -43,18 +47,11 @@ def fixture(func):
4347
def use(*fixtures):
4448
"""Apply fixture(s) to a function
4549
46-
Any context manager may be used as a fixture.
47-
48-
Magic fixtures may be passed to this decorator. Fixture resolution
49-
is done using the ``__name__`` attribute of the magic fixture
50-
function, so the actual fixture that is invoked will follow normal
51-
pytest fixture resolution rules, which looks first in the test
52-
module, then in relevant conftest modules, etc. This means that the
53-
fixture that is resolved may be an override of or even unrelated to
54-
the one that was passed to ``@use(...)``. In the future this may
55-
change to use precisely the passed fixture, so it is safest to pass
56-
the most specific fixture possible (the override rather than the
57-
overridden fixture).
50+
Accepted fixture types:
51+
52+
- `unmagic.fixture`
53+
- context manager
54+
- name of a `pytest.fixture` (`str`)
5855
"""
5956
if not fixtures:
6057
raise TypeError("At least one fixture is required")
@@ -115,41 +112,37 @@ class UnmagicFixture:
115112
def create(cls, fixture, scope="function", autouse=False):
116113
if isinstance(fixture, cls):
117114
return fixture
118-
if _api.getfixturemarker(fixture) is not None:
119-
@wraps(_api.get_real_func(fixture))
120-
def func():
121-
yield get_request().getfixturevalue(fixture.__name__)
122-
func.__pytest_wrapped__ = fixture.__pytest_wrapped__
123-
func.__unmagic_wrapped__ = fixture
115+
if isinstance(fixture, str):
116+
return PytestFixture(fixture, scope, autouse)
117+
118+
outer = fixture
119+
if (
120+
callable(fixture)
121+
and not hasattr(type(fixture), "__enter__")
122+
and not hasattr(fixture, "unmagic_fixtures")
123+
):
124+
fixture = fixture()
125+
if not hasattr(type(fixture), "__enter__"):
126+
raise TypeError(
127+
f"{outer!r} is not a fixture. Hint: expected generator "
128+
"functcion, context manager, or pytest.fixture name."
129+
)
130+
if isinstance(fixture, _GeneratorContextManager):
131+
# special case for contextmanager
132+
inner = wrapped = fixture.func
124133
else:
125-
outer = fixture
126-
if (
127-
callable(fixture)
128-
and not hasattr(type(fixture), "__enter__")
129-
and not hasattr(fixture, "unmagic_fixtures")
130-
):
131-
fixture = fixture()
132-
if not hasattr(type(fixture), "__enter__"):
133-
raise TypeError(
134-
f"{outer!r} is not a fixture. Hint: expected generator "
135-
"functcion, context manager, or pytest.fixture."
136-
)
137-
if isinstance(fixture, _GeneratorContextManager):
138-
# special case for contextmanager
139-
inner = wrapped = fixture.func
134+
if isinstance(fixture, mock._patch):
135+
inner = _pretty_patch(fixture)
140136
else:
141-
if isinstance(fixture, mock._patch):
142-
inner = _pretty_patch(fixture)
143-
else:
144-
inner = type(fixture)
145-
wrapped = inner.__enter__ # must be a function
146-
147-
@wraps(inner)
148-
def func():
149-
with fixture as value:
150-
yield value
151-
func.__pytest_wrapped__ = _api.Wrapper(wrapped)
152-
func.__unmagic_wrapped__ = outer
137+
inner = type(fixture)
138+
wrapped = inner.__enter__ # must be a function
139+
140+
@wraps(inner)
141+
def func():
142+
with fixture as value:
143+
yield value
144+
func.__pytest_wrapped__ = _api.Wrapper(wrapped)
145+
func.__unmagic_wrapped__ = outer
153146
# delete __wrapped__ to prevent pytest from
154147
# introspecting arguments from wrapped function
155148
del func.__wrapped__
@@ -220,6 +213,30 @@ def _register(self, node):
220213
)
221214

222215

216+
class PytestFixture(UnmagicFixture):
217+
218+
def __init__(self, name, scope, autouse):
219+
if autouse:
220+
raise ValueError(f"Cannot autouse pytest.fixture: {name!r}")
221+
if scope != "function":
222+
raise ValueError(f"Cannot set scope of pytest.fixture: {name!r}")
223+
224+
def func():
225+
assert 0, "should not get here"
226+
func.__name__ = self._id = name
227+
func.__doc__ = f"Unmagic-wrapped pytest.fixture: {name!r}"
228+
super().__init__(func, None, None)
229+
230+
def _get_value(self):
231+
return get_request().getfixturevalue(self._id)
232+
233+
def _is_registered_for(self, node):
234+
return True
235+
236+
def _register(self, node):
237+
raise NotImplementedError
238+
239+
223240
_SCOPE_NODE_ID = {
224241
"function": lambda n: n,
225242
"class": lambda n: n.rsplit("::", 1)[0],

tests/test_fixtures.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def test_module_is_fenced():
9595

9696

9797
def test_use_magic_fixture():
98-
cap = get_request().getfixturevalue("capsys")
98+
cap = fixture("capsys")()
9999
print("hello")
100100
captured = cap.readouterr()
101101
assert captured.out == "hello\n"
@@ -173,6 +173,36 @@ def test():
173173
result.assert_outcomes(failed=1)
174174

175175

176+
def test_module_fixture_using_session_pytest_fixture():
177+
@get_source
178+
def test_py():
179+
import pytest
180+
from unmagic import fixture, use
181+
182+
traces = []
183+
184+
@pytest.fixture(scope="session")
185+
def tracer():
186+
traces.append("session")
187+
yield
188+
189+
@fixture(scope="module")
190+
@use("tracer")
191+
def class_tracer():
192+
traces.append("module")
193+
yield
194+
195+
@class_tracer
196+
def test():
197+
assert "session" in traces
198+
assert "module" in traces
199+
200+
pytester = unmagic_tester()
201+
pytester.makepyfile(test_py)
202+
result = pytester.runpytest("-s")
203+
result.assert_outcomes(passed=1)
204+
205+
176206
@fixture(scope="module")
177207
def module_request():
178208
yield get_request()
@@ -584,10 +614,9 @@ def test():
584614
def test_use_magic_fixture(self):
585615
@get_source
586616
def test_py():
587-
from _pytest.capture import capsys
588617
from unmagic import use
589618

590-
@use(capsys)
619+
@use("capsys")
591620
def test():
592621
pass
593622

0 commit comments

Comments
 (0)