Skip to content

Commit 4ef23c1

Browse files
jakkdlhrfuller
authored andcommitted
Don't reregister subfixture finalizer in requested fixture if value is cached (pytest-dev#12136)
1 parent db7fbd6 commit 4ef23c1

File tree

3 files changed

+75
-4
lines changed

3 files changed

+75
-4
lines changed

changelog/12135.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix fixtures adding their finalizer multiple times to fixtures they request, causing unreliable and non-intuitive teardown ordering in some instances.

src/_pytest/fixtures.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,14 +1035,25 @@ def finish(self, request: SubRequest) -> None:
10351035
raise BaseExceptionGroup(msg, exceptions[::-1])
10361036

10371037
def execute(self, request: SubRequest) -> FixtureValue:
1038-
finalizer = functools.partial(self.finish, request=request)
1039-
# Get required arguments and register our own finish()
1040-
# with their finalization.
1038+
"""Return the value of this fixture, executing it if not cached."""
1039+
# Ensure that the dependent fixtures requested by this fixture are loaded.
1040+
# This needs to be done before checking if we have a cached value, since
1041+
# if a dependent fixture has their cache invalidated, e.g. due to
1042+
# parametrization, they finalize themselves and fixtures depending on it
1043+
# (which will likely include this fixture) setting `self.cached_result = None`.
1044+
# See #4871
1045+
requested_fixtures_that_should_finalize_us = []
10411046
for argname in self.argnames:
10421047
fixturedef = request._get_active_fixturedef(argname)
1048+
# Saves requested fixtures in a list so we later can add our finalizer
1049+
# to them, ensuring that if a requested fixture gets torn down we get torn
1050+
# down first. This is generally handled by SetupState, but still currently
1051+
# needed when this fixture is not parametrized but depends on a parametrized
1052+
# fixture.
10431053
if not isinstance(fixturedef, PseudoFixtureDef):
1044-
fixturedef.addfinalizer(finalizer)
1054+
requested_fixtures_that_should_finalize_us.append(fixturedef)
10451055

1056+
# Check for (and return) cached value/exception.
10461057
my_cache_key = self.cache_key(request)
10471058
if self.cached_result is not None:
10481059
cache_key = self.cached_result[1]
@@ -1060,6 +1071,13 @@ def execute(self, request: SubRequest) -> FixtureValue:
10601071
self.finish(request)
10611072
assert self.cached_result is None
10621073

1074+
# Add finalizer to requested fixtures we saved previously.
1075+
# We make sure to do this after checking for cached value to avoid
1076+
# adding our finalizer multiple times. (#12135)
1077+
finalizer = functools.partial(self.finish, request=request)
1078+
for parent_fixture in requested_fixtures_that_should_finalize_us:
1079+
parent_fixture.addfinalizer(finalizer)
1080+
10631081
ihook = request.node.ihook
10641082
try:
10651083
# Setup the fixture, run the code in it, and cache the value

testing/python/fixtures.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4751,3 +4751,55 @@ def test_2(fixture_1: None) -> None:
47514751
)
47524752
result = pytester.runpytest()
47534753
assert result.ret == 0
4754+
4755+
4756+
def test_subfixture_teardown_order(pytester: Pytester) -> None:
4757+
"""
4758+
Make sure fixtures don't re-register their finalization in parent fixtures multiple
4759+
times, causing ordering failure in their teardowns.
4760+
4761+
Regression test for #12135
4762+
"""
4763+
pytester.makepyfile(
4764+
"""
4765+
import pytest
4766+
4767+
execution_order = []
4768+
4769+
@pytest.fixture(scope="class")
4770+
def fixture_1():
4771+
...
4772+
4773+
@pytest.fixture(scope="class")
4774+
def fixture_2(fixture_1):
4775+
execution_order.append("setup 2")
4776+
yield
4777+
execution_order.append("teardown 2")
4778+
4779+
@pytest.fixture(scope="class")
4780+
def fixture_3(fixture_1):
4781+
execution_order.append("setup 3")
4782+
yield
4783+
execution_order.append("teardown 3")
4784+
4785+
class TestFoo:
4786+
def test_initialize_fixtures(self, fixture_2, fixture_3):
4787+
...
4788+
4789+
# This would previously reschedule fixture_2's finalizer in the parent fixture,
4790+
# causing it to be torn down before fixture 3.
4791+
def test_reschedule_fixture_2(self, fixture_2):
4792+
...
4793+
4794+
# Force finalization directly on fixture_1
4795+
# Otherwise the cleanup would sequence 3&2 before 1 as normal.
4796+
@pytest.mark.parametrize("fixture_1", [None], indirect=["fixture_1"])
4797+
def test_finalize_fixture_1(self, fixture_1):
4798+
...
4799+
4800+
def test_result():
4801+
assert execution_order == ["setup 2", "setup 3", "teardown 3", "teardown 2"]
4802+
"""
4803+
)
4804+
result = pytester.runpytest()
4805+
assert result.ret == 0

0 commit comments

Comments
 (0)