FastAPI - Test

  • Project setup

    First, create a new project and install the required testing dependencies:

    Terminal window
    uv init test
    cd test
    uv add fastapi[standard]
    uv add --dev pytest httpx

    FastAPI testing is built on HTTPX, which shares a similar design with the popular Requests library. This makes testing familiar and intuitive.

    Since HTTPX is compatible with PyTest, you can use pytest directly to test your FastAPI applications.

    TestClient

    Create your FastAPI application in main.py:

    main.py
    from fastapi import FastAPI
    app = FastAPI()
    @app.get("/")
    async def get():
    return {"message": "Hello World"}

    Then create a test file test_main.py:

    test_main.py
    from fastapi.testclient import TestClient
    from main import app
    client = TestClient(app)
    def test_get():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello World"}

    Writing Tests

    1. Create a TestClient instance by passing your FastAPI application to it
    2. Define test functions with names starting with test_ (standard pytest convention)
    3. Make HTTP requests using the TestClient object the same way as you would with httpx
    4. Add assertions using standard Python assert statements to verify the expected behavior
    Nota

    Notice that test functions use regular def, not async def.

    Client calls are also synchronous (no await needed).

    This allows you to use pytest directly without complications.

    Extended example

    Let’s extend this example with more details to demonstrate testing different scenarios.

    Suppose your main.py file now includes additional path operations:

    • A GET operation that may return errors
    • A POST operation that may return multiple types of errors
    • Both operations require an X-Token header for authentication
    main.py
    from typing import Annotated
    from fastapi import FastAPI, Header, HTTPException
    from pydantic import BaseModel
    secret_token = "V2o3cTZwZ0ZyVnNqVXA5a0xkU1RNVWJ6cGJ3Z1ZqQ3c="
    class Item(BaseModel):
    id: int
    title: str
    description: str | None = None
    db: dict[int, Item] = {
    1: Item(id=1, title="Laptop", description="A high-performance laptop for development"),
    2: Item(id=2, title="Mouse", description="Wireless ergonomic mouse"),
    3: Item(id=3, title="Keyboard"),
    }
    app = FastAPI()
    @app.get("/item/{item_id}", response_model=Item)
    async def item_get(item_id: int, x_token: Annotated[str, Header()]):
    if x_token != secret_token:
    raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item_id not in db:
    raise HTTPException(status_code=404, detail="Item not found")
    return db[item_id]
    @app.post("/item", response_model=Item)
    async def item_post(item: Item, x_token: Annotated[str, Header()]):
    if x_token != secret_token:
    raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item.id in db:
    raise HTTPException(status_code=409, detail="Item already exists")
    db[item.id] = item
    return item

    Here are comprehensive tests covering both success and error cases:

    test_main.py
    from fastapi.testclient import TestClient
    from main import app
    client = TestClient(app)
    def test_item_get():
    response = client.get("/item/1", headers={"X-Token": "V2o3cTZwZ0ZyVnNqVXA5a0xkU1RNVWJ6cGJ3Z1ZqQ3c="})
    assert response.status_code == 200
    assert response.json() == {"id": 1, "title": "Laptop", "description": "A high-performance laptop for development"}
    def test_item_get_bad_token():
    response = client.get("/item/1", headers={"X-Token": "Z1l5R0xqQk1yR1ZrU2RwX1NMS09mV2Ryb1p3b1h2b1E="})
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}
    def test_item_get_nonexistent_item():
    response = client.get("/item/10", headers={"X-Token": "V2o3cTZwZ0ZyVnNqVXA5a0xkU1RNVWJ6cGJ3Z1ZqQ3c="})
    assert response.status_code == 404
    assert response.json() == {"detail": "Item not found"}
    def test_item_post():
    response = client.post(
    "/item",
    headers={"X-Token": "V2o3cTZwZ0ZyVnNqVXA5a0xkU1RNVWJ6cGJ3Z1ZqQ3c="},
    json={"id": 4, "title": "Monitor", "description": "27-inch 4K IPS display"},
    )
    assert response.status_code == 200
    assert response.json() == {"id": 4, "title": "Monitor", "description": "27-inch 4K IPS display"}
    def test_item_post_bad_token():
    response = client.post(
    "/item",
    headers={"X-Token": "Z1l5R0xqQk1yR1ZrU2RwX1NMS09mV2Ryb1p3b1h2b1E="},
    json={"id": 5, "title": "USB-C Hub", "description": "7-in-1 hub with HDMI and Ethernet"}
    )
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}
    def test_item_post_existing_item():
    response = client.post(
    "/item",
    headers={"X-Token": "V2o3cTZwZ0ZyVnNqVXA5a0xkU1RNVWJ6cGJ3Z1ZqQ3c="},
    json={"id": 4, "title": "Monitor", "description": "27-inch 4K IPS display"},
    )
    assert response.status_code == 409
    assert response.json() == {"detail": "Item already exists"}

    Passing data to the TestClient

    When you need to send data in requests, and you’re unsure of the syntax, you can reference the HTTPX documentation or even search for examples using the Requests library, since HTTPX’s API is based on Requests.

    Common patterns:

    Data TypeHow to Pass It
    Path or query parametersAdd them directly to the URL
    JSON bodyPass a Python object (e.g., a dict) to the json parameter
    Form dataUse the data parameter instead of json
    HeadersPass a dict to the headers parameter
    CookiesPass a dict to the cookies parameter
    Nota

    Note that the TestClient receives data that can be converted to JSON, not Pydantic models.

    If you have a Pydantic model in your test and you want to send its data to the application during testing, you can use the jsonable_encoder described in JSON Compatible Encoder.

    Async tests

    Why async tests?

    Asynchronous tests are essential when your application interacts with async components like databases, external APIs, or message queues. For example, if you’re using an async database library, you’ll want to verify that your FastAPI endpoints correctly write data to the database.

    However, this introduces a challenge: how do you test async operations effectively?

    Installing dependencies

    First, add the required testing dependency for async support:

    Terminal window
    uv add --dev trio

    Using pytest.mark.anyio

    To test asynchronous code, your test functions must also be asynchronous. The AnyIO library provides a pytest plugin that enables async test execution.

    Mark your test functions with @pytest.mark.anyio to run them asynchronously.

    Why you can’t use TestClient for async tests

    Even if your FastAPI application uses regular def functions instead of async def, FastAPI is fundamentally asynchronous under the hood.

    The TestClient uses internal mechanisms to call your asynchronous FastAPI application from synchronous test functions. However, this approach breaks down when your test function itself is asynchronous. When running tests with async def, you cannot use TestClient.

    Instead, you’ll use HTTPX’s AsyncClient directly.

    Using AsyncClient

    Since TestClient is built on top of HTTPX, you can use HTTPX’s AsyncClient to test your API directly in async tests.

    Update your test_main.py file:

    test_main.py
    import pytest
    from httpx import ASGITransport, AsyncClient
    @pytest.mark.anyio
    async def test_get_async():
    async with AsyncClient(
    transport=ASGITransport(app=app), base_url="http://test"
    ) as async_client:
    response = await async_client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello World"}

    Key differences from synchronous tests:

    1. Test function declaration: Use async def instead of def
    2. Decorator: Add @pytest.mark.anyio before the test function
    3. Client: Use AsyncClient instead of TestClient
    4. Requests: Use await when making requests
    5. Context manager: Use async with for proper resource management
    Precaució

    The AsyncClient doesn’t automatically trigger lifespan events (startup/shutdown). If your application depends on these events, use LifespanManager from florimondmanca/asgi-lifespan.

    Testing with other async operations

    Now that your test function is asynchronous, you can freely await other async operations within your tests. This is particularly useful for:

    • Querying async databases
    • Making external API calls
    • Verifying background tasks
    • Checking message queues

    For example, you might test an endpoint and then verify the database state:

    @pytest.mark.anyio
    async def test_item_creates_db_entry():
    async with AsyncClient as async_client: response = await async_client.post
    # Now you can await async database operations
    item = await db.get_item(10) # Async database call
    assert item.title == "New Item"
    Nota

    If you encounter a RuntimeError: Task attached to a different loop when integrating asynchronous function calls in your tests (e.g. when using MongoDB’s MotorClient), remember to instantiate objects that need an event loop only within async functions, e.g. an @app.on_event("startup") callback.

    Pending