Skip to content

Commit 520ff29

Browse files
authored
Merge pull request #12096 from bluetech/staticmethod-instance
python: fix instance handling in static and class method tests
2 parents b777b05 + 0dc0360 commit 520ff29

File tree

6 files changed

+105
-19
lines changed

6 files changed

+105
-19
lines changed

changelog/12065.bugfix.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fixed a regression in pytest 8.0.0 where test classes containing ``setup_method`` and tests using ``@staticmethod`` or ``@classmethod`` would crash with ``AttributeError: 'NoneType' object has no attribute 'setup_method'``.
2+
3+
Now the :attr:`request.instance <pytest.FixtureRequest.instance>` attribute of tests using ``@staticmethod`` and ``@classmethod`` is no longer ``None``, but a fresh instance of the class, like in non-static methods.
4+
Previously it was ``None``, and all fixtures of such tests would share a single ``self``.

src/_pytest/fixtures.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -470,8 +470,9 @@ def cls(self):
470470
@property
471471
def instance(self):
472472
"""Instance (can be None) on which test function was collected."""
473-
function = getattr(self, "function", None)
474-
return getattr(function, "__self__", None)
473+
if self.scope != "function":
474+
return None
475+
return getattr(self._pyfuncitem, "instance", None)
475476

476477
@property
477478
def module(self):
@@ -1096,22 +1097,23 @@ def resolve_fixture_function(
10961097
fixturedef: FixtureDef[FixtureValue], request: FixtureRequest
10971098
) -> "_FixtureFunc[FixtureValue]":
10981099
"""Get the actual callable that can be called to obtain the fixture
1099-
value, dealing with unittest-specific instances and bound methods."""
1100+
value."""
11001101
fixturefunc = fixturedef.func
11011102
# The fixture function needs to be bound to the actual
11021103
# request.instance so that code working with "fixturedef" behaves
11031104
# as expected.
1104-
if request.instance is not None:
1105+
instance = request.instance
1106+
if instance is not None:
11051107
# Handle the case where fixture is defined not in a test class, but some other class
11061108
# (for example a plugin class with a fixture), see #2270.
11071109
if hasattr(fixturefunc, "__self__") and not isinstance(
1108-
request.instance,
1110+
instance,
11091111
fixturefunc.__self__.__class__, # type: ignore[union-attr]
11101112
):
11111113
return fixturefunc
11121114
fixturefunc = getimfunc(fixturedef.func)
11131115
if fixturefunc != fixturedef.func:
1114-
fixturefunc = fixturefunc.__get__(request.instance) # type: ignore[union-attr]
1116+
fixturefunc = fixturefunc.__get__(instance) # type: ignore[union-attr]
11151117
return fixturefunc
11161118

11171119

src/_pytest/python.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -302,10 +302,10 @@ def instance(self):
302302
"""Python instance object the function is bound to.
303303
304304
Returns None if not a test method, e.g. for a standalone test function,
305-
a staticmethod, a class or a module.
305+
a class or a module.
306306
"""
307-
node = self.getparent(Function)
308-
return getattr(node.obj, "__self__", None) if node is not None else None
307+
# Overridden by Function.
308+
return None
309309

310310
@property
311311
def obj(self):
@@ -1702,7 +1702,8 @@ def __init__(
17021702
super().__init__(name, parent, config=config, session=session)
17031703

17041704
if callobj is not NOTSET:
1705-
self.obj = callobj
1705+
self._obj = callobj
1706+
self._instance = getattr(callobj, "__self__", None)
17061707

17071708
#: Original function name, without any decorations (for example
17081709
#: parametrization adds a ``"[...]"`` suffix to function names), used to access
@@ -1752,12 +1753,31 @@ def function(self):
17521753
"""Underlying python 'function' object."""
17531754
return getimfunc(self.obj)
17541755

1755-
def _getobj(self):
1756-
assert self.parent is not None
1756+
@property
1757+
def instance(self):
1758+
try:
1759+
return self._instance
1760+
except AttributeError:
1761+
if isinstance(self.parent, Class):
1762+
# Each Function gets a fresh class instance.
1763+
self._instance = self._getinstance()
1764+
else:
1765+
self._instance = None
1766+
return self._instance
1767+
1768+
def _getinstance(self):
17571769
if isinstance(self.parent, Class):
17581770
# Each Function gets a fresh class instance.
1759-
parent_obj = self.parent.newinstance()
1771+
return self.parent.newinstance()
1772+
else:
1773+
return None
1774+
1775+
def _getobj(self):
1776+
instance = self.instance
1777+
if instance is not None:
1778+
parent_obj = instance
17601779
else:
1780+
assert self.parent is not None
17611781
parent_obj = self.parent.obj # type: ignore[attr-defined]
17621782
return getattr(parent_obj, self.originalname)
17631783

src/_pytest/unittest.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,16 +177,15 @@ class TestCaseFunction(Function):
177177
nofuncargs = True
178178
_excinfo: Optional[List[_pytest._code.ExceptionInfo[BaseException]]] = None
179179

180-
def _getobj(self):
180+
def _getinstance(self):
181181
assert isinstance(self.parent, UnitTestCase)
182-
testcase = self.parent.obj(self.name)
183-
return getattr(testcase, self.name)
182+
return self.parent.obj(self.name)
184183

185184
# Backward compat for pytest-django; can be removed after pytest-django
186185
# updates + some slack.
187186
@property
188187
def _testcase(self):
189-
return self._obj.__self__
188+
return self.instance
190189

191190
def setup(self) -> None:
192191
# A bound method to be called during teardown() if set (see 'runtest()').
@@ -296,7 +295,8 @@ def addDuration(self, testcase: "unittest.TestCase", elapsed: float) -> None:
296295
def runtest(self) -> None:
297296
from _pytest.debugging import maybe_wrap_pytest_function_for_tracing
298297

299-
testcase = self.obj.__self__
298+
testcase = self.instance
299+
assert testcase is not None
300300

301301
maybe_wrap_pytest_function_for_tracing(self)
302302

testing/python/fixtures.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4577,3 +4577,48 @@ def test_deduplicate_names() -> None:
45774577
assert items == ("a", "b", "c", "d")
45784578
items = deduplicate_names((*items, "g", "f", "g", "e", "b"))
45794579
assert items == ("a", "b", "c", "d", "g", "f", "e")
4580+
4581+
4582+
def test_staticmethod_classmethod_fixture_instance(pytester: Pytester) -> None:
4583+
"""Ensure that static and class methods get and have access to a fresh
4584+
instance.
4585+
4586+
This also ensures `setup_method` works well with static and class methods.
4587+
4588+
Regression test for #12065.
4589+
"""
4590+
pytester.makepyfile(
4591+
"""
4592+
import pytest
4593+
4594+
class Test:
4595+
ran_setup_method = False
4596+
ran_fixture = False
4597+
4598+
def setup_method(self):
4599+
assert not self.ran_setup_method
4600+
self.ran_setup_method = True
4601+
4602+
@pytest.fixture(autouse=True)
4603+
def fixture(self):
4604+
assert not self.ran_fixture
4605+
self.ran_fixture = True
4606+
4607+
def test_method(self):
4608+
assert self.ran_setup_method
4609+
assert self.ran_fixture
4610+
4611+
@staticmethod
4612+
def test_1(request):
4613+
assert request.instance.ran_setup_method
4614+
assert request.instance.ran_fixture
4615+
4616+
@classmethod
4617+
def test_2(cls, request):
4618+
assert request.instance.ran_setup_method
4619+
assert request.instance.ran_fixture
4620+
"""
4621+
)
4622+
result = pytester.runpytest()
4623+
assert result.ret == ExitCode.OK
4624+
result.assert_outcomes(passed=3)

testing/python/integration.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,22 +410,37 @@ def test_function_instance(pytester: Pytester) -> None:
410410
items = pytester.getitems(
411411
"""
412412
def test_func(): pass
413+
413414
class TestIt:
414415
def test_method(self): pass
416+
415417
@classmethod
416418
def test_class(cls): pass
419+
417420
@staticmethod
418421
def test_static(): pass
419422
"""
420423
)
421424
assert len(items) == 4
425+
422426
assert isinstance(items[0], Function)
423427
assert items[0].name == "test_func"
424428
assert items[0].instance is None
429+
425430
assert isinstance(items[1], Function)
426431
assert items[1].name == "test_method"
427432
assert items[1].instance is not None
428433
assert items[1].instance.__class__.__name__ == "TestIt"
434+
435+
# Even class and static methods get an instance!
436+
# This is the instance used for bound fixture methods, which
437+
# class/staticmethod tests are perfectly able to request.
438+
assert isinstance(items[2], Function)
439+
assert items[2].name == "test_class"
440+
assert items[2].instance is not None
441+
429442
assert isinstance(items[3], Function)
430443
assert items[3].name == "test_static"
431-
assert items[3].instance is None
444+
assert items[3].instance is not None
445+
446+
assert items[1].instance is not items[2].instance is not items[3].instance

0 commit comments

Comments
 (0)