Skip to content

API to get a test's fixture definitions during parametrization #14991

Open

Description

@tjkuson

What's the problem this feature will solve?

During pytest_generate_tests, I want to be able to get the functions that define a test's fixture dependencies to determine whether and how to parametrize the test (including fixtures requested via other fixtures).

Specifically, pytest-asyncio can run tests with different event loop configurations. It needs to apply this parametrization to all tests that use its async fixtures, including sync tests.

Describe the solution you'd like

A public API on Metafunc to get the fixture definitions upon which a test depends, without running the fixtures.

pytest.FixtureDef is already public. What I am looking for is a way to get the definitions used by a particular test during pytest_generate_tests.

Something like this could work:

def pytest_generate_tests(metafunc):
    funcs = [fd.func for fd in metafunc.fixturedefs]  # proposed new metafunc.fixturedefs API
    # use the above to decide whether to call metafunc.parametrize(...), etc.

Metafunc.fixturedefs could be a read-only property that returns a tuple of pytest.FixtureDef objects. My specific use case needs supported access to each definition's .func, though. The returned fixture definitions for a test should follow fixture resolution rules (i.e., handle the different ways to request a fixture, overrides, and fixture configuration via parametrization).

I don't need a fixed iteration order or for the API to pre-empt future changes to the dependencies (I'm not sure it'd even be possible to deal with future parametrization). It would be fine (and, IMO, intuitive) for each access to reflect the fixture definitions so far. I'd like the plugin to be able to read the dependencies after other hooks apply parametrization, and then apply further parametrization itself.

Alternative Solutions

There doesn't seem to be a solution at the moment other than attempting to re-implement fixture resolution via private APIs.

This mapping gives access to fixture definitions:

fixturedefs = metafunc._arg2fixturedefs["resource"]
functions = [fixturedef.func for fixturedef in fixturedefs]

However, it includes definitions that the test does not use.

# conftest.py
import pytest

@pytest.fixture
def resource():
    return "base"
# test_example.py
import pytest

@pytest.fixture
def resource(resource):
    return resource

def test_example(resource):
    pass

Here, the mapping contains both resource definitions, and the proposed API should return both functions. If the local fixture takes no arguments and returns "replacement", the mapping still contains both definitions. The proposed API should then return only the local fixture function.

The private traverse_fixture_closure function resolves dependency names, but does not return the definitions it visits.

Additional context

This would help close pytest-dev/pytest-asyncio#1501.

Activity

  1. bluetech commented on Sep 9, 2026

    @bluetech
    Member

    Thanks for opening an issue. It is more common for plugins to just use the internals :)

    Your request to have a way to inspect fixtures in metafunc sounds reasonable to me.

    Indeed the "The proposed API should then return only the local fixture function" is the problematic part. We need all of the overrides to be present in arg2fixturedefs just in case the local fixture does something like this:

    @pytest.fixture
    def resource(request):
        return request.getfixturevalue("resource")

    I will try to think if there's a way we can do it.

  2. RonnyPfannschmidt commented on Sep 9, 2026

    @RonnyPfannschmidt
    Member

    Id like to expose the statically discoverable fixture graph in some way

  3. RonnyPfannschmidt commented on Sep 13, 2026

    @RonnyPfannschmidt
    Member

    I asked Claude (Opus 5, via Claude Code) to work through what this API actually has to
    do, against the pytest-asyncio case this issue links. The exploration, the repros and the
    writeup below are its work; I read it and I am posting it.


    Some detail on what a solution here needs, because #15007 showed that the obvious shape
    does not hold up.

    It needs to be a graph of resolved FixtureDef objects, not a flat sequence of them.

    A flat tuple cannot express the thing that makes this hard. With an override chain you get
    back (resource, resource, resource) — the same argname three times, no edges, no parent,
    no way to tell which level is which or which one a given requester resolves to. Resolving
    that is precisely what the closure walk has to compute (it carries a per-name index into
    the override chain, the same state machine as _get_active_fixturedef), and a tuple throws
    all of it away at the API boundary. Consumers would have to re-derive it from private
    state, which is where we came in.

    So: nodes are the resolved FixtureDef objects, edges are "this def requests that def",
    roots are the item's initial names. Same information the walk already has.

    Things such an API has to make an explicit decision about:

    1. Closure mutation during pytest_generate_tests. pytest-asyncio extends a test's
      closure by appending to metafunc.fixturenames and then parametrizing indirectly
      (plugin.py#L776):

      metafunc.fixturenames.append(_asyncio_loop_factory.__name__)
      metafunc.parametrize(_asyncio_loop_factory.__name__, ..., indirect=True, scope=loop_scope)

      Anything seeded from fixtureinfo.initialnames cannot see that name — indirect=True
      never registers a def in name2fixturedefs either. On the MRE from
      Loop factory parametrization tears down async fixtures used by sync tests pytest-asyncio#1501, with a trylast hook so pytest-asyncio has already run:

      test_mre.py::test_async
        '_asyncio_loop_factory' in fixturenames    : True
        '_asyncio_loop_factory' in initialnames    : False
        '_asyncio_loop_factory' in name2fixturedefs: False
      

      (The appended name is then stripped straight back out by prune_dependency_tree —
      at collection item.fixturenames == ['event_loop_policy', 'parent'] — and
      pytest-asyncio only survives because PytestAsyncioFunction.setup re-appends the runner
      id and calls getfixturevalue.) Whether appending to fixturenames is a supported way
      to extend the closure is a question this issue has to answer, not route around.

    2. Overrides that reach their base via getfixturevalue. @bluetech raised this above.
      It is not statically discoverable, and the honest answer may be "over-approximate:
      yield the whole chain". What it must not do is silently under-report:

      # conftest.py
      @pytest.fixture
      def resource(): return "base"
      
      # test_example.py
      @pytest.fixture
      def resource(request): return "wrapped:" + request.getfixturevalue("resource")
      
      def test_example(resource): assert resource == "wrapped:base"   # passes

      The conftest def runs. An argnames-only walk does not report it. For the
      pytest-asyncio use case that direction of error is the bad one — a false negative
      silently fails to parametrize a test that really does use a managed fixture.

    3. Desugared direct parametrization. Direct params are represented as
      DirectParamFixtureDef, whose .func is an internal stub;
      _get_fixtures_per_test already filters it. A public graph must not leak it. Note the
      two spellings currently differ: @pytest.mark.parametrize("x", ...) drops x from
      name2fixturedefs entirely via ignore_args, while metafunc.parametrize("x", ...)
      replaces the entry with a DirectParamFixtureDef.

    4. Where it is read from. @tjkuson asked for it on Metafunc; a graph is equally
      wanted on Item, and the two differ (pre- vs post-pruning, pre- vs
      post-parametrization). Reading it during pytest_generate_tests means the answer
      changes across a parametrize() call — which was asked for deliberately, and should be
      documented as such rather than discovered.

    What already works, for the record. The transitive part is fine. On the
    pytest-asyncio#1501 MRE, a purely synchronous test_sync(child) where child and parent
    are session-scoped async fixtures does statically reach both defs, with fd.func and
    fd.scope available — enough for "does this sync test's closure contain a managed fixture,
    at what widest loop scope". So the underlying walk is sound; the open questions are the
    shape and the four decisions above.

    I would rather land this once, as the graph, than land a tuple and need a second public API
    later.

  4. AdemVessell commented on Sep 14, 2026

    @AdemVessell

    Following the graph discussion, our AI-assisted investigation produced two
    small consumer examples that may help pin down the contract. These compare
    pytest-dev/pytest-asyncio#1553 at d86d02c with a private adapter that reads definitions
    visited by pytest's existing traversal. This is not a proposed fix for pytest-dev/pytest-asyncio#1501;
    pytest-dev/pytest-asyncio#1553 already passes that reproduction.

    • A session-scoped async fixture wrapped by a same-name synchronous fixture is
      missed by the fixturenames plus fixturedefs[-1] lookup. With one loop
      factory, two sync tests, and another function-scoped async fixture, the
      session resource is initialized twice. Removing the wrapper gives one setup.
      The adapter also gives one. The reduced test below reproduces the failure
      with pytest 9.1.1 and Fix loop factory parametrization for sync fixtures pytest-asyncio#1553, without our pytest changes.
    • Hook-time direct parametrization can leave a soon-to-be-pruned async
      dependency in fixturenames. The same lookup then adds loop-factory variants
      to a sync test whose async dependency never runs: four tests instead of two.
      Removing that dependency from the shadowed fixture makes both routes collect
      two. The adapter also avoids the extra variants.

    These show why the shallow lookup loses information, not that a graph is the
    only solution for this consumer. Our prototype is not PR-ready: appended
    registered fixture names remain unresolved, dynamic same-name base requests
    are missed, and direct-param internals still escape through edges.

    Would separating exact declared dependency edges from an explicitly
    conservative set of possible override definitions fit the intended API?
    The latter can include unused bases and cause extra parametrization; it would
    not infer arbitrary computed getfixturevalue requests. The lifecycle of
    plugin-added/removed roots and parameter nodes still needs agreement too.

    Reduced session-fixture example

    Run these two files with pytest 9.1.1 and pytest-dev/pytest-asyncio#1553 at d86d02c:

    # conftest.py
    import asyncio
    import pytest_asyncio
    
    setups = []
    
    def pytest_asyncio_loop_factories(config, item):
        return {"default": asyncio.new_event_loop}
    
    @pytest_asyncio.fixture(scope="session", loop_scope="session")
    async def resource():
        setups.append("resource setup")
        yield "resource"
    
    @pytest_asyncio.fixture(scope="function", loop_scope="function")
    async def other():
        yield "other"
    # test_wrapped.py
    import conftest
    import pytest
    
    @pytest.fixture(scope="session")
    def resource(resource):
        return resource
    
    def test_first(resource, other):
        assert (resource, other) == ("resource", "other")
        assert len(conftest.setups) == 1
    
    def test_second(resource, other):
        assert (resource, other) == ("resource", "other")
        assert len(conftest.setups) == 1
    PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest \
      -p pytest_asyncio.plugin -p no:cacheprovider -q

    Observed on macOS ARM64/Python 3.12.13: test_second fails at assert 2 == 1.
    Delete only the synchronous resource(resource) wrapper and both tests pass.
    This is a local reproduction, not cross-platform validation.

  5. tjkuson commented on Sep 14, 2026

    @tjkuson
    ContributorAuthor

    Thanks for the analysis @RonnyPfannschmidt.

    An item-level graph would be helpful beyond the original request. In pytest-asyncio, we already read from item._fixtureinfo.name2fixturedefs for the strict-mode fixture checks. A public API would replace that private access. For pytest-dev/pytest-asyncio#1514, we also need to know which test or fixture requests each dependency, so the graph would be useful there as well.

    On the API questions you raised, these are pytest-asyncio's requirements for the item-level checks:

    • We need each fixture’s function object and scope to determine how pytest-asyncio handles it.
    • A graph of statically declared dependencies would already be useful; it need not predict runtime getfixturevalue() calls. It should include overridden definitions reached through declared dependencies. Returning whole override chains would leave us having to resolve them ourselves.
    • We do not need the internal fixture definitions used to represent direct parameters.

    Those checks would use collected items, after parametrization and pruning. The parametrization use in the original request would still need access through Metafunc during pytest_generate_tests.

    I linked pytest-dev/pytest-asyncio#1501 because fixture inspection would help pytest-asyncio identify which tests need loop-factory parametrization. That addresses part of the problem, but it does not itself change how pytest-asyncio declares or manages its runner dependencies. Those need separate work, and I don't think a complete solution to the linked issue should be a requirement for this API.

    On appending to metafunc.fixturenames, I would like pytest-asyncio to stop doing it; pytest need not support that for us.

    Looking again at pytest-dev/pytest-asyncio#1501, the underlying problem is an undeclared dependency. Each async fixture needs an internal runner fixture, which in turn depends on the loop-factory fixture we parametrize. pytest cannot see that first dependency during collection.

    We fetch the runner during setup, then compensate with early factory requests and manually registered finalizers. I would prefer to declare the dependency and let pytest handle setup, cache invalidation, and teardown.

    We need a supported way to do that before pytest computes fixture dependencies. It must also work for plain @pytest.fixture async functions in auto mode where pytest-asyncio does not control the decorator. Choosing the runner can depend on the pytest configuration as well.

    With that dependency declared, we could parametrize every test whose dependencies include the loop factory, including synchronous tests.

    Would you recommend opening a separate feature request for declaring fixture dependencies? #378 discusses the related user-facing need to declare a dependency without adding a function parameter.

  6. AdemVessell commented on Sep 15, 2026

    @AdemVessell

    Thanks @tjkuson, that helps separate fixture inspection from the runner-dependency work.

    We've narrowed the AI-assisted local prototype to the declared dependencies you described. It now provides item-level access after parametrization and pruning, including each fixture's function and scope and which test or fixture requests it. It follows declared override paths without returning unused bases. Direct parameter names can still appear, but their internal fixture objects are excluded. The Metafunc query remains available during pytest_generate_tests.

    As a separate consumer check, we used those edges for a small loop-scope diagnostic related to pytest-dev/pytest-asyncio#1514. One useful test case was two fixtures requesting the same dependency: both requester relationships must remain visible. The diagnostic was tested locally, enabled and disabled; it does not infer through synchronous wrappers or dynamic requests.

    The API names and shape are still provisional. We've kept runner-dependency declaration out of this change. I can share the small item-query example and tests here if useful, alongside the generation-time case.

  7. AdemVessell commented on Oct 5, 2026

    @AdemVessell

    Thanks for laying out the design questions. I used an AI-assisted local prototype to test a concrete approach before suggesting another PR.

    Code and runnable checks: prototype patch, case table and 40 core tests.

    The implementation records resolution events from traverse_fixture_closure, producing declared root names, edges (requester, requested_name, target), and the reached FixtureDef objects for .func/.scope inspection. A test-level requester is None. Same-name wrappers retain their base dependency; replacements omit unused bases. Direct parameters are terminal entries with no fixture target or internal stub.

    For Metafunc, the fix reads existing decorator directness before the builtin generation hook and incorporates subsequent parametrize() calls. Collected Item queries use completed fixture mappings, so a marker added too late to be expanded cannot hide a real fixture. Each query takes a fresh structural snapshot; the referenced definitions remain live.

    I propose roots from function arguments, autouse and usefixtures, captured when fixture information is built. Raw fixturenames edits would not rewrite this declared graph, though they can still affect existing setup behavior. Dynamic getfixturevalue() dependencies and effective invocation scopes remain outside this contract. This therefore cannot establish that a managed fixture will never execute.

    The patch passes 56 focused checks and 587 relevant upstream tests on main at 980445e. Seven cases fail on the previous prototype and pass after the roots/directness changes. A generation-hook consumer also exercises the wrapper and parameter-shadow cases discussed earlier. Tests ran locally on macOS/Python 3.12.

    Is this declared-root boundary suitable for the read API, or must direct fixturenames edits be reflected?

  8. RonnyPfannschmidt commented on Oct 6, 2026

    @RonnyPfannschmidt
    Member

    🤖 Written by Claude Opus 5.5 via Claude Code for the pytest maintainers; I prompted it, it did the work, I read it.

    Thanks, this is the shape I was asking for. I applied the gist at 980445e, and both the 40 checks and the 587 upstream tests pass.

    On your question: I don't want the graph to silently disagree with what actually runs. It still does in two places, and both can be fixed inside the same walk.

    1. Dynamic same-name bases. If resource(request) calls request.getfixturevalue("resource"), the shadowed definition runs, but the graph drops it. This lookup isn't arbitrary: _get_active_fixturedef resolves it to exactly one level up the override chain. So when an override takes request and there's a shadowed level, the walk can follow that level as an edge with origin="dynamic", and walk that level's dependencies the same way. Consumers that need certainty can filter on origin. Consumers that must not under-report, like the loop-factory case, can keep those edges. Lookups of other names through getfixturevalue stay out of contract.

    2. Names appended to fixturenames. We don't want to support appending, but those names are still set up at runtime: in a quick check, an appended conftest fixture ran for every test. Rather than ignoring them, the graph should report names that are in fixturenames but not reachable from the declared roots as extra roots with origin="closure". They should be resolved the way runtime resolves them, via getfixturedefs(name, node). On Metafunc, compare against the closure as it was before the generation hooks ran, so that names a direct parametrize() is about to prune don't come back. This makes the mutation visible, which is also what we'd need in order to deprecate it later.

    I prototyped both on top of your patch, about 100 lines. Two of your tests assert the old ignore-appended contract and fail with it, as expected. Everything else passes.

    3. Shape for the public API.

    • Edges are (requester, name, target, kind, origin), with requester=None for the test itself.
    • Direct parameters should be nodes with an identity, a small parameter object carrying the name, rather than target=None. That way dependents() works for them too and the graph has no holes.
    • The queries are roots, dependencies(node), dependents(node), all reached definitions, and the definitions reachable through declared edges only.
    • The spelling is Metafunc.fixture_graph() and Item.fixture_graph(), returning a snapshot pytest.FixtureGraph.
    • Out of contract: arbitrary getfixturevalue names, and the effective scope set by parametrize(scope=...). Please document both.

    With those changes I'd welcome a PR. Please coordinate with #15060, which rewrites the same walk.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions