Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions changelog/10819.deprecation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Added a deprecation warning for class-scoped fixtures defined as instance methods (without ``@classmethod``). Such fixtures set attributes on a different instance than the test methods use, leading to unexpected behavior. Use ``@classmethod`` decorator instead -- by :user:`yastcher`.

See :issue:`10819` and :issue:`14011`.
47 changes: 47 additions & 0 deletions doc/en/deprecations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,53 @@ You can fix it by converting generators and iterators to lists or tuples:
Note that :class:`range` objects are ``Collection`` and are not affected by this deprecation.


.. _class-scoped-fixture-as-instance-method:

Class-scoped fixture as instance method
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. deprecated:: 9.1

Defining a class-scoped fixture as an instance method (without ``@classmethod``) is deprecated
and will be removed in pytest 10.0.

When a class-scoped fixture is defined as an instance method, any attributes set on ``self``
will not be visible to test methods. This happens because pytest creates a new instance of the
test class for each test method, while the fixture runs only once per class on a different instance.

**Before** (deprecated):

.. code-block:: python

class TestExample:
@pytest.fixture(scope="class")
def setup_data(self):
self.data = [1, 2, 3] # This won't be visible to tests!

def test_something(self, setup_data):
assert self.data == [
1,
2,
3,
] # AttributeError: 'TestExample' object has no attribute 'data'

**After** (recommended):

.. code-block:: python

class TestExample:
@pytest.fixture(scope="class")
@classmethod
def setup_data(cls):
cls.data = [1, 2, 3]

def test_something(self, setup_data):
assert self.data == [1, 2, 3] # Works correctly

Using ``@classmethod`` ensures attributes are set on the class itself, making them accessible
to all test methods.


.. _monkeypatch-fixup-namespace-packages:

``monkeypatch.syspath_prepend`` with legacy namespace packages
Expand Down
8 changes: 8 additions & 0 deletions src/_pytest/deprecated.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@
"Use @pytest.fixture instead; they are the same."
)

CLASS_FIXTURE_INSTANCE_METHOD = PytestRemovedIn10Warning(
"Class-scoped fixture defined as instance method is deprecated.\n"
"Instance attributes set in this fixture will NOT be visible to test methods,\n"
"as each test gets a new instance while the fixture runs only once per class.\n"
"Use @classmethod decorator and set attributes on cls instead.\n"
"See https://docs.pytest.org/en/stable/deprecations.html#class-scoped-fixture-as-instance-method"
Comment thread
yastcher marked this conversation as resolved.
)

# This deprecation is never really meant to be removed.
PRIVATE = PytestDeprecationWarning("A private pytest class or function was used.")

Expand Down
11 changes: 11 additions & 0 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
from _pytest.config import ExitCode
from _pytest.config.argparsing import Parser
from _pytest.deprecated import check_ispytest
from _pytest.deprecated import CLASS_FIXTURE_INSTANCE_METHOD
from _pytest.deprecated import FIXTURE_GETFIXTUREVALUE_DURING_TEARDOWN
from _pytest.deprecated import YIELD_FIXTURE
from _pytest.main import Session
Expand Down Expand Up @@ -1220,6 +1221,16 @@ def resolve_fixture_function(
# request.instance so that code working with "fixturedef" behaves
# as expected.
instance = request.instance

if fixturedef._scope is Scope.Class:
# Check if fixture is an instance method (bound to instance, not class)
if hasattr(fixturefunc, "__self__"):
bound_to = fixturefunc.__self__
# classmethod: bound_to is the class itself (a type)
# instance method: bound_to is an instance (not a type)
if not isinstance(bound_to, type):
warnings.warn(CLASS_FIXTURE_INSTANCE_METHOD, stacklevel=2)

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.
Expand Down
40 changes: 40 additions & 0 deletions testing/deprecated_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,43 @@ def __init__(self, foo: int, *, _ispytest: bool = False) -> None:

# Doesn't warn.
PrivateInit(10, _ispytest=True)


def test_node_ctor_fspath_argument_is_deprecated(pytester: Pytester) -> None:
mod = pytester.getmodulecol("")

class MyFile(pytest.File):
def collect(self):
raise NotImplementedError()

with pytest.warns(
pytest.PytestDeprecationWarning,
match=re.escape(
"The (fspath: py.path.local) argument to MyFile is deprecated."
),
):
MyFile.from_parent(
parent=mod.parent,
fspath=legacy_path("bla"),
)


def test_class_scope_instance_method_is_deprecated(pytester: Pytester) -> None:
pytester.makepyfile(
"""
import pytest

class TestClass:
@pytest.fixture(scope="class")
def fix(self):
self.attr = True

def test_foo(self, fix):
pass
"""
)
result = pytester.runpytest("-Werror::pytest.PytestRemovedIn10Warning")
result.assert_outcomes(errors=1)
result.stdout.fnmatch_lines(
["*PytestRemovedIn10Warning: Class-scoped fixture defined as instance method*"]
)
Loading