diff --git a/changelog/10819.deprecation.rst b/changelog/10819.deprecation.rst new file mode 100644 index 00000000000..ebb306379b7 --- /dev/null +++ b/changelog/10819.deprecation.rst @@ -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`. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 73b04f03711..d6fca825792 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -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 diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index f25db4df287..d9da941a789 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -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" +) + # This deprecation is never really meant to be removed. PRIVATE = PytestDeprecationWarning("A private pytest class or function was used.") diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 367eb6419de..da29622ef23 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -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 @@ -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. diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index c49a6e084ce..dd5c6781869 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -85,3 +85,24 @@ def __init__(self, foo: int, *, _ispytest: bool = False) -> None: # Doesn't warn. PrivateInit(10, _ispytest=True) + + +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*"] + )