From e3e64ad5339992ace54d0b858724f76f934ce698 Mon Sep 17 00:00:00 2001 From: Teddy Tennant Date: Sat, 8 Aug 2026 17:33:03 -0400 Subject: [PATCH] Create a fresh test class instance for each rerun pytest caches the test class instance on the test item, so reruns reused the instance of the attempt that had just failed. Any state stored on self -- including attributes set by class-defined fixtures -- leaked from one attempt into the next, breaking the per-test isolation pytest otherwise guarantees. Drop the cached instance (and the method bound to it) as part of the existing pre-rerun cleanup, so pytest rebuilds both lazily. Cached results of fixtures with a scope higher than function are untouched, so those fixtures are still not re-executed on a rerun. --- CHANGES.rst | 6 +++ src/pytest_rerunfailures.py | 19 ++++++++ tests/test_pytest_rerunfailures.py | 69 ++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 3c0e8fc..e0a9691 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,12 @@ Bug fixes teardown was ignored and the test was rerun anyway. Fixes `#270 `_. +- Create a new test class instance for each rerun. Previously the instance of + the failed attempt was reused, so state stored on ``self`` leaked into the + rerun and broke test isolation. Fixtures cached at class scope or higher are + still not re-executed. + Fixes `#268 `_. + Features ++++++++ diff --git a/src/pytest_rerunfailures.py b/src/pytest_rerunfailures.py index 6b6eb8a..69c04c1 100644 --- a/src/pytest_rerunfailures.py +++ b/src/pytest_rerunfailures.py @@ -358,6 +358,24 @@ def _remove_cached_results_from_failed_fixtures(item): fixture_def._finalizers.clear() +def _discard_test_class_instance(item): + """ + Drop the cached test class instance so the rerun gets a fresh one. + + Note: pytest creates one instance per test item and caches it on the item, + so without this the rerun would reuse the instance -- and therefore any + state stored on ``self`` -- of the attempt that just failed. + """ + if getattr(item, "_instance", None) is None: + # no instance is cached: not a test method, or pytest dropped it itself + return + + # ``obj`` is the method bound to the cached instance, so it has to go too; + # both are recomputed lazily on next access. + del item._instance + item._obj = None + + def _remove_failed_setup_state_from_session(item): """ Clean up setup state. @@ -947,6 +965,7 @@ def pytest_runtest_protocol(item, nextitem): # cleanin item's cashed results from any level of setups _remove_cached_results_from_failed_fixtures(item) _remove_failed_setup_state_from_session(item) + _discard_test_class_instance(item) _remove_failed_subtests_from_report(item, report) _remove_failed_subtest_reports_from_stats(item) diff --git a/tests/test_pytest_rerunfailures.py b/tests/test_pytest_rerunfailures.py index e78f818..24b92f3 100644 --- a/tests/test_pytest_rerunfailures.py +++ b/tests/test_pytest_rerunfailures.py @@ -731,6 +731,75 @@ def test_pass_2(self, session_fixture, setup_fixture): assert_outcomes(result, passed=2, rerun=1) +def test_rerun_recreates_test_class_instance(testdir): + """ + Case: state stored on ``self`` by a failed attempt must not leak into the + rerun, i.e. every attempt gets a fresh test class instance + """ + testdir.makepyfile( + """ + import pytest + + attempts = 0 + + class TestFoo(object): + @pytest.fixture(autouse=True) + def counting_fixture(self): + assert not hasattr(self, 'seen_by_fixture') + self.seen_by_fixture = True + + @pytest.mark.flaky(reruns=2) + def test_fresh_instance(self): + global attempts + attempts += 1 + assert not hasattr(self, 'seen_by_test') + self.seen_by_test = True + assert attempts == 3""" + ) + result = testdir.runpytest() + assert_outcomes(result, passed=1, rerun=2) + + +def test_rerun_recreates_instance_without_re_executing_scoped_fixtures(testdir): + """ + Case: recreating the test class instance for a rerun must not invalidate + fixtures cached at a higher scope than function + """ + testdir.makepyfile( + """ + import pytest + + attempts = 0 + executions = {'session': 0, 'module': 0, 'class': 0} + + @pytest.fixture(scope='session') + def session_fixture(): + executions['session'] += 1 + + @pytest.fixture(scope='module') + def module_fixture(): + executions['module'] += 1 + + @pytest.fixture(scope='class') + def class_fixture(): + executions['class'] += 1 + + class TestFoo(object): + @pytest.mark.flaky(reruns=2) + def test_fresh_instance( + self, session_fixture, module_fixture, class_fixture + ): + global attempts + attempts += 1 + assert executions == {'session': 1, 'module': 1, 'class': 1} + assert not hasattr(self, 'seen_by_test') + self.seen_by_test = True + assert attempts == 3""" + ) + result = testdir.runpytest() + assert_outcomes(result, passed=1, rerun=2) + + def test_execution_count_exposed(testdir): testdir.makepyfile("def test_pass(): assert True") testdir.makeconftest(