pytest-dev / pytest-dev/pytest
Internal `AssertionError` on stale `_finalizers` when a `pytest_fixture_setup` hookimpl raises during setup of a parametrized argument
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 14.5k
- Forks
- 3.4k
- Avg merge
- 2d 9h
- Merged PRs (30d)
- 35
Description
Problem
If a pytest_fixture_setup hook implementation raises (including raising Skipped via pytest.skip()), the FixtureDef for the parametrized argument is left with a stale entry in self._finalizers. Every subsequent parameter for that same argument then fails during setup with an internal AssertionError at _pytest/fixtures.py:1221 instead of running.
The first parameter reports correctly. Only the ones after it break, so the damage is order-dependent and the reported failures point at the wrong parameters.
This is a regression in 9.1.0. 9.0.0 and 8.4.x are fine, and it still reproduces on main.
Minimal reproducer
No third-party plugins required.
conftest.py:
import pytest
@pytest.hookimpl(tryfirst=True)
def pytest_fixture_setup(fixturedef, request):
"""Resolve a fixture by name from inside pytest_fixture_setup, based on the
param value. This is what pytest-lazy-fixtures does to implement lf()."""
param = getattr(request, "param", None)
if isinstance(param, str) and param.startswith("fixture:"):
request.param = request.getfixturevalue(param[len("fixture:"):])
test_pure.py:
import pytest
@pytest.fixture
def skipping_base():
pytest.skip("backend unavailable")
@pytest.fixture
def derived(skipping_base):
return "derived"
@pytest.mark.parametrize("val", ["fixture:derived", "plain-1", "plain-2"])
def test_thing(val):
assert isinstance(val, str)
Run pytest.
Expected (and what 8.4.2 / 9.0.0 do): 2 passed, 1 skipped.
Actual on 9.1.0, 9.1.1 and main: 1 skipped, 2 errors, both errors being
# Register the pytest_fixture_post_finalizer as the first finalizer,
# which is executed last.
> assert not self._finalizers
^^^^^^^^^^^^^^^^^^^^
E AssertionError
.../_pytest/fixtures.py:1221: AssertionError
pytest.skip() is not special here — replacing it with raise RuntimeError("boom")
produces the same internal AssertionError on the following parameters (the
first parameter then reports the RuntimeError correctly, as it should).
Version bisect
| pytest | result |
|---|---|
| 8.3.5 | 2 passed, 1 skipped |
| 8.4.2 | 2 passed, 1 skipped |
| 9.0.0 | 2 passed, 1 skipped |
| 9.1.0 | 1 skipped, 2 errors |
| 9.1.1 | 1 skipped, 2 errors |
| main (9.2.0.dev133+g56b196e92) | 1 skipped, 2 errors |
Cause
Two commits that both landed in 9.1.0 encode contradictory assumptions about when _finalizers may be populated.
96728d56 ("fix pytest_fixture_post_finalizer sometimes called multiple times", #5848) added an early return to FixtureDef.finish() (_pytest/fixtures.py:1146):
def finish(self, request: SubRequest) -> None:
if self.cached_result is None:
# Already finished. It is assumed that finalizers cannot be added in
# this state.
return
719f1ec9 ("improve handling of pytest_fixture_post_finalizer raising",
#14114) then made FixtureDef.execute() add a finalizer in exactly that state —
cached_result is still None at this point, because the hook that sets it has
not been called yet (_pytest/fixtures.py:1219):
# Register the pytest_fixture_post_finalizer as the first finalizer,
# which is executed last.
assert not self._finalizers
self.addfinalizer(
lambda: request.node.ihook.pytest_fixture_post_finalizer(
fixturedef=self, request=request
)
)
ihook = request.node.ihook
try:
result: FixtureValue = ihook.pytest_fixture_setup(
fixturedef=self, request=request
)
finally:
# Schedule our finalizer, even if the setup failed.
request.node.addfinalizer(finalizer)
So the stated assumption in finish() — "finalizers cannot be added in this state" — is violated by execute() itself, on every fixture setup.
The failure sequence, confirmed by tracing addfinalizer / finish / execute on the val fixturedef:
execute()for param 1:_finalizersis empty, assert passes, the
pytest_fixture_post_finalizerlambda is appended →len(_finalizers) == 1,
cached_resultstillNone.ihook.pytest_fixture_setup(...)raises.cached_resultis never assigned.
Thefinallycorrectly schedulesfinishon the node.- At teardown, that scheduled
finish(request)runs — and hits the early
return, becausecached_result is None._finalizersis not drained. execute()for param 2:_finalizersstill has 1 entry andcached_result
isNone, so the cache branch is skipped andassert not self._finalizers
trips.
Trace output for the reproducer above, with FixtureDef.addfinalizer,
FixtureDef.finish, FixtureDef.execute and SetupState.addfinalizer
monkeypatched to log calls for argname == "val":
[execute] val: _finalizers=0 cached=False
[addfinalizer] val -> now 1
[SetupState.addfinalizer] node=test_thing[fixture:derived] fin=<bound method finish of <FixtureDef argname='val' ...>>
[finish] val (had 1 finalizers) <-- early-returns, does not drain
[execute] val: _finalizers=1 cached=False <-- assert trips
Relationship to #14775 / #14777
#14777 is the same family of bug — a setup failure before cached_result is set
leaves a stale finalizer and the next execute() trips the same assert — but its
fix does not cover this case. That fix moves resolve_fixture_function() and the
async check inside the try/except TEST_OUTCOME in the default
pytest_fixture_setup implementation, so the failure gets cached. Here the
exception comes from a different hookimpl that runs before the default
implementation (tryfirst), so the default implementation never executes and
nothing caches the failure. Verified: the reproducer above still fails on main,
which already contains that work.
Suggested fixes
Either would resolve it; the first seems closer to the intent of both commits.
- Make
finish()'s early return conditional on there being nothing to do, e.g.
if self.cached_result is None and not self._finalizers: return, so a
fixture whose setup failed still gets drained. This keeps #5848's
"post_finalizer not called twice" property, since the guard still short-
circuits the genuinely-already-finished case. - Register the
pytest_fixture_post_finalizerfinalizer only after
cached_resulthas been set, restoring the invariant thatfinish()
documents.
Either way it would be worth turning the assert not self._finalizers into a
real error path or dropping it, since as written any hook-raised setup failure
converts into an internal assertion with no indication of the real cause.
Real-world impact
Hit via pytest-lazy-fixtures, which implements lf() by resolving fixtures
inside pytest_fixture_setup exactly as the reproducer does. Any
@pytest.mark.parametrize list that mixes lf(...) with other values breaks
under 9.1.x as soon as the lazy fixture's dependency chain skips — which is the
normal case for integration tests that skip when a backing service is absent. In
our suite that turned 16 tests into internal AssertionErrors, attributed to
parameters that had nothing to do with the unavailable service.
Environment
pytest 9.1.1
Python 3.11.10
platform darwin
plugins: none required for the reproducer
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in _pytest/fixtures.py, reading FixtureDef.execute() and finish() around the cited lines. Run the conftest.py and test_pure.py reproducer, then add or locate regression coverage for a hookimpl that raises during parametrized fixture setup. Done means the first parameter reports the original skip or error and subsequent parameters run without an internal AssertionError.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- testing-qa
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100