Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/12065.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
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'``.

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.
Previously it was ``None``, and all fixtures of such tests would share a single ``self``.
14 changes: 8 additions & 6 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,9 @@ def cls(self):
@property
def instance(self):
"""Instance (can be None) on which test function was collected."""
function = getattr(self, "function", None)
return getattr(function, "__self__", None)
if self.scope != "function":
return None
return getattr(self._pyfuncitem, "instance", None)

@property
def module(self):
Expand Down Expand Up @@ -1096,22 +1097,23 @@ def resolve_fixture_function(
fixturedef: FixtureDef[FixtureValue], request: FixtureRequest
) -> "_FixtureFunc[FixtureValue]":
"""Get the actual callable that can be called to obtain the fixture
value, dealing with unittest-specific instances and bound methods."""
value."""
fixturefunc = fixturedef.func
# The fixture function needs to be bound to the actual
# request.instance so that code working with "fixturedef" behaves
# as expected.
if request.instance is not None:
instance = request.instance
if instance is not None:
# Handle the case where fixture is defined not in a test class, but some other class
# (for example a plugin class with a fixture), see #2270.
if hasattr(fixturefunc, "__self__") and not isinstance(
request.instance,
instance,
fixturefunc.__self__.__class__, # type: ignore[union-attr]
):
return fixturefunc
fixturefunc = getimfunc(fixturedef.func)
if fixturefunc != fixturedef.func:
fixturefunc = fixturefunc.__get__(request.instance) # type: ignore[union-attr]
fixturefunc = fixturefunc.__get__(instance) # type: ignore[union-attr]
return fixturefunc


Expand Down
34 changes: 27 additions & 7 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,10 @@ def instance(self):
"""Python instance object the function is bound to.

Returns None if not a test method, e.g. for a standalone test function,
a staticmethod, a class or a module.
a class or a module.
"""
node = self.getparent(Function)
return getattr(node.obj, "__self__", None) if node is not None else None
# Overridden by Function.
return None

@property
def obj(self):
Expand Down Expand Up @@ -1702,7 +1702,8 @@ def __init__(
super().__init__(name, parent, config=config, session=session)

if callobj is not NOTSET:
self.obj = callobj
self._obj = callobj
self._instance = getattr(callobj, "__self__", None)

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

def _getobj(self):
assert self.parent is not None
@property
def instance(self):
try:
return self._instance
except AttributeError:
if isinstance(self.parent, Class):
# Each Function gets a fresh class instance.
self._instance = self._getinstance()
else:
self._instance = None
return self._instance

def _getinstance(self):
Comment thread
nicoddemus marked this conversation as resolved.
if isinstance(self.parent, Class):
# Each Function gets a fresh class instance.
parent_obj = self.parent.newinstance()
return self.parent.newinstance()
else:
return None

def _getobj(self):
instance = self.instance
if instance is not None:
parent_obj = instance
else:
assert self.parent is not None
parent_obj = self.parent.obj # type: ignore[attr-defined]
return getattr(parent_obj, self.originalname)

Expand Down
10 changes: 5 additions & 5 deletions src/_pytest/unittest.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,15 @@ class TestCaseFunction(Function):
nofuncargs = True
_excinfo: Optional[List[_pytest._code.ExceptionInfo[BaseException]]] = None

def _getobj(self):
def _getinstance(self):
assert isinstance(self.parent, UnitTestCase)
testcase = self.parent.obj(self.name)
return getattr(testcase, self.name)
return self.parent.obj(self.name)

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

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

testcase = self.obj.__self__
testcase = self.instance
assert testcase is not None

maybe_wrap_pytest_function_for_tracing(self)

Expand Down
45 changes: 45 additions & 0 deletions testing/python/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -4577,3 +4577,48 @@ def test_deduplicate_names() -> None:
assert items == ("a", "b", "c", "d")
items = deduplicate_names((*items, "g", "f", "g", "e", "b"))
assert items == ("a", "b", "c", "d", "g", "f", "e")


def test_staticmethod_classmethod_fixture_instance(pytester: Pytester) -> None:
"""Ensure that static and class methods get and have access to a fresh
instance.

This also ensures `setup_method` works well with static and class methods.

Regression test for #12065.
"""
pytester.makepyfile(
"""
import pytest

class Test:
ran_setup_method = False
ran_fixture = False

def setup_method(self):
assert not self.ran_setup_method
self.ran_setup_method = True

@pytest.fixture(autouse=True)
def fixture(self):
assert not self.ran_fixture
self.ran_fixture = True

def test_method(self):
assert self.ran_setup_method
assert self.ran_fixture

@staticmethod
def test_1(request):
assert request.instance.ran_setup_method
assert request.instance.ran_fixture

@classmethod
def test_2(cls, request):
assert request.instance.ran_setup_method
assert request.instance.ran_fixture
"""
)
result = pytester.runpytest()
assert result.ret == ExitCode.OK
result.assert_outcomes(passed=3)
17 changes: 16 additions & 1 deletion testing/python/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,22 +410,37 @@ def test_function_instance(pytester: Pytester) -> None:
items = pytester.getitems(
"""
def test_func(): pass

class TestIt:
def test_method(self): pass

@classmethod
def test_class(cls): pass

@staticmethod
def test_static(): pass
"""
)
assert len(items) == 4

assert isinstance(items[0], Function)
assert items[0].name == "test_func"
assert items[0].instance is None

assert isinstance(items[1], Function)
assert items[1].name == "test_method"
assert items[1].instance is not None
assert items[1].instance.__class__.__name__ == "TestIt"

# Even class and static methods get an instance!
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it is possible for this to break some plugin which is using instance to check if the method is a static/classmethod, but I suppose this is far fetched.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You prompted me to check how plugins use instance. Overall it's almost not used, the only prominent case I found is pytest-asyncio, whose usage is not problematic if I'm reading correctly and according to its tests.

pytest-asyncio however was broken by yesterday's removal of FixtureDef.unittest (#12089), for which I will send them a fix.

# This is the instance used for bound fixture methods, which
# class/staticmethod tests are perfectly able to request.
assert isinstance(items[2], Function)
assert items[2].name == "test_class"
assert items[2].instance is not None

assert isinstance(items[3], Function)
assert items[3].name == "test_static"
assert items[3].instance is None
assert items[3].instance is not None

assert items[1].instance is not items[2].instance is not items[3].instance