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(