Matthew Brown's Blog

pytest snippets for python 3.11+

Below is a set of snippets that I used frequently when using pytest in the later versions of python.

asyncio tests without any plugins

The following snippet adds very basic support to pytest for async tests in python 3.11 and above. Add it to your conftest.py and async tests will run in an an asyncio.Runner

import asyncio
import inspect
import logging
from contextlib import contextmanager
from typing import Any, AsyncGenerator, Generator, TypeVar

import pytest

# Set up logging for debugging
logger = logging.getLogger(__name__)

T = TypeVar("T")


@contextmanager
def async_to_sync_generator(
    runner: asyncio.Runner, agen: AsyncGenerator[T, Any]
) -> Generator[T | None, None, None]:
    """
    Converts an async generator into a synchronous generator using the provided asyncio.Runner.
    """

    async def get_next(agen):
        try:
            return await anext(agen), None
        except StopAsyncIteration:
            return None, StopIteration
        except Exception as e:
            return None, e

    try:
        while True:
            value, exc = runner.run(get_next(agen))
            if exc is not None:
                if exc is StopIteration:
                    break
                raise exc
            yield value
    finally:
        # Ensure cleanup for the async generator
        runner.run(agen.aclose())


def pytest_sessionstart(session):
    """Initialize an asyncio.Runner for the pytest session."""
    if not hasattr(asyncio, "Runner"):
        pytest.exit("This plugin requires Python 3.11 or higher", returncode=1)
    session.asyncio_runner = asyncio.Runner()
    logger.debug("Asyncio Runner initialized for the session.")


def pytest_sessionfinish(session):
    """Close the asyncio.Runner after the session ends."""
    if hasattr(session, "asyncio_runner"):
        try:
            session.asyncio_runner.close()
            logger.debug("Asyncio Runner closed after the session.")
        except Exception as e:
            logger.error(f"Error closing asyncio Runner: {e}")


async def anext(agen: AsyncGenerator):
    """Fetch the next item from an async generator."""
    return await agen.__anext__()


@pytest.hookimpl(hookwrapper=True)
def pytest_fixture_setup(fixturedef, request):
    """
    Convert async fixtures into their synchronous counterparts, handling both async generators and coroutines.
    """
    if inspect.isasyncgenfunction(fixturedef.func):
        generator = fixturedef.func

        def gen_wrapper(*args, **kwargs):
            runner: asyncio.Runner = request.session.asyncio_runner
            gen_obj = generator(*args, **kwargs)
            with async_to_sync_generator(runner, gen_obj) as v:
                yield v

        fixturedef.func = gen_wrapper
        logger.debug(f"Converted async generator fixture {fixturedef.func} to sync.")

    elif inspect.iscoroutinefunction(fixturedef.func):
        coro = fixturedef.func
        runner: asyncio.Runner = request.session.asyncio_runner

        def coro_wrapper(*args, **kwargs):
            return runner.run(coro(*args, **kwargs))

        fixturedef.func = coro_wrapper
        logger.debug(f"Converted coroutine fixture {fixturedef.func} to sync.")

    yield


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
    """
    Convert async test functions to sync functions by running them in the asyncio.Runner.
    """
    if inspect.iscoroutinefunction(item.obj):
        runner = item.session.asyncio_runner
        original = item.obj

        def item_wrapper(*args, **kwargs):
            return runner.run(original(*args, **kwargs))

        item.obj = item_wrapper
        logger.debug(f"Converted async test {item.obj} to sync.")

    yield

integration tests

Mark tests as integration tests that don’t run by default. You have to run pytest with the --integration flag to run them.

def pytest_addoption(parser):
    parser.addoption(
        "--integration",
        action="store_true",
        default=False,
        help="Run tests marked with @pytest.mark.integration",
    )


def pytest_configure(config):
    config.addinivalue_line(
        "markers", "integration: mark test to run only with --integration"
    )


def pytest_collection_modifyitems(config, items):
    if config.getoption("--integration"):
        # Only run tests marked with 'integration'
        integration_items = [item for item in items if "integration" in item.keywords]
        items[:] = integration_items
    else:
        # Skip tests marked with 'integration'
        skip_integration = pytest.mark.skip(reason="Need --integration option to run")
        for item in items:
            if "integration" in item.keywords:
                item.add_marker(skip_integration)

sqlalchemy async support with factory-boy

I find passing in an explicit session easier than the built in factory-boy session local behaviour. So this is a little snippet adds factory generated instances to the current session.


# factory
T = TypeVar("T", bound=BaseFactory)
Factory = Callable[Concatenate[Type[T], ...], Awaitable[T]]


@pytest.fixture(scope="function", name="factory")
async def factory_fixture(session: AsyncSession):
    """
    Creates a factory fixture for creating models.
    Explicitly uses the current active session
    """

    async def fn(factory: Type[T], **overrides):
        instance = factory.build(**overrides)
        session.add(instance)
        await session.flush()
        return instance

    return fn

### usage

async def test_user(factory: Factory):
    instance = await factory(UserFactory)