diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 3675c7cb2..0119b0a60 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -785,6 +785,8 @@ Session related reporting hooks: .. autofunction:: pytest_terminal_summary .. hook:: pytest_fixture_setup .. autofunction:: pytest_fixture_setup +.. hook:: pytest_fixture_teardown +.. autofunction:: pytest_fixture_teardown .. hook:: pytest_fixture_post_finalizer .. autofunction:: pytest_fixture_post_finalizer .. hook:: pytest_warning_recorded diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 383084e07..01ff27ec6 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1015,25 +1015,8 @@ class FixtureDef(Generic[FixtureValue]): self._finalizers.append(finalizer) def finish(self, request: SubRequest) -> None: - exceptions: List[BaseException] = [] - while self._finalizers: - fin = self._finalizers.pop() - try: - fin() - except BaseException as e: - exceptions.append(e) node = request.node - node.ihook.pytest_fixture_post_finalizer(fixturedef=self, request=request) - # Even if finalization fails, we invalidate the cached fixture - # value and remove all finalizers because they may be bound methods - # which will keep instances alive. - self.cached_result = None - self._finalizers.clear() - if len(exceptions) == 1: - raise exceptions[0] - elif len(exceptions) > 1: - msg = f'errors while tearing down fixture "{self.argname}" of {node}' - raise BaseExceptionGroup(msg, exceptions[::-1]) + node.ihook.pytest_fixture_teardown(fixturedef=self, request=request) def execute(self, request: SubRequest) -> FixtureValue: """Return the value of this fixture, executing it if not cached.""" @@ -1145,6 +1128,30 @@ def pytest_fixture_setup( return result +def pytest_fixture_teardown( + fixturedef: FixtureDef[FixtureValue], request: SubRequest +) -> None: + exceptions: List[BaseException] = [] + while fixturedef._finalizers: + fin = fixturedef._finalizers.pop() + try: + fin() + except BaseException as e: + exceptions.append(e) + node = request.node + node.ihook.pytest_fixture_post_finalizer(fixturedef=fixturedef, request=request) + # Even if finalization fails, we invalidate the cached fixture + # value and remove all finalizers because they may be bound methods + # which will keep instances alive. + fixturedef.cached_result = None + fixturedef._finalizers.clear() + if len(exceptions) == 1: + raise exceptions[0] + elif len(exceptions) > 1: + msg = f'errors while tearing down fixture "{fixturedef.argname}" of {node}' + raise BaseExceptionGroup(msg, exceptions[::-1]) + + def wrap_function_to_error_out_if_called_directly( function: FixtureFunction, fixture_marker: "FixtureFunctionMarker", diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index c7f9d036c..c80a0983a 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -889,8 +889,28 @@ def pytest_fixture_setup( """ -def pytest_fixture_post_finalizer( +def pytest_fixture_teardown( fixturedef: "FixtureDef[Any]", request: "SubRequest" +) -> None: + """Perform fixture teardown execution. + + :param fixturdef: + The fixture definition object. + :param request: + The fixture request object. + + Use in conftest plugins + ======================= + + Any conftest file can implement this hook. For a given fixture, only + conftest files in the fixture scope's directory and its parent directories + are consulted. + """ + + +def pytest_fixture_post_finalizer( + fixturedef: "FixtureDef[Any]", + request: "SubRequest", ) -> None: """Called after fixture teardown, but before the cache is cleared, so the fixture result ``fixturedef.cached_result`` is still available (not diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index d3cff38f9..4aa0c9666 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -4054,6 +4054,65 @@ def test_pytest_fixture_setup_and_post_finalizer_hook(pytester: Pytester) -> Non ) +def test_exceptions_in_pytest_fixture_setup_and_pytest_fixture_teardown( + pytester: Pytester, +) -> None: + pytester.makeconftest( + """ + import pytest + @pytest.hookimpl(hookwrapper=True) + def pytest_fixture_setup(fixturedef): + result = yield + print('SETUP EXCEPTION in {0}: {1}'.format(fixturedef.argname, result.exception)) + @pytest.hookimpl(hookwrapper=True) + def pytest_fixture_teardown(fixturedef): + result = yield + print('TEARDOWN EXCEPTION in {0}: {1}'.format(fixturedef.argname, result.exception)) + """ + ) + pytester.makepyfile( + **{ + "tests/test_fixture_exceptions.py": """ + import pytest + + @pytest.fixture(scope='module') + def module_teardown_exeption(): + yield + raise ValueError('exeption in module_teardown_exeption') + + @pytest.fixture() + def func_teardown_exeption(): + yield + raise ValueError('exeption in func_teardown_exeption') + + @pytest.fixture() + def func_setup_exeption(): + raise ValueError('exeption in func_setup_exeption') + + @pytest.mark.usefixtures( + 'module_teardown_exeption', + 'func_teardown_exeption', + 'func_setup_exeption', + ) + def test_func(): + pass + """, + } + ) + result = pytester.runpytest("-s") + assert result.ret == 1 + result.stdout.fnmatch_lines( + [ + "*SETUP EXCEPTION in module_teardown_exeption: None*", + "*SETUP EXCEPTION in func_teardown_exeption: None*", + "*SETUP EXCEPTION in func_setup_exeption: exeption in func_setup_exeption*", + "*TEARDOWN EXCEPTION in func_setup_exeption: None*", + "*TEARDOWN EXCEPTION in func_teardown_exeption: exeption in func_teardown_exeption*", + "*TEARDOWN EXCEPTION in module_teardown_exeption: exeption in module_teardown_exeption*", + ] + ) + + class TestScopeOrdering: """Class of tests that ensure fixtures are ordered based on their scopes (#2405)"""