From 395b55c6bfdc8059105334a3db3133fa3fcdf415 Mon Sep 17 00:00:00 2001 From: yast Date: Sun, 28 Dec 2025 18:23:44 +0300 Subject: [PATCH] deprecate: warn on class-scoped fixture as instance method (#10819) (#14011) --- changelog/10819.bugfix.rst | 3 + src/_pytest/deprecated.py | 8 +++ src/_pytest/fixtures.py | 11 ++++ testing/deprecated_test.py | 21 ++++++ testing/test_inherit_class_fixture.py | 92 +++++++++++++++++++++++++++ 5 files changed, 135 insertions(+) create mode 100644 changelog/10819.bugfix.rst create mode 100644 testing/test_inherit_class_fixture.py diff --git a/changelog/10819.bugfix.rst b/changelog/10819.bugfix.rst new file mode 100644 index 00000000000..0d3cc380de9 --- /dev/null +++ b/changelog/10819.bugfix.rst @@ -0,0 +1,3 @@ +Fixed class-scoped fixtures defined in base classes not binding to the correct test instance when inherited by child test classes -- by :user:`yastcher`. + +Fixes :issue:`10819` and :issue:`14011`. 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*"] + ) diff --git a/testing/test_inherit_class_fixture.py b/testing/test_inherit_class_fixture.py new file mode 100644 index 00000000000..74aac83b609 --- /dev/null +++ b/testing/test_inherit_class_fixture.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import typing + +import pytest + + +class ParentBase: + """from issue #14011""" + + name = "" + variable = "" + flag: bool + + @classmethod + def setup(cls) -> None: + cls.variable = cls.name + + @classmethod + def teardown(cls) -> None: + pass + + @pytest.fixture(scope="class") + @classmethod + def fix(cls) -> typing.Generator[None]: + cls.setup() + yield + cls.teardown() + + @pytest.fixture(scope="class", autouse=True) + @classmethod + def base_autouse(cls) -> None: + cls.flag = True + + +@pytest.mark.usefixtures("fix") +class Test1(ParentBase): + name = "test1" + + def test_a(self) -> None: + assert self.variable == self.name + + +@pytest.mark.usefixtures("fix") +class Test2(ParentBase): + name = "test2" + + def test_a(self) -> None: + assert self.variable == self.name + + +class TestChild(ParentBase): + def test_flag(self) -> None: + assert self.flag + + +class BaseTestClass: + """from issue #10819""" + + test_func_scope_set = None + test_class_scope_set = None + + @pytest.fixture(scope="class", autouse=True) + @classmethod + def dummy_class_fixture(cls) -> None: + cls.test_class_scope_set = True + + @pytest.fixture(scope="function", autouse=True) + def dummy_func_fixture(self) -> None: + self.test_func_scope_set = True + + +class TestDummy(BaseTestClass): + def test_dummy(self) -> None: + assert self.test_func_scope_set is True + assert self.test_class_scope_set is True + + +@pytest.mark.usefixtures("fix") +class TestMultipleMethods(ParentBase): + """check class-scoped fixture with multiple test methods""" + + name = "multi" + + def test_a(self) -> None: + assert self.variable == self.name + + def test_b(self) -> None: + assert self.variable == self.name + + def test_c(self) -> None: + assert self.variable == self.name