diff --git a/changelog/12164.feature.rst b/changelog/12164.feature.rst new file mode 100644 index 0000000000..7b042bce6a --- /dev/null +++ b/changelog/12164.feature.rst @@ -0,0 +1 @@ +Added new hook :hook:`pytest_fixture_teardown` to fully track the completion of fixtures by analogy with :hook:`pytest_fixture_setup`. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 3bb03cc038..c4925a0179 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 6b882fa351..f63cb692b8 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1014,25 +1014,8 @@ def addfinalizer(self, finalizer: Callable[[], object]) -> None: 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.""" @@ -1150,6 +1133,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 0a41b0aca4..a2a84ff7a7 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -893,6 +893,23 @@ def pytest_fixture_setup( """ +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: @@ -904,6 +921,8 @@ def pytest_fixture_post_finalizer( The fixture definition object. :param request: The fixture request object. + :param exception: + An exception raised in the finalisation of the fixtures. Use in conftest plugins ======================= diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 46f0a762cb..e52622ea73 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -4098,6 +4098,65 @@ def test_func(my_fixture): ) +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_exception(): + yield + raise ValueError('exception in module_teardown_exception') + + @pytest.fixture() + def func_teardown_exception(): + yield + raise ValueError('exception in func_teardown_exception') + + @pytest.fixture() + def func_setup_exception(): + raise ValueError('exception in func_setup_exception') + + @pytest.mark.usefixtures( + 'module_teardown_exception', + 'func_teardown_exception', + 'func_setup_exception', + ) + def test_func(): + pass + """, + } + ) + result = pytester.runpytest("-s") + assert result.ret == 1 + result.stdout.fnmatch_lines( + [ + "*SETUP EXCEPTION in module_teardown_exception: None*", + "*SETUP EXCEPTION in func_teardown_exception: None*", + "*SETUP EXCEPTION in func_setup_exception: exception in func_setup_exception*", + "*TEARDOWN EXCEPTION in func_setup_exception: None*", + "*TEARDOWN EXCEPTION in func_teardown_exception: exception in func_teardown_exception*", + "*TEARDOWN EXCEPTION in module_teardown_exception: exception in module_teardown_exception*", + ] + ) + + class TestScopeOrdering: """Class of tests that ensure fixtures are ordered based on their scopes (#2405)"""