Async tests and fixtures

Pytest already has the official pytest-asyncio plugin which allows you to write async tests and fixtures.

According to pytest-asyncio docs, you can have an async test with an async fixture like this:

from typing import Any, AsyncGenerator

import pytest
import pytest_asyncio
from httpx import AsyncClient


@pytest_asyncio.fixture
async def client() -> AsyncGenerator[AsyncClient, Any]:
    async with AsyncClient() as c:
        yield c


@pytest.mark.asyncio
async def test_api_call(client: AsyncClient) -> None:
    response = await client.get("http://test.com")
    assert response.status_code == 200

There are a few points to consider here:

  • Obviously the fixture and test are defined as async def since that was the intention.
  • The fixture should be decorated with @pytest_asyncio.fixture so pytest can pick this up properly.
  • The tests should be marked with @pytest.mark.asyncio decorator.

As an alternative you can define a variable pytestmark = pytest.mark.asyncio in your test file to treat all tests as async and avoid the repetition.

Discovery mode

This works well but with some tweaks it can be made simpler. The pytest-asyncio offers a discovery mode concept which allows you to control how async tests are discovered.

If your project only uses asyncio as the asynchronous programming library, you can take advantage of the discovery mode to make this simpler:

from typing import Any, AsyncGenerator

import pytest
from httpx import AsyncClient


@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, Any]:
    async with AsyncClient() as c:
        yield c


async def test_api_call(client: AsyncClient) -> None:
    response = await client.get("http://test.com")
    assert response.status_code == 200

This way:

  • You can define fixtures with normal @pytest.fixture decorator.
  • You don’t need to mark tests as async, so no decorators needed.

You can now run the tests with:

$ pytest tests --asyncio-mode=auto 

If you are using pyproject.toml file you can add this with:

[tool.pytest.ini_options]
asyncio_mode = "auto"

And run the tests with:

$ pytest tests

Asyncio event loop

When testing a FastAPI or SQLAlchemy project, you might get the error <Task pending> attached to a different loop.

The reason is that you should define event loop for pytest to use. You can add the event_loop fixture to conftest.py with:

import asyncio
from typing import Any, Generator

import pytest


@pytest.fixture(scope="session")
def event_loop() -> Generator[asyncio.AbstractEventLoop, Any, None]:
    loop = asyncio.get_event_loop()
    yield loop
    loop.close()