Starting an app with Uvicorn for testing

Normally, testing of FastAPI apps should be done by passing the app to httpx.AsyncClient and using HTTPX’s built-in support for sending requests directly to an ASGI application. To do this, use the client fixture provided by the fastapi_safir_app template (see Creating an app from the template).

However, in some test scenarios it may be necessary for the app being tested to respond to regular HTTP requests, such as for Selenium testing with a browser. Testing integration with Uvicorn also requires running the app with Uvicorn.

safir.testing.uvicorn.spawn_uvicorn is a helper function to spawn a separate Uvicorn process to run a test FastAPI application. The details about the running process are provided in a returned dataclass, UvicornProcess.

To use this function, write a fixture similar to the following. This code assumes the source code of the test app is in the variable _APP_SOURCE, and that code declares a variable named app to hold the FastAPI object.

from collections.abc import Iterator

from safir.testing.uvicorn import UvicornProcess, spawn_uvicorn


@pytest.fixture
def server(tmp_path: Path) -> Iterator[UvicornProcess]:
    app_path = tmp_path / "test.py"
    app_path.write_text(_APP_SOURCE)
    uvicorn = spawn_uvicorn(working_directory=tmp_path, app="test:app")
    yield uvicorn
    uvicorn.process.terminate()

The .url attribute of the returned object will contain the base URL of the running app. It will be listening on localhost on a random high-numbered port.

Writing out small test files is useful for quick tests. For more complex applications, the app argument can instead be any variable in any module on the Python search path that contains a FastAPI app to run. Additional environment variables for the app can be passed via the env argument to spawn_uvicorn.

Alternately, if you need to dynamically create the application (using other fixtures, for example), you can pass in a factory function:

from collections.abc import Iterator

from safir.testing.uvicorn import UvicornProcess, spawn_uvicorn


@pytest.fixture
def server(tmp_path: Path) -> Iterator[UvicornProcess]:
    app_path = tmp_path / "test.py"
    app_path.write_text(_APP_SOURCE)
    uvicorn = spawn_uvicorn(
        working_directory=tmp_path,
        factory="tests.support.selenium:create_app",
    )
    yield uvicorn
    uvicorn.process.terminate()

By default, the output from Uvicorn is sent to the normal standard output and standard error, where it will be captured by pytest but will not be available to the test. If the test itself needs to inspect the Uvicorn output, pass capture=True to spawn_uvicorn, and then call the communicate method on the .process attribute of the returned object to retrieve the standard output and standard error (generally after calling its terminate method). For example:

import pytest
from httpx import AsyncClient

from safir.testing.uvicorn import UvicornProcess, spawn_uvicorn


@pytest.mark.asyncio
def test_something(tmp_path: Path) -> None:
    app_path = tmp_path / "test.py"
    app_path.write_text(_APP_SOURCE)
    uvicorn = spawn_uvicorn(
        working_directory=tmp_path,
        factory="tests.support.selenium:create_app",
        capture=True,
    )
    try:
        async with AsyncClient() as client:
            # Interact with app at uvicorn.url
            ...
    finally:
        uvicorn.process.terminate()
    stdout, stderr = uvicorn.process.communicate()

    # Do something with stdout and stderr