From 15141ed09103f1378266baeee46d0a4b4afbce18 Mon Sep 17 00:00:00 2001 From: yast Date: Sun, 28 Dec 2025 18:23:44 +0300 Subject: [PATCH 1/5] deprecate: warn on class-scoped fixture as instance method (#10819) (#14011) --- changelog/10819.deprecation.rst | 3 +++ doc/en/deprecations.rst | 47 +++++++++++++++++++++++++++++++++ src/_pytest/deprecated.py | 8 ++++++ src/_pytest/fixtures.py | 11 ++++++++ testing/deprecated_test.py | 21 +++++++++++++++ 5 files changed, 90 insertions(+) create mode 100644 changelog/10819.deprecation.rst 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*"] + ) From df4c9d3d271156424b82c31bfc0b370bd8261318 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 14 May 2026 10:19:50 -0300 Subject: [PATCH 2/5] Update deprecated_test.py --- testing/deprecated_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index fbddbb96b1d..36f9ebea3c4 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -5,6 +5,8 @@ from _pytest.pytester import Pytester import pytest from pytest import PytestDeprecationWarning +import re +from _pytest.compat import legacy_path @pytest.mark.parametrize("plugin", sorted(deprecated.DEPRECATED_EXTERNAL_PLUGINS)) From 285d22228586277d321189e3e90b70588642b246 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 13:21:23 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/deprecated_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 36f9ebea3c4..cfe03c82d5d 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -1,12 +1,13 @@ # mypy: allow-untyped-defs from __future__ import annotations +import re + from _pytest import deprecated +from _pytest.compat import legacy_path from _pytest.pytester import Pytester import pytest from pytest import PytestDeprecationWarning -import re -from _pytest.compat import legacy_path @pytest.mark.parametrize("plugin", sorted(deprecated.DEPRECATED_EXTERNAL_PLUGINS)) From d7a1566d5654c288037964377febf243f6327261 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 14 May 2026 10:30:17 -0300 Subject: [PATCH 4/5] Update deprecated_test.py --- testing/deprecated_test.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index cfe03c82d5d..36130f4c400 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -90,25 +90,6 @@ def __init__(self, foo: int, *, _ispytest: bool = False) -> None: 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( """ From 702dca08467634b25a623ed3c1471217aa4d6281 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 13:30:40 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/deprecated_test.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 36130f4c400..dd5c6781869 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -1,10 +1,7 @@ # mypy: allow-untyped-defs from __future__ import annotations -import re - from _pytest import deprecated -from _pytest.compat import legacy_path from _pytest.pytester import Pytester import pytest from pytest import PytestDeprecationWarning