Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-633: fix fixture methods run with different self than the test method #804

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ Version history
===============

This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.
**UNRELEASED**

- Fixed fixture methods run with different self than the test method
(`#633 <https://github.com/agronholm/anyio/issues/633>`_)

**4.6.0**

Expand Down
64 changes: 53 additions & 11 deletions src/anyio/pytest_plugin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import sys
from collections.abc import Iterator
from collections.abc import Generator, Iterator
from contextlib import ExitStack, contextmanager
from inspect import isasyncgenfunction, iscoroutinefunction
from typing import Any, cast
Expand Down Expand Up @@ -70,7 +70,41 @@ def pytest_configure(config: Any) -> None:
)


def pytest_fixture_setup(fixturedef: Any, request: Any) -> None:
def _getimfunc(func: Any) -> Any:
try:
return func.__func__
except AttributeError:
return func


def _resolve_fixture_function(fixturedef: Any, request: Any) -> Any:
"""
Get the actual callable that can be called to obtain the fixture
value.

copied from _pytest.fixtures.resolve_fixture_function
"""
fixturefunc = fixturedef.func
# The fixture function needs to be bound to the actual
# request.instance so that code working with "fixturedef" behaves
# as expected.
instance = request.instance
if instance is not None:
# Handle the case where fixture is defined not in a test class, but some other class
# (for example a plugin class with a fixture), see #2270.
if hasattr(fixturefunc, "__self__") and not isinstance(
instance,
fixturefunc.__self__.__class__,
):
return fixturefunc
fixturefunc = _getimfunc(fixturedef.func)
if fixturefunc != fixturedef.func:
fixturefunc = fixturefunc.__get__(instance)
return fixturefunc


@pytest.hookimpl(wrapper=True)
def pytest_fixture_setup(fixturedef: Any, request: Any) -> Generator[Any, None, None]:
def wrapper(*args, anyio_backend, **kwargs): # type: ignore[no-untyped-def]
backend_name, backend_options = extract_backend_and_options(anyio_backend)
if has_backend_arg:
Expand All @@ -82,15 +116,23 @@ def wrapper(*args, anyio_backend, **kwargs): # type: ignore[no-untyped-def]
else:
yield runner.run_fixture(func, kwargs)

# Only apply this to coroutine functions and async generator functions in requests
# that involve the anyio_backend fixture
func = fixturedef.func
if isasyncgenfunction(func) or iscoroutinefunction(func):
if "anyio_backend" in request.fixturenames:
has_backend_arg = "anyio_backend" in fixturedef.argnames
fixturedef.func = wrapper
if not has_backend_arg:
fixturedef.argnames += ("anyio_backend",)
with ExitStack() as stack:
# Only apply this to coroutine functions and async generator functions in requests
# that involve the anyio_backend fixture
func = _resolve_fixture_function(fixturedef, request)
if isasyncgenfunction(func) or iscoroutinefunction(func):
if "anyio_backend" in request.fixturenames:
has_backend_arg = "anyio_backend" in fixturedef.argnames
original_func = fixturedef.func
fixturedef.func = wrapper
stack.callback(setattr, fixturedef, "func", original_func)
Copy link
Collaborator Author

@graingert graingert Oct 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is needed so each request gets a rebound func to request.instance

this should also fix a case where anyio adopts a fixture and then subsequent tests use it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

basically the fixturedef persists across the whole test session, and you need to bind for each test run - so I monkeypatch fixturedef and then roll back the patches
this has a side effect of allowing other test plugins to adopt the fixture if they use the same strategy
I think my explanation is a little poor though
you may notice this approach is totally different from pytest-asyncio's approach. This is because I did not understand it


if not has_backend_arg:
original_argnames = fixturedef.argnames
fixturedef.argnames = original_argnames + ("anyio_backend",)
stack.callback(setattr, fixturedef, "argnames", original_argnames)

return (yield)


@pytest.hookimpl(tryfirst=True)
Expand Down
41 changes: 41 additions & 0 deletions tests/test_pytest_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,3 +468,44 @@ async def test_anyio_mark_first():
)

testdir.runpytest_subprocess(*pytest_args, timeout=3)


def test_bound_methods(testdir: Pytester) -> None:
testdir.makepyfile(
"""
import pytest

class TestFoo:
@pytest.fixture(autouse=True)
async def fixt(self):
self.is_same_instance = 1

@pytest.mark.anyio
async def test_fixt(self):
assert self.is_same_instance == 1
"""
)

result = testdir.runpytest(*pytest_args)
result.assert_outcomes(passed=2)


def test_bound_async_gen_methods(testdir: Pytester) -> None:
testdir.makepyfile(
"""
import pytest

class TestFoo:
@pytest.fixture(autouse=True)
async def fixt(self):
self.is_same_instance = 1
yield

@pytest.mark.anyio
async def test_fixt(self):
assert self.is_same_instance == 1
"""
)

result = testdir.runpytest(*pytest_args)
result.assert_outcomes(passed=2)
Loading