diff --git a/changelog/14103.bugfix.rst b/changelog/14103.bugfix.rst new file mode 100644 index 00000000000..13e5c7bdefd --- /dev/null +++ b/changelog/14103.bugfix.rst @@ -0,0 +1 @@ +Fixtures are now rebuilt when param changes for a fixture they depend on, if the dependency is via ``request.getfixturevalue()``. diff --git a/changelog/14114.bugfix.rst b/changelog/14114.bugfix.rst new file mode 100644 index 00000000000..27ed1cfd9c5 --- /dev/null +++ b/changelog/14114.bugfix.rst @@ -0,0 +1 @@ +An exception from ``pytest_fixture_post_finalizer`` no longer prevents fixtures from being torn down, causing additional errors in the following tests. diff --git a/changelog/2043.bugfix.rst b/changelog/2043.bugfix.rst new file mode 100644 index 00000000000..416325b4eed --- /dev/null +++ b/changelog/2043.bugfix.rst @@ -0,0 +1 @@ +If a fixture depends on a fixture that becomes hidden in a test (compared to the previous test), the hidden fixture definition is no longer executed. diff --git a/changelog/4871.improvement.rst b/changelog/4871.improvement.rst new file mode 100644 index 00000000000..29daca2f5ab --- /dev/null +++ b/changelog/4871.improvement.rst @@ -0,0 +1,3 @@ +Garbage finalizers for fixture teardown are no longer accumulated in nodes and fixtures. + +:func:`Node.addfinalizer <_pytest.nodes.Node.addfinalizer>` and ``request.addfinalizer()`` now return a handle that allows to remove the finalizer. diff --git a/changelog/5848.improvement.rst b/changelog/5848.improvement.rst new file mode 100644 index 00000000000..320cfe45377 --- /dev/null +++ b/changelog/5848.improvement.rst @@ -0,0 +1 @@ +``pytest_fixture_post_finalizer`` is no longer called extra times for the same fixture teardown. diff --git a/changelog/9287.bugfix.rst b/changelog/9287.bugfix.rst new file mode 100644 index 00000000000..ae0dff3f5de --- /dev/null +++ b/changelog/9287.bugfix.rst @@ -0,0 +1,47 @@ +Teardown of parametrized fixtures now happens in the teardown stage of the test before the parameter changes. + +Previously teardown would happen in the setup stage of the test where the parameter changes. + +If a test forces teardown of a parametrized fixture, e.g. using ``request.getfixturevalue()``, it instead fails. An example of such test: + +.. code-block:: pytest + + # conftest.py + import pytest + + @pytest.hookimpl(wrapper=True, tryfirst=True) + def pytest_collection_modifyitems(items): + # Disable built-in test reordering. + original_items = items[:] + yield + items[:] = original_items + + # test_invalid.py + import pytest + + @pytest.fixture(scope="session") + def foo(request): + return getattr(request, "param", "default") + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_a(foo): + assert foo == 1 + + def test_b(request): + request.getfixturevalue("foo") + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_c(foo): + assert foo == 1 + +This produces the following error: + +.. code-block:: console + + Parameter for the requested fixture changed unexpectedly in test: + test_invalid.py::test_b + Requested fixture 'foo' defined in: + test_invalid.py:4 + + Previous parameter value: 1 + New parameter value: None diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 62ae3564e18..6126b4787f7 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -993,6 +993,13 @@ ExitCode :members: +FinalizerHandle +~~~~~~~~~~~~~~~ + +.. autoclass:: pytest.FinalizerHandle() + :members: + + FixtureDef ~~~~~~~~~~ diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 84f90f946be..72f34abb26b 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -69,14 +69,11 @@ from _pytest.warning_types import PytestWarning -if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup - - if TYPE_CHECKING: from _pytest.python import CallSpec2 from _pytest.python import Function from _pytest.python import Metafunc + from _pytest.runner import FinalizerHandle # The value of the fixture -- return/yield of the fixture function (type variable). @@ -389,6 +386,9 @@ def __init__( # - In the future we might consider using a generic for the param type, but # for now just using Any. self.param: Any + # FixtureDefs requested through this specific `request` object. + # Allows tracking dependencies on fixtures. + self._own_fixture_defs: Final[dict[str, FixtureDef[object]]] = {} @property def _fixturemanager(self) -> FixtureManager: @@ -483,9 +483,15 @@ def session(self) -> Session: return self._pyfuncitem.session @abc.abstractmethod - def addfinalizer(self, finalizer: Callable[[], object]) -> None: + def addfinalizer(self, finalizer: Callable[[], object]) -> FinalizerHandle: """Add finalizer/teardown function to be called without arguments after - the last test within the requesting test context finished execution.""" + the last test within the requesting test context finished execution. + + :returns: A handle that can be used to remove the finalizer. + + .. versionadded:: 9.1 + The :class:`FinalizerHandle ` result. + """ raise NotImplementedError() def applymarker(self, marker: str | MarkDecorator) -> None: @@ -555,6 +561,7 @@ def _get_active_fixturedef(self, argname: str) -> FixtureDef[object]: fixturedef = self._fixture_defs.get(argname) if fixturedef is not None: self._check_scope(fixturedef, fixturedef._scope) + self._own_fixture_defs[argname] = fixturedef return fixturedef # Find the appropriate fixturedef. @@ -592,10 +599,7 @@ def _get_active_fixturedef(self, argname: str) -> FixtureDef[object]: fixturedef = fixturedefs[index] # Prepare a SubRequest object for calling the fixture. - try: - callspec = self._pyfuncitem.callspec - except AttributeError: - callspec = None + callspec: CallSpec2 | None = getattr(self._pyfuncitem, "callspec", None) if callspec is not None and argname in callspec.params: param = callspec.params[argname] param_index = callspec.indices[argname] @@ -616,6 +620,7 @@ def _get_active_fixturedef(self, argname: str) -> FixtureDef[object]: self, scope, param, param_index, fixturedef, _ispytest=True ) + self._own_fixture_defs[argname] = fixturedef # Make sure the fixture value is cached, running it if it isn't fixturedef.execute(request=subrequest) @@ -687,7 +692,7 @@ def _check_scope( pass @property - def node(self): + def node(self) -> nodes.Node: return self._pyfuncitem def __repr__(self) -> str: @@ -699,8 +704,8 @@ def _fillfixtures(self) -> None: if argname not in item.funcargs: item.funcargs[argname] = self.getfixturevalue(argname) - def addfinalizer(self, finalizer: Callable[[], object]) -> None: - self.node.addfinalizer(finalizer) + def addfinalizer(self, finalizer: Callable[[], object]) -> FinalizerHandle: + return self.node.addfinalizer(finalizer) @final @@ -786,8 +791,8 @@ def _format_fixturedef_line(self, fixturedef: FixtureDef[object]) -> str: sig = signature(factory) return f"{path}:{lineno + 1}: def {factory.__name__}{sig}" - def addfinalizer(self, finalizer: Callable[[], object]) -> None: - self._fixturedef.addfinalizer(finalizer) + def addfinalizer(self, finalizer: Callable[[], object]) -> FinalizerHandle: + return self._fixturedef.addfinalizer(finalizer) @final @@ -950,6 +955,16 @@ def _eval_scope_callable( return result +def _get_cached_value( + cached_result: _FixtureCachedResult[FixtureValue], +) -> FixtureValue: + if cached_result[2] is not None: + exc, exc_tb = cached_result[2] + raise exc.with_traceback(exc_tb) + else: + return cached_result[0] + + class FixtureDef(Generic[FixtureValue]): """A container for a fixture definition. @@ -1014,7 +1029,10 @@ def __init__( # If the fixture was executed, the current value of the fixture. # Can change if the fixture is executed with different parameters. self.cached_result: _FixtureCachedResult[FixtureValue] | None = None - self._finalizers: Final[list[Callable[[], object]]] = [] + # The request object with which the fixture was set up. + self._cached_request: SubRequest | None = None + # Handles to remove our finalizer from various scopes. + self._self_finalizer_handles: Final[list[FinalizerHandle]] = [] # only used to emit a deprecationwarning, can be removed in pytest9 self._autouse = _autouse @@ -1024,77 +1042,37 @@ def scope(self) -> _ScopeName: """Scope string, one of "function", "class", "module", "package", "session".""" return self._scope.value - def addfinalizer(self, finalizer: Callable[[], object]) -> None: - self._finalizers.append(finalizer) + def addfinalizer(self, finalizer: Callable[[], object]) -> FinalizerHandle: + assert self._cached_request is not None + setupstate = self._cached_request.session._setupstate + return setupstate.fixture_addfinalizer(finalizer, self) 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]) + try: + request.session._setupstate.fixture_teardown(self, request.node) + finally: + self.cached_result = None + self._cached_request = None + # Avoid accumulating garbage finalizers in nodes and fixturedefs (#4871). + for handle in self._self_finalizer_handles: + handle.remove_finalizer() + self._self_finalizer_handles.clear() def execute(self, request: SubRequest) -> FixtureValue: """Return the value of this fixture, executing it if not cached.""" - # Ensure that the dependent fixtures requested by this fixture are loaded. - # This needs to be done before checking if we have a cached value, since - # if a dependent fixture has their cache invalidated, e.g. due to - # parametrization, they finalize themselves and fixtures depending on it - # (which will likely include this fixture) setting `self.cached_result = None`. - # See #4871 - requested_fixtures_that_should_finalize_us = [] - for argname in self.argnames: - fixturedef = request._get_active_fixturedef(argname) - # Saves requested fixtures in a list so we later can add our finalizer - # to them, ensuring that if a requested fixture gets torn down we get torn - # down first. This is generally handled by SetupState, but still currently - # needed when this fixture is not parametrized but depends on a parametrized - # fixture. - requested_fixtures_that_should_finalize_us.append(fixturedef) - - # Check for (and return) cached value/exception. if self.cached_result is not None: - request_cache_key = self.cache_key(request) - cache_key = self.cached_result[1] - try: - # Attempt to make a normal == check: this might fail for objects - # which do not implement the standard comparison (like numpy arrays -- #6497). - cache_hit = bool(request_cache_key == cache_key) - except (ValueError, RuntimeError): - # If the comparison raises, use 'is' as fallback. - cache_hit = request_cache_key is cache_key - - if cache_hit: - if self.cached_result[2] is not None: - exc, exc_tb = self.cached_result[2] - raise exc.with_traceback(exc_tb) - else: - return self.cached_result[0] - # We have a previous but differently parametrized fixture instance - # so we need to tear it down before creating a new one. - self.finish(request) - assert self.cached_result is None - - # Add finalizer to requested fixtures we saved previously. - # We make sure to do this after checking for cached value to avoid - # adding our finalizer multiple times. (#12135) - finalizer = functools.partial(self.finish, request=request) - for parent_fixture in requested_fixtures_that_should_finalize_us: - parent_fixture.addfinalizer(finalizer) + self._check_cache_hit(request, self.cached_result[1]) + return _get_cached_value(self.cached_result) + + # Execute fixtures from argnames here to make sure that analytics + # in pytest_fixture_setup only handle the body of the current fixture. + for argname in self.argnames: + request._get_active_fixturedef(argname) + + self._cached_request = request + setupstate = request.session._setupstate + setupstate.fixture_setup(self) + setupstate.fixture_addfinalizer(self._run_post_finalizer, self) ihook = request.node.ihook try: @@ -1105,10 +1083,84 @@ def execute(self, request: SubRequest) -> FixtureValue: ) finally: # Schedule our finalizer, even if the setup failed. - request.node.addfinalizer(finalizer) + fin = functools.partial(self.finish, request) + self._self_finalizer_handles.append(request.node.addfinalizer(fin)) + for fixturedef in request._own_fixture_defs.values(): + self._self_finalizer_handles.append(fixturedef.addfinalizer(fin)) return result + def _run_post_finalizer(self) -> None: + request = self._cached_request + assert request is not None + ihook = request.node.ihook + ihook.pytest_fixture_post_finalizer(fixturedef=self, request=request) + + def _is_cache_hit(self, old_cache_key: object, new_cache_key: object) -> bool: + try: + # Attempt to make a normal == check: this might fail for objects + # which do not implement the standard comparison (like numpy arrays -- #6497). + cache_hit = bool(new_cache_key == old_cache_key) + except (ValueError, RuntimeError): + # If the comparison raises, use 'is' as fallback. + cache_hit = new_cache_key is old_cache_key + return cache_hit + + def _finish_if_param_changed(self, nextitem: nodes.Item) -> None: + assert self._cached_request is not None + assert self.cached_result is not None + old_cache_key = self.cached_result[1] + + callspec: CallSpec2 | None = getattr(nextitem, "callspec", None) + if callspec is not None: + new_cache_key = callspec.params.get(self.argname, None) + else: + new_cache_key = None + + if old_cache_key is None and new_cache_key is None: + # Shortcut for the most common case. + return + + if new_cache_key is None: + fixtureinfo: FuncFixtureInfo | None + fixtureinfo = getattr(nextitem, "_fixtureinfo", None) + if fixtureinfo is None: + return + fixturedefs = fixtureinfo.name2fixturedefs.get(self.argname, ()) + if self not in fixturedefs: + # Carry the fixture cache over a test that does not request + # the (previously) parametrized fixture statically. + # This implementation decision has the consequence that requesting + # the fixture dynamically is disallowed, see _check_cache_hit. + return + + if not self._is_cache_hit(old_cache_key, new_cache_key): + self.finish(self._cached_request) + + def _check_cache_hit(self, request: SubRequest, old_cache_key: object) -> None: + new_cache_key = self.cache_key(request) + if self._is_cache_hit(old_cache_key, new_cache_key): + return + + # Finishing the fixture in setup phase in unacceptable (see PR #14104). + item = request._pyfuncitem + location = getlocation(self.func, item.config.rootpath) + msg = ( + "Parameter for the requested fixture changed unexpectedly in test:\n" + f" {item.nodeid}\n\n" + f"Requested fixture '{self.argname}' defined in:\n" + f"{location}\n\n" + f"Previous parameter value: {old_cache_key!r}\n" + f"New parameter value: {new_cache_key!r}\n\n" + f"This could happen because the current test requested the previously " + "parametrized fixture dynamically via 'getfixturevalue' and did not " + "provide a parameter for the fixture.\n" + "Either provide a parameter for the fixture, or make fixture " + f"'{self.argname}' statically reachable from the current test, " + "e.g. by adding it as an argument to the test function." + ) + fail(msg, pytrace=False) + def cache_key(self, request: SubRequest) -> object: return getattr(request, "param", None) @@ -1134,8 +1186,12 @@ def __init__(self, request: FixtureRequest) -> None: ) self.cached_result = (request, [0], None) - def addfinalizer(self, finalizer: Callable[[], object]) -> None: - pass + def addfinalizer(self, finalizer: Callable[[], object]) -> FinalizerHandle: + # RequestFixtureDef is not exposed to the user, e.g. + # pytest_fixture_setup and pytest_fixture_post_teardown are not called. + # Also RequestFixtureDef is not finalized properly, so if addfinalizer is + # somehow called, then the finalizer will never be called. + assert False def resolve_fixture_function( diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index bc1dfc90d96..57b09ab6ba7 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -47,6 +47,7 @@ # Imported here due to circular import. from _pytest.main import Session + from _pytest.runner import FinalizerHandle SEP = "/" @@ -395,14 +396,19 @@ def listextrakeywords(self) -> set[str]: def listnames(self) -> list[str]: return [x.name for x in self.listchain()] - def addfinalizer(self, fin: Callable[[], object]) -> None: + def addfinalizer(self, fin: Callable[[], object]) -> FinalizerHandle: """Register a function to be called without arguments when this node is finalized. This method can only be called when this node is active in a setup chain, for example during self.setup(). + + :returns: A handle that can be used to remove the finalizer. + + .. versionadded:: 9.1 + The :class:`FinalizerHandle ` result. """ - self.session._setupstate.addfinalizer(fin, self) + return self.session._setupstate.addfinalizer(fin, self) def getparent(self, cls: type[_NodeType]) -> _NodeType | None: """Get the closest parent node (including self) which is an instance of diff --git a/src/_pytest/runner.py b/src/_pytest/runner.py index d1090aace89..83f1239db72 100644 --- a/src/_pytest/runner.py +++ b/src/_pytest/runner.py @@ -14,6 +14,7 @@ from typing import Generic from typing import Literal from typing import TYPE_CHECKING +from typing import TypeAlias from typing import TypeVar from .config import Config @@ -41,6 +42,7 @@ from exceptiongroup import BaseExceptionGroup if TYPE_CHECKING: + from _pytest.fixtures import FixtureDef from _pytest.main import Session from _pytest.terminal import TerminalReporter @@ -431,6 +433,49 @@ def collect() -> list[Item | Collector]: return rep +class _FinalizerId: + __slots__ = () + + +_Finalizer: TypeAlias = Callable[[], object] +_FinalizerStorage: TypeAlias = dict[_FinalizerId, _Finalizer] + + +class FinalizerHandle: + """ + Allows to remove a finalizer after an ``addfinalizer`` call. + + The handle does not own the finalizer, dropping the handle does nothing. + + .. versionadded:: 9.1 + """ + + __slots__ = ("_finalizer_storage", "_id") + + def __init__( + self, + finalizer_storage: _FinalizerStorage, + id: _FinalizerId, + *, + _ispytest: bool = False, + ) -> None: + check_ispytest(_ispytest) + self._finalizer_storage = finalizer_storage + self._id = id + + def remove_finalizer(self) -> None: + """Remove the finalizer.""" + self._finalizer_storage.pop(self._id, None) + + +def _append_finalizer( + finalizer_storage: _FinalizerStorage, finalizer: _Finalizer +) -> FinalizerHandle: + finalizer_id = _FinalizerId() + finalizer_storage[finalizer_id] = finalizer + return FinalizerHandle(finalizer_storage, finalizer_id, _ispytest=True) + + class SetupState: """Shared state for setting up/tearing down test items or collectors in a session. @@ -501,11 +546,12 @@ def __init__(self) -> None: Node, tuple[ # Node's finalizers. - list[Callable[[], object]], + _FinalizerStorage, # Node's exception and original traceback, if its setup raised. tuple[OutcomeException | Exception, types.TracebackType | None] | None, ], ] = {} + self._fixture_finalizers: dict[FixtureDef[object], _FinalizerStorage] = {} def setup(self, item: Item) -> None: """Setup objects along the collector chain to the item.""" @@ -521,22 +567,28 @@ def setup(self, item: Item) -> None: for col in needed_collectors[len(self.stack) :]: assert col not in self.stack # Push onto the stack. - self.stack[col] = ([col.teardown], None) + finalizers = _FinalizerStorage() + _append_finalizer(finalizers, col.teardown) + self.stack[col] = (finalizers, None) try: col.setup() except TEST_OUTCOME as exc: self.stack[col] = (self.stack[col][0], (exc, exc.__traceback__)) raise - def addfinalizer(self, finalizer: Callable[[], object], node: Node) -> None: + def addfinalizer( + self, finalizer: Callable[[], object], node: Node + ) -> FinalizerHandle: """Attach a finalizer to the given node. The node must be currently active in the stack. + + :returns: A handle that can be used to remove the finalizer. """ assert node and not isinstance(node, tuple) assert callable(finalizer) assert node in self.stack, (node, self.stack) - self.stack[node][0].append(finalizer) + return _append_finalizer(self.stack[node][0], finalizer) def teardown_exact(self, nextitem: Item | None) -> None: """Teardown the current stack up until reaching nodes that nextitem @@ -553,12 +605,18 @@ def teardown_exact(self, nextitem: Item | None) -> None: node, (finalizers, _) = self.stack.popitem() these_exceptions = [] while finalizers: - fin = finalizers.pop() + _, fin = finalizers.popitem() try: fin() except TEST_OUTCOME as e: these_exceptions.append(e) + if isinstance(node, Item) and nextitem is not None: + try: + self._finish_stale_fixtures(nextitem) + except TEST_OUTCOME as e: + exceptions.append(e) + if len(these_exceptions) == 1: exceptions.extend(these_exceptions) elif these_exceptions: @@ -572,6 +630,49 @@ def teardown_exact(self, nextitem: Item | None) -> None: if nextitem is None: assert not self.stack + def fixture_setup(self, fixturedef: FixtureDef[object]) -> None: + assert fixturedef not in self._fixture_finalizers + self._fixture_finalizers[fixturedef] = _FinalizerStorage() + + def fixture_addfinalizer( + self, finalizer: Callable[[], object], fixturedef: FixtureDef[object] + ) -> FinalizerHandle: + assert fixturedef in self._fixture_finalizers + return _append_finalizer(self._fixture_finalizers[fixturedef], finalizer) + + def fixture_teardown(self, fixturedef: FixtureDef[object], node: Node) -> None: + assert fixturedef in self._fixture_finalizers + # Do not remove for now to allow adding finalizers last second. + finalizers = self._fixture_finalizers[fixturedef] + + exceptions: list[BaseException] = [] + while finalizers: + _, fin = finalizers.popitem() + try: + fin() + except TEST_OUTCOME as e: + exceptions.append(e) + + self._fixture_finalizers.pop(fixturedef) + 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 _finish_stale_fixtures(self, nextitem: Item) -> None: + exceptions: list[BaseException] = [] + for fixturedef in reversed(list(self._fixture_finalizers.keys())): + try: + fixturedef._finish_if_param_changed(nextitem) + except TEST_OUTCOME as e: + exceptions.append(e) + if len(exceptions) == 1: + raise exceptions[0] + elif len(exceptions) > 1: + msg = f'errors while tearing down fixtures for "{nextitem.nodeid}"' + raise BaseExceptionGroup(msg, exceptions[::-1]) + def collect_one_node(collector: Collector) -> CollectReport: ihook = collector.ihook diff --git a/src/_pytest/setuponly.py b/src/_pytest/setuponly.py index 7e6b46bcdb4..1aa04e14722 100644 --- a/src/_pytest/setuponly.py +++ b/src/_pytest/setuponly.py @@ -47,7 +47,7 @@ def pytest_fixture_setup( else: param = request.param fixturedef.cached_param = param # type: ignore[attr-defined] - _show_fixture_action(fixturedef, request.config, "SETUP") + _show_fixture_action(fixturedef, request, "SETUP") def pytest_fixture_post_finalizer( @@ -56,14 +56,15 @@ def pytest_fixture_post_finalizer( if fixturedef.cached_result is not None: config = request.config if config.option.setupshow: - _show_fixture_action(fixturedef, request.config, "TEARDOWN") + _show_fixture_action(fixturedef, request, "TEARDOWN") if hasattr(fixturedef, "cached_param"): del fixturedef.cached_param def _show_fixture_action( - fixturedef: FixtureDef[object], config: Config, msg: str + fixturedef: FixtureDef[object], request: SubRequest, msg: str ) -> None: + config = request.config capman = config.pluginmanager.getplugin("capturemanager") if capman: capman.suspend_global_capture() @@ -77,14 +78,14 @@ def _show_fixture_action( scopename = fixturedef.scope[0].upper() tw.write(f"{msg:<8} {scopename} {fixturedef.argname}") + if hasattr(fixturedef, "cached_param"): + tw.write(f"[{saferepr(fixturedef.cached_param, maxsize=42)}]") + if msg == "SETUP": - deps = sorted(arg for arg in fixturedef.argnames if arg != "request") + deps = sorted(request._own_fixture_defs.keys()) if deps: tw.write(" (fixtures used: {})".format(", ".join(deps))) - if hasattr(fixturedef, "cached_param"): - tw.write(f"[{saferepr(fixturedef.cached_param, maxsize=42)}]") - tw.flush() if capman: diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 3e6281ac388..63f6196e271 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -69,6 +69,7 @@ from _pytest.reports import CollectReport from _pytest.reports import TestReport from _pytest.runner import CallInfo +from _pytest.runner import FinalizerHandle from _pytest.stash import Stash from _pytest.stash import StashKey from _pytest.subtests import SubtestReport @@ -110,6 +111,7 @@ "ExceptionInfo", "ExitCode", "File", + "FinalizerHandle", "FixtureDef", "FixtureLookupError", "FixtureRequest", diff --git a/testing/python/fixture_dependencies.py b/testing/python/fixture_dependencies.py new file mode 100644 index 00000000000..e3e23fb4da8 --- /dev/null +++ b/testing/python/fixture_dependencies.py @@ -0,0 +1,1074 @@ +""" +Tests for fixture functionality covering setup-teardown ordering of fixtures, including +parametrized fixtures and dynamically requested fixtures. +""" + +from __future__ import annotations + +from _pytest.pytester import Pytester +import pytest + + +def test_getfixturevalue_parametrized_dependency_tracked(pytester: Pytester) -> None: + """ + Test that a fixture depending on a parametrized fixture via getfixturevalue + is properly recomputed when the parametrized fixture changes (#14103). + """ + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(request): + return request.param + + @pytest.fixture(scope="session") + def bar(request): + return request.getfixturevalue("foo") + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_first(foo, bar): + assert bar == 1 + + @pytest.mark.parametrize("foo", [2], indirect=True) + def test_second(foo, bar): + assert bar == 2 + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=2) + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_first[1] ", + "SETUP S foo[1]", + "SETUP S bar (fixtures used: foo)", + " *test_first*", + "TEARDOWN S bar", + "TEARDOWN S foo[1]", + "test_fixtures.py::test_second[2] ", + "SETUP S foo[2]", + "SETUP S bar (fixtures used: foo)", + " *test_second*", + "TEARDOWN S bar", + "TEARDOWN S foo[2]", + ], + consecutive=True, + ) + + +@pytest.mark.xfail(reason="#14095") +def test_fixture_override_finishes_dependencies(pytester: Pytester) -> None: + """Test that a fixture gets recomputed if its dependency resolves to a different fixturedef (#14095).""" + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(): + return "outer" + + @pytest.fixture(scope="session") + def bar(foo): + return f"dependent_{foo}" + + @pytest.fixture(scope="session") + def baz(bar): + return bar + + def test_before_class(baz): + assert baz == "dependent_outer" + + class TestOverride: + @pytest.fixture(scope="session") + def foo(self): + return "inner" + + def test_in_class(self, baz): + assert baz == "dependent_inner" + + def test_after_class(baz): + assert baz == "dependent_outer" + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=3) + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_before_class ", + "SETUP S foo", + "SETUP S bar (fixtures used: foo)", + "SETUP S baz (fixtures used: bar)", + " *test_before_class*", + "test_fixtures.py::TestOverride::test_in_class ", + # baz and bar are recomputed because foo resolves to a different fixturedef. + "TEARDOWN S baz", + "TEARDOWN S bar", + "SETUP S foo", # The inner foo. + "SETUP S bar (fixtures used: foo)", + "SETUP S baz (fixtures used: bar)", + " *test_in_class*", + "test_fixtures.py::test_after_class ", + # baz and bar are recomputed because foo resolves to a different fixturedef. + "TEARDOWN S baz", + "TEARDOWN S bar", + "SETUP S bar (fixtures used: foo)", + "SETUP S baz (fixtures used: bar)", + " *test_after_class*", + "TEARDOWN S baz", + "TEARDOWN S bar", + "TEARDOWN S foo", + "TEARDOWN S foo", + ], + consecutive=True, + ) + + +def test_override_fixture_with_new_parametrized_fixture(pytester: Pytester) -> None: + """Test what happens when a cached fixture is overridden by a new parametrized fixture, + and another fixture depends on it. + + This test verifies that: + 1. A fixture can be overridden by a parametrized fixture in a nested scope + 2. Dependent fixtures get recomputed because a dependency now resolves to a different fixturedef + 3. The outer fixture is not setup or finalized unnecessarily + """ + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(request): + assert not hasattr(request, "param") + return "outer" + + @pytest.fixture(scope="session") + def bar(foo): + return f"dependent_{foo}" + + def test_before_class(foo, bar): + assert foo == "outer" + assert bar == "dependent_outer" + + class TestOverride: + @pytest.fixture(scope="session") + def foo(self, request): + return f"inner_{request.param}" + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_in_class(self, foo, bar): + assert foo == "inner_1" + assert bar == "dependent_inner_1" + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=2) + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_before_class ", + "SETUP S foo", + "SETUP S bar (fixtures used: foo)", + " test_fixtures.py::test_before_class (fixtures used: bar, foo, request)PASSED", + "TEARDOWN S bar", + "TEARDOWN S foo", + "test_fixtures.py::TestOverride::test_in_class[1] ", + "SETUP S foo[1]", + "SETUP S bar (fixtures used: foo)", + " test_fixtures.py::TestOverride::test_in_class[1] (fixtures used: bar, foo, request)PASSED", + "TEARDOWN S bar", + "TEARDOWN S foo[1]", + ], + consecutive=True, + ) + + +def test_fixture_post_finalizer_hook_exception(pytester: Pytester) -> None: + """Test that exceptions in pytest_fixture_post_finalizer hook are caught. + + Also verifies that the fixture cache is properly reset even when the + post_finalizer hook raises an exception, so the fixture can be rebuilt + in subsequent tests. + """ + pytester.makeconftest( + """ + import pytest + + def pytest_fixture_post_finalizer(fixturedef, request): + if "test_first" in request.node.nodeid: + raise RuntimeError("Error in post finalizer hook") + + @pytest.fixture + def my_fixture(request): + yield request.node.nodeid + """ + ) + pytester.makepyfile( + test_fixtures=""" + def test_first(my_fixture): + assert "test_first" in my_fixture + + def test_second(my_fixture): + assert "test_second" in my_fixture + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=2, errors=1) + result.stdout.fnmatch_lines( + [ + "*test_first*PASSED", + "*test_first*ERROR", + "*RuntimeError: Error in post finalizer hook*", + ] + ) + # Verify fixture is setup twice (rebuilt for test_second despite error). + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_first ", + " SETUP F my_fixture", + " test_fixtures.py::test_first (fixtures used: my_fixture, request)PASSED", + "test_fixtures.py::test_first ERROR", + "test_fixtures.py::test_second ", + " SETUP F my_fixture", + " test_fixtures.py::test_second (fixtures used: my_fixture, request)PASSED", + " TEARDOWN F my_fixture", + ], + consecutive=True, + ) + + +def test_parametrized_fixture_carries_over_unaware_item(pytester: Pytester) -> None: + """Test that cached parametrized fixtures carry over non-fixture-aware test items. + + We disable test reordering to ensure tests run in the defined order. + """ + pytester.makeconftest( + """ + import pytest + + class MeaningOfLifeTest(pytest.Item): + def runtest(self): + return self.path.read_text(encoding="utf-8").strip() == "42" + + def pytest_collect_file(parent, file_path): + if "meaning_of_life" in file_path.name: + return MeaningOfLifeTest.from_parent(parent, path=file_path, name=file_path.stem) + + @pytest.hookimpl(wrapper=True, tryfirst=True) + def pytest_collection_modifyitems(items): + original_items = items[:] + yield + items[:] = original_items + + @pytest.fixture(scope="session") + def foo(request): + return getattr(request, "param", None) + """ + ) + pytester.makepyfile( + test_1=""" + import pytest + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_first(foo): + pass + """ + ) + pytester.maketxtfile(test_2_meaning_of_life="42\n") + pytester.makepyfile( + test_3=""" + import pytest + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_third(foo): + pass + """ + ) + result = pytester.runpytest("-v", "--setup-only") + result.stdout.fnmatch_lines( + [ + "test_1.py::test_first[1] ", + "SETUP S foo[1]", + " test_1.py::test_first[1] (fixtures used: foo, request)", + ".::test_2_meaning_of_life ", + " .::test_2_meaning_of_life", + "test_3.py::test_third[1] ", + " test_3.py::test_third[1] (fixtures used: foo, request)", + "TEARDOWN S foo[1]", + ], + consecutive=True, + ) + + +def test_fixture_rebuilt_when_param_appears(pytester: Pytester) -> None: + """Test that fixtures are rebuilt when their parameter appears or disappears. + + We disable test reordering to ensure tests run in the defined order. + """ + pytester.makeconftest( + """ + import pytest + + @pytest.hookimpl(wrapper=True, tryfirst=True) + def pytest_collection_modifyitems(items): + original_items = items[:] + yield + items[:] = original_items + """ + ) + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(request): + return getattr(request, "param", None) + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_a(foo): + assert foo == 1 + + def test_b(foo): + assert foo is None + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_c(foo): + assert foo == 1 + + def test_d(foo): + assert foo is None + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=4) + + +def test_fixture_not_rebuilt_when_not_requested(pytester: Pytester) -> None: + """Test that fixtures are NOT rebuilt when not requested in an intermediate test. + + This is a control test showing that when test_b doesn't access foo at all, + the fixture remains cached and is not torn down/rebuilt. + + Scenario: + 1. test_a: fixture 'foo' is parametrized with value 1 + 2. test_b: does NOT request fixture 'foo' + 3. test_c: fixture 'foo' is parametrized with value 1 (same as test_a) + + We disable test reordering to ensure tests run in the defined order. + """ + pytester.makeconftest( + """ + import pytest + + @pytest.hookimpl(wrapper=True, tryfirst=True) + def pytest_collection_modifyitems(items): + original_items = items[:] + yield + items[:] = original_items + """ + ) + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(request): + return request.param + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_a(foo): + assert foo == 1 + + def test_b(): + pass + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_c(foo): + assert foo == 1 + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=3) + # Verify fixture is setup only once and carries over test_b. + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_a[1] ", + "SETUP S foo[1]", + " test_fixtures.py::test_a[1] (fixtures used: foo, request)PASSED", + "test_fixtures.py::test_b ", + " test_fixtures.py::test_bPASSED", + "test_fixtures.py::test_c[1] ", + " test_fixtures.py::test_c[1] (fixtures used: foo, request)PASSED", + "TEARDOWN S foo[1]", + ], + consecutive=True, + ) + + +def test_cache_mismatch_error_on_sudden_getfixturevalue(pytester: Pytester) -> None: + """Test cache key mismatch when accessing parametrized fixture via getfixturevalue. + + This test demonstrates that accessing a previously parametrized fixture via + getfixturevalue without providing a parameter causes a cache key mismatch error. + + Scenario: + 1. test_a: fixture 'foo' is parametrized with value 1 + 2. test_b: fixture 'foo' is accessed via getfixturevalue without parameter + 3. test_c: fixture 'foo' is parametrized again with value 1 + + We disable test reordering to ensure tests run in the defined order. + """ + pytester.makeconftest( + """ + import pytest + + @pytest.hookimpl(wrapper=True, tryfirst=True) + def pytest_collection_modifyitems(items): + original_items = items[:] + yield + items[:] = original_items + """ + ) + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(request): + return getattr(request, 'param', 'default') + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_a(foo): + assert foo == 1 + + def test_b(request): + value = request.getfixturevalue("foo") + assert value is not None + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_c(foo): + assert foo == 1 + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=2, failed=1) + result.stdout.fnmatch_lines( + [ + "*Parameter for the requested fixture changed unexpectedly*", + "*test_b*", + "*Requested fixture 'foo'*", + "*Previous parameter value: 1", + "*New parameter value: None", + ] + ) + # Verify fixture is NOT torn down during test_b failure. + result.stdout.fnmatch_lines( + [ + "SETUP S foo[1]", + "*test_a*PASSED", + "*test_b*", + "*test_b*FAILED", + "*test_c*", + "*test_c*PASSED", + "TEARDOWN S foo[1]", + ], + consecutive=True, + ) + + +def test_cache_key_mismatch_error_on_unexpected_param_change( + pytester: Pytester, +) -> None: + """Test what happens when param changes unexpectedly, forcing a parametrized + fixture to teardown during setup phase. In this case, the test that changed its + parameter unexpectedly fails. + """ + pytester.makeconftest( + """ + import pytest + + @pytest.hookimpl(wrapper=True, tryfirst=True) + def pytest_collection_modifyitems(items): + # Disable built-in parametrized test reordering. + original_items = items[:] + yield + items[:] = original_items + + @pytest.hookimpl(wrapper=True, tryfirst=True) + def pytest_runtest_protocol(item, nextitem): + # Manipulate callspec for test_b to cause unexpected param change. + if item.name == "test_b[1]": + # Change the parameter value after teardown check but before setup. + item.callspec.params["foo"] = 999 + yield + """ + ) + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(request): + return request.param + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_a(foo): + assert foo == 1 + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_b(foo): + assert foo == 1 + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_c(foo): + assert foo == 1 + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=2, errors=1) + result.stdout.fnmatch_lines( + [ + "*Parameter for the requested fixture changed unexpectedly*", + "*test_b*", + "*Requested fixture 'foo'*", + "*Previous parameter value: 1", + "*New parameter value: 999", + ] + ) + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_a[1] ", + "SETUP S foo[1]", + " *test_a*PASSED", + "test_fixtures.py::test_b[1] ERROR", + "test_fixtures.py::test_c[1] ", + " *test_c*PASSED", + "TEARDOWN S foo[1]", + ], + consecutive=True, + ) + + +def test_teardown_stale_fixtures_single_exception(pytester: Pytester) -> None: + """Test that a single exception during stale fixture teardown is propagated.""" + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def failing_fixture(request): + yield request.param + raise RuntimeError("Teardown error") + + @pytest.mark.parametrize("failing_fixture", [1], indirect=True) + def test_first(failing_fixture): + assert failing_fixture == 1 + + @pytest.mark.parametrize("failing_fixture", [2], indirect=True) + def test_second(failing_fixture): + assert failing_fixture == 2 + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=2, errors=2) + result.stdout.fnmatch_lines( + [ + "*RuntimeError: Teardown error*", + "*RuntimeError: Teardown error*", + ] + ) + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_first[1] ", + "SETUP S failing_fixture[1]", + " *test_first*PASSED", + "TEARDOWN S failing_fixture[1]", + "*test_first*ERROR", + "test_fixtures.py::*test_second* ", + "SETUP S failing_fixture[2]", + " *test_second*PASSED", + "TEARDOWN S failing_fixture[2]", + "*test_second*ERROR", + ], + consecutive=True, + ) + + +def test_teardown_stale_fixtures_multiple_exceptions(pytester: Pytester) -> None: + """Test that multiple exceptions during stale fixture teardown are grouped.""" + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope="session") + def fixture_a(request): + yield request.param + raise RuntimeError("Error in fixture_a teardown") + + @pytest.fixture(scope="session") + def fixture_b(request): + yield request.param + raise ValueError("Error in fixture_b teardown") + + @pytest.mark.parametrize("fixture_a,fixture_b", [(1, 1)], indirect=True) + def test_first(fixture_a, fixture_b): + assert fixture_a == 1 + assert fixture_b == 1 + + @pytest.mark.parametrize("fixture_a,fixture_b", [(2, 2)], indirect=True) + def test_second(fixture_a, fixture_b): + assert fixture_a == 2 + assert fixture_b == 2 + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=2, errors=2) + # Should have errors with BaseExceptionGroup containing both exceptions. + result.stdout.fnmatch_lines( + [ + "*ExceptionGroup: errors while tearing down fixtures*", + "*RuntimeError: Error in fixture_a teardown*", + "*ValueError: Error in fixture_b teardown*", + ] + ) + + +def test_request_stealing_then_getfixturevalue_on_parametrized( + pytester: Pytester, +) -> None: + """Golden test for the behavior of fixture dependency tracking when a fixture + steals another fixture's request. + + This test demonstrates the behavior when: + 1. A session-scoped fixture returns its request object + 2. Another session-scoped fixture uses that request to call getfixturevalue + 3. The requested fixture is parametrized + + The current behavior is to allow this but skip fixture dependency tracking + for when request object is used after its fixture setup completes. + This could be detected and disallowed in the future. + """ + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def param_fixture(request): + return request.param + + @pytest.fixture(scope="session") + def request_provider(request): + return request + + @pytest.fixture(scope="session") + def dependent(request_provider): + return request_provider.getfixturevalue("param_fixture") + + @pytest.mark.parametrize("param_fixture", [1], indirect=True) + def test_first(dependent, param_fixture): + assert param_fixture == 1 + assert dependent == 1 + + @pytest.mark.parametrize("param_fixture", [2], indirect=True) + def test_second(dependent, param_fixture): + assert param_fixture == 2 + # Dependency of dependent on param_fixture is not tracked. + assert dependent == 1 + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=2) + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_first[1] ", + "SETUP S request_provider", + "SETUP S param_fixture[1]", + "SETUP S dependent (fixtures used: request_provider)", + " *test_first*", + # Dependency of dependent on param_fixture is not tracked. + "TEARDOWN S param_fixture[1]", + "test_fixtures.py::test_second[2] ", + "SETUP S param_fixture[2]", + " *test_second*", + "TEARDOWN S param_fixture[2]", + "TEARDOWN S dependent", + "TEARDOWN S request_provider", + ], + consecutive=True, + ) + + +def test_stale_finalizer_not_invoked(pytester: Pytester) -> None: + """Test that stale fixture finalizers are not invoked. + + Scenario: + 1. Fixture 'bar' depends on 'foo' via getfixturevalue in first evaluation; + in a possible implementation, a finalizer is added to 'foo' to first destroy 'bar' + 2. Fixture 'bar' gets recomputed and no longer depends on 'foo' + 3. Fixture 'foo' gets finalized + 4. Without any measures to remove the stale finalizer, 'bar' would be finalized + by the finalizer registered during step 1, even though 'bar' has been recomputed + + The test verifies that 'bar' is NOT finalized when 'foo' is finalized, + because 'bar' was recomputed and the old finalizer should be ignored. + """ + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(request): + return request.param + + @pytest.fixture(scope="session") + def bar(request): + if request.param == 1: + return request.getfixturevalue("foo") + return "independent" + + @pytest.mark.parametrize("foo,bar", [(1, 1)], indirect=True) + def test_first(foo, bar): + assert foo == 1 + assert bar == 1 + + @pytest.mark.parametrize("foo,bar", [(1, 2)], indirect=True) + def test_second(foo, bar): + assert foo == 1 + assert bar == "independent" + + @pytest.mark.parametrize("foo,bar", [(2, 2)], indirect=True) + def test_third(foo, bar): + assert foo == 2 + assert bar == "independent" + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=3) + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_first[1-1] ", + "SETUP S foo[1]", + "SETUP S bar[1] (fixtures used: foo)", + " *test_first*PASSED", + "TEARDOWN S bar[1]", + "test_fixtures.py::test_second[1-2] ", + "SETUP S bar[2]", + " *test_second*PASSED", + "TEARDOWN S foo[1]", + # bar should NOT be torn down here. + "test_fixtures.py::test_third[2-2] ", + "SETUP S foo[2]", + " *test_third*PASSED", + "TEARDOWN S foo[2]", + "TEARDOWN S bar[2]", + ], + consecutive=True, + ) + + +def test_fixture_teardown_when_not_requested_but_param_changes( + pytester: Pytester, +) -> None: + """Test when a parametrized fixture gets torn down when not requested by intermediate test. + + This test demonstrates a surprising but necessary behavior: + When a parametrized fixture is not requested by an intermediate test, and + the next test requests it with a different parameter, the fixture gets torn down + at the end of the intermediate test. + """ + pytester.makeconftest( + """ + import pytest + + @pytest.hookimpl(wrapper=True, tryfirst=True) + def pytest_collection_modifyitems(items): + # Disable built-in parametrized test reordering. + original_items = items[:] + yield + items[:] = original_items + """ + ) + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(request): + return request.param + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_a(foo): + assert foo == 1 + + def test_b(): + pass + + @pytest.mark.parametrize("foo", [2], indirect=True) + def test_c(foo): + assert foo == 2 + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=3) + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_a[1] ", + "SETUP S foo[1]", + " *test_a*PASSED", + "test_fixtures.py::test_b ", + " *test_b*PASSED", + # foo[1] is torn down here, at the end of test_b. + "TEARDOWN S foo[1]", + "test_fixtures.py::test_c[2] ", + "SETUP S foo[2]", + " *test_c*PASSED", + "TEARDOWN S foo[2]", + ], + consecutive=True, + ) + + +def test_fixture_teardown_with_reordered_tests(pytester: Pytester) -> None: + """Test that fixture teardown works correctly when tests are reordered at runtime. + + This test verifies that the fixture teardown mechanism doesn't hard rely on the + ordering of tests from the collection stage. Tests are collected in one order + but executed in a different order via a custom pytest_runtestloop hook. + + Collection order: test_a, test_b, test_c + Execution order: test_a, test_c, test_b + """ + pytester.makeconftest( + """ + import pytest + + @pytest.hookimpl(wrapper=True, tryfirst=True) + def pytest_collection_modifyitems(items): + # Disable built-in parametrized test reordering. + original_items = items[:] + yield + items[:] = original_items + + @pytest.hookimpl(tryfirst=True) + def pytest_runtestloop(session): + # Reorder tests at runtime: test_a, test_c, test_b. + items = list(session.items) + assert len(items) == 3 + # Swap test_b and test_c. + items[1], items[2] = items[2], items[1] + for i, item in enumerate(items): + nextitem = items[i + 1] if i + 1 < len(items) else None + item.config.hook.pytest_runtest_protocol(item=item, nextitem=nextitem) + return True + """ + ) + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(request): + return request.param + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_a(foo): + assert foo == 1 + + @pytest.mark.parametrize("foo", [1], indirect=True) + def test_b(foo): + assert foo == 1 + + @pytest.mark.parametrize("foo", [2], indirect=True) + def test_c(foo): + assert foo == 2 + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=3) + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_a[1] ", + "SETUP S foo[1]", + " *test_a*PASSED", + # foo[1] is torn down here because test_c needs foo[2]. + "TEARDOWN S foo[1]", + "test_fixtures.py::test_c[2] ", + "SETUP S foo[2]", + " *test_c*PASSED", + "TEARDOWN S foo[2]", + "test_fixtures.py::test_b[1] ", + "SETUP S foo[1]", + " *test_b*PASSED", + "TEARDOWN S foo[1]", + ], + consecutive=True, + ) + + +def test_fixture_post_finalizer_called_once(pytester: Pytester) -> None: + """Test that pytest_fixture_post_finalizer is called only once per fixture teardown. + + When a fixture depends on multiple parametrized fixtures and all their parameters + change at the same time, the dependent fixture should be torn down only once, + and pytest_fixture_post_finalizer should be called only once for it. + """ + pytester.makeconftest( + """ + import pytest + + finalizer_calls = [] + + def pytest_fixture_post_finalizer(fixturedef, request): + finalizer_calls.append(fixturedef.argname) + + @pytest.fixture(autouse=True) + def check_finalizer_calls(request): + yield + # After each test, verify no duplicate finalizer calls. + if finalizer_calls: + assert len(finalizer_calls) == len(set(finalizer_calls)), ( + f"Duplicate finalizer calls detected: {finalizer_calls}" + ) + finalizer_calls.clear() + """ + ) + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(request): + return request.param + + @pytest.fixture(scope="session") + def bar(request): + return request.param + + @pytest.fixture(scope="session") + def baz(foo, bar): + return f"{foo}-{bar}" + + @pytest.mark.parametrize("foo,bar", [(1, 1)], indirect=True) + def test_first(foo, bar, baz): + assert foo == 1 + assert bar == 1 + assert baz == "1-1" + + @pytest.mark.parametrize("foo,bar", [(2, 2)], indirect=True) + def test_second(foo, bar, baz): + assert foo == 2 + assert bar == 2 + assert baz == "2-2" + """ + ) + result = pytester.runpytest("-v") + # The test passes, which means no duplicate finalizer calls were detected + # by the check_finalizer_calls autouse fixture. + result.assert_outcomes(passed=2) + + +def test_parametrized_fixtures_teardown_in_reverse_setup_order( + pytester: Pytester, +) -> None: + """Test that when multiple parametrized fixtures change at the same time, they are torn down in reverse setup order. + + When two parametrized fixtures both change their parameters between tests, + they should be torn down in the reverse order of their setup. + """ + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="session") + def foo(request): + return request.param + + @pytest.fixture(scope="session") + def bar(request): + return request.param + + @pytest.mark.parametrize("foo,bar", [(1, 1)], indirect=True) + def test_first(foo, bar): + assert foo == 1 + assert bar == 1 + + @pytest.mark.parametrize("foo,bar", [(2, 2)], indirect=True) + def test_second(foo, bar): + assert foo == 2 + assert bar == 2 + """ + ) + result = pytester.runpytest("-v", "--setup-show") + result.assert_outcomes(passed=2) + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::test_first[1-1] ", + "SETUP S foo[1]", + "SETUP S bar[1]", + " *test_first*PASSED", + # Teardown in reverse setup order: bar first, then foo. + "TEARDOWN S bar[1]", + "TEARDOWN S foo[1]", + "test_fixtures.py::test_second[2-2] ", + "SETUP S foo[2]", + "SETUP S bar[2]", + " *test_second*PASSED", + "TEARDOWN S bar[2]", + "TEARDOWN S foo[2]", + ], + consecutive=True, + ) + + +def test_parametrized_fixture_teardown_order_with_mixed_scopes( + pytester: Pytester, +) -> None: + """Test teardown order when parametrized fixtures are mixed with regular fixtures. + + When a function-scoped, parametrized session-scoped, and class-scoped fixtures + are all torn down at the same time, parametrized fixtures are considered to be + between function-scoped and class-scoped in teardown order. + """ + pytester.makepyfile( + test_fixtures=""" + import pytest + + @pytest.fixture(scope="function") + def func_fixture(): + yield "func" + + @pytest.fixture(scope="session") + def session_param_fixture(request): + yield request.param + + @pytest.fixture(scope="class") + def class_fixture(): + yield "class" + + class TestClass: + @pytest.mark.parametrize("session_param_fixture", [1], indirect=True) + def test_first(self, func_fixture, session_param_fixture, class_fixture): + pass + + @pytest.mark.parametrize("session_param_fixture", [2], indirect=True) + def test_second(session_param_fixture): + pass + """ + ) + result = pytester.runpytest("-v", "--setup-plan") + result.stdout.fnmatch_lines( + [ + "test_fixtures.py::TestClass::test_first[1] ", + "SETUP S session_param_fixture[1]", + " SETUP C class_fixture", + " SETUP F func_fixture", + " *test_first*", + " TEARDOWN F func_fixture", + # Parametrized fixture torn down between function and class fixtures. + "TEARDOWN S session_param_fixture[1]", + " TEARDOWN C class_fixture", + "test_fixtures.py::test_second[2] ", + "SETUP S session_param_fixture[2]", + " *test_second*", + "TEARDOWN S session_param_fixture[2]", + ], + consecutive=True, + )