Skip to content

Commit

Permalink
Don't add fixture finalizer if the value is cached (#11833)
Browse files Browse the repository at this point in the history
Fixes #1489
  • Loading branch information
jakkdl authored Mar 16, 2024
1 parent c203f16 commit 70c1158
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 31 deletions.
1 change: 1 addition & 0 deletions changelog/1489.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix some instances where teardown of higher-scoped fixtures was not happening in the reverse order they were initialized in.
50 changes: 19 additions & 31 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,11 @@ def getfixturevalue(self, argname: str) -> Any:
:raises pytest.FixtureLookupError:
If the given fixture could not be found.
"""
# Note that in addition to the use case described in the docstring,
# getfixturevalue() is also called by pytest itself during item and fixture
# setup to evaluate the fixtures that are requested statically
# (using function parameters, autouse, etc).

fixturedef = self._get_active_fixturedef(argname)
assert fixturedef.cached_result is not None, (
f'The fixture value for "{argname}" is not available. '
Expand Down Expand Up @@ -587,9 +592,8 @@ def _compute_fixture_value(self, fixturedef: "FixtureDef[object]") -> None:
"""Create a SubRequest based on "self" and call the execute method
of the given FixtureDef object.
This will force the FixtureDef object to throw away any previous
results and compute a new fixture value, which will be stored into
the FixtureDef object itself.
If the FixtureDef has cached the result it will do nothing, otherwise it will
setup and run the fixture, cache the value, and schedule a finalizer for it.
"""
# prepare a subrequest object before calling fixture function
# (latter managed by fixturedef)
Expand Down Expand Up @@ -646,18 +650,9 @@ def _compute_fixture_value(self, fixturedef: "FixtureDef[object]") -> None:
subrequest = SubRequest(
self, scope, param, param_index, fixturedef, _ispytest=True
)
try:
# Call the fixture function.
fixturedef.execute(request=subrequest)
finally:
self._schedule_finalizers(fixturedef, subrequest)

def _schedule_finalizers(
self, fixturedef: "FixtureDef[object]", subrequest: "SubRequest"
) -> None:
# If fixture function failed it might have registered finalizers.
finalizer = functools.partial(fixturedef.finish, request=subrequest)
subrequest.node.addfinalizer(finalizer)
# Make sure the fixture value is cached, running it if it isn't
fixturedef.execute(request=subrequest)


@final
Expand Down Expand Up @@ -788,21 +783,6 @@ def _format_fixturedef_line(self, fixturedef: "FixtureDef[object]") -> str:
def addfinalizer(self, finalizer: Callable[[], object]) -> None:
self._fixturedef.addfinalizer(finalizer)

def _schedule_finalizers(
self, fixturedef: "FixtureDef[object]", subrequest: "SubRequest"
) -> None:
# If the executing fixturedef was not explicitly requested in the argument list (via
# getfixturevalue inside the fixture call) then ensure this fixture def will be finished
# first.
if (
fixturedef.argname not in self._fixture_defs
and fixturedef.argname not in self._pyfuncitem.fixturenames
):
fixturedef.addfinalizer(
functools.partial(self._fixturedef.finish, request=self)
)
super()._schedule_finalizers(fixturedef, subrequest)


@final
class FixtureLookupError(LookupError):
Expand Down Expand Up @@ -1055,12 +1035,13 @@ def finish(self, request: SubRequest) -> None:
raise BaseExceptionGroup(msg, exceptions[::-1])

def execute(self, request: SubRequest) -> FixtureValue:
finalizer = functools.partial(self.finish, request=request)
# Get required arguments and register our own finish()
# with their finalization.
for argname in self.argnames:
fixturedef = request._get_active_fixturedef(argname)
if not isinstance(fixturedef, PseudoFixtureDef):
fixturedef.addfinalizer(functools.partial(self.finish, request=request))
fixturedef.addfinalizer(finalizer)

my_cache_key = self.cache_key(request)
if self.cached_result is not None:
Expand All @@ -1080,7 +1061,14 @@ def execute(self, request: SubRequest) -> FixtureValue:
assert self.cached_result is None

ihook = request.node.ihook
result = ihook.pytest_fixture_setup(fixturedef=self, request=request)
try:
# Setup the fixture, run the code in it, and cache the value
# in self.cached_result
result = ihook.pytest_fixture_setup(fixturedef=self, request=request)
finally:
# schedule our finalizer, even if the setup failed
request.node.addfinalizer(finalizer)

return result

def cache_key(self, request: SubRequest) -> object:
Expand Down
55 changes: 55 additions & 0 deletions testing/python/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -4696,3 +4696,58 @@ def test_crash_expected_setup_and_teardown() -> None:
)
result = pytester.runpytest()
assert result.ret == 0


def test_scoped_fixture_teardown_order(pytester: Pytester) -> None:
"""
Make sure teardowns happen in reverse order of setup with scoped fixtures, when
a later test only depends on a subset of scoped fixtures.
Regression test for https://github.com/pytest-dev/pytest/issues/1489
"""
pytester.makepyfile(
"""
from typing import Generator
import pytest
last_executed = ""
@pytest.fixture(scope="module")
def fixture_1() -> Generator[None, None, None]:
global last_executed
assert last_executed == ""
last_executed = "fixture_1_setup"
yield
assert last_executed == "fixture_2_teardown"
last_executed = "fixture_1_teardown"
@pytest.fixture(scope="module")
def fixture_2() -> Generator[None, None, None]:
global last_executed
assert last_executed == "fixture_1_setup"
last_executed = "fixture_2_setup"
yield
assert last_executed == "run_test"
last_executed = "fixture_2_teardown"
def test_fixture_teardown_order(fixture_1: None, fixture_2: None) -> None:
global last_executed
assert last_executed == "fixture_2_setup"
last_executed = "run_test"
def test_2(fixture_1: None) -> None:
# This would previously queue an additional teardown of fixture_1,
# despite fixture_1's value being cached, which caused fixture_1 to be
# torn down before fixture_2 - violating the rule that teardowns should
# happen in reverse order of setup.
pass
"""
)
result = pytester.runpytest()
assert result.ret == 0

0 comments on commit 70c1158

Please sign in to comment.