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 e607b7f26dc..2ac7698220f 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -113,6 +113,53 @@ You can fix it by convert 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 dd46a8b06ba..c0c4e9d0f8c 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -35,6 +35,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 84f90f946be..dce3ac3a1d1 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -54,6 +54,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 YIELD_FIXTURE from _pytest.main import Session from _pytest.mark import ParameterSet @@ -1148,6 +1149,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 e7f1d396f3c..5f9b3d19de5 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -107,3 +107,24 @@ def collect(self): 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*"] + )