Saltar al contenido

Testing

Gracias a Starlette, probar aplicaciones FastAPI es fácil y agradable.

Está basado en HTTPX, que a su vez está diseñado basándose en Requests, así que es muy familiar e intuitivo.

Con él, puedes usar pytest directamente con FastAPI.

Usar TestClient

Nota

Para usar TestClient, primero instala httpx.

Asegúrate de crear un entorno virtual, activarlo, y luego instalarlo, por ejemplo:

$ pip install httpx

Importa TestClient.

Crea un TestClient pasándole tu aplicación FastAPI.

Crea funciones con un nombre que comience con test_ (esta es una convención estándar de pytest).

Usa el objeto TestClient de la misma manera que lo haces con httpx.

Escribe sentencias assert simples con las expresiones estándar de Python que necesites verificar (de nuevo, pytest estándar).

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}


client = TestClient(app)


def test_read_main():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}

Consejo

Notarás que las funciones de testing son def normales, no async def.

Y las llamadas al cliente también son llamadas normales, sin usar await.

Esto te permite usar pytest directamente sin complicaciones.

Detalles Técnicos

También podrías usar from starlette.testclient import TestClient.

FastAPI proporciona el mismo starlette.testclient como fastapi.testclient solo como una conveniencia para ti, el desarrollador. Pero viene directamente de Starlette.

Consejo

Si quieres llamar funciones async en tus pruebas además de enviar requests a tu aplicación FastAPI (ej. funciones asíncronas de base de datos), revisa las Pruebas Asíncronas en el tutorial avanzado.

Separar las pruebas

En una aplicación real, probablemente tendrías tus pruebas en un archivo diferente.

Y tu aplicación FastAPI también podría estar compuesta por varios archivos/módulos, etc.

Archivo de la app FastAPI

Digamos que tienes una estructura de archivos como la descrita en Aplicaciones Más Grandes:

.
├── app
│   ├── __init__.py
│   └── main.py

En el archivo main.py tienes tu app FastAPI:

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}

Archivo de pruebas

Luego podrías tener un archivo test_main.py con tus pruebas. Podría vivir en el mismo paquete de Python (el mismo directorio con un archivo __init__.py):

.
├── app
│   ├── __init__.py
│   ├── main.py
│   └── test_main.py

Como este archivo está en el mismo paquete, puedes usar imports relativos para importar el objeto app del módulo main (main.py):

from fastapi.testclient import TestClient

from .main import app

client = TestClient(app)


def test_read_main():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}

...y tener el código para las pruebas igual que antes.

Testing: ejemplo extendido

Ahora extendamos este ejemplo y agreguemos más detalles para ver cómo probar diferentes partes.

Archivo de la app FastAPI extendido

Continuemos con la misma estructura de archivos que antes:

.
├── app
│   ├── __init__.py
│   ├── main.py
│   └── test_main.py

Digamos que ahora el archivo main.py con tu app FastAPI tiene algunas otras path operations.

Tiene una operación GET que podría devolver un error.

Tiene una operación POST que podría devolver varios errores.

Ambas path operations requieren un header X-Token.

from typing import Annotated

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

fake_secret_token = "coneofsilence"

fake_db = {
    "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
    "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}

app = FastAPI()


class Item(BaseModel):
    id: str
    title: str
    description: str | None = None


@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: Annotated[str, Header()]):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item_id not in fake_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return fake_db[item_id]


@app.post("/items/")
async def create_item(item: Item, x_token: Annotated[str, Header()]) -> Item:
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item.id in fake_db:
        raise HTTPException(status_code=409, detail="Item already exists")
    fake_db[item.id] = item.model_dump()
    return item
🤓 Otras versiones y variantes

Consejo

Preferible usar la versión con Annotated si es posible.

from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

fake_secret_token = "coneofsilence"

fake_db = {
    "foo": {"id": "foo", "title": "Foo", "description": "There goes my hero"},
    "bar": {"id": "bar", "title": "Bar", "description": "The bartenders"},
}

app = FastAPI()


class Item(BaseModel):
    id: str
    title: str
    description: str | None = None


@app.get("/items/{item_id}", response_model=Item)
async def read_main(item_id: str, x_token: str = Header()):
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item_id not in fake_db:
        raise HTTPException(status_code=404, detail="Item not found")
    return fake_db[item_id]


@app.post("/items/")
async def create_item(item: Item, x_token: str = Header()) -> Item:
    if x_token != fake_secret_token:
        raise HTTPException(status_code=400, detail="Invalid X-Token header")
    if item.id in fake_db:
        raise HTTPException(status_code=409, detail="Item already exists")
    fake_db[item.id] = item.model_dump()
    return item

Archivo de pruebas extendido

Luego podrías actualizar test_main.py con las pruebas extendidas:

from fastapi.testclient import TestClient

from .main import app

client = TestClient(app)


def test_read_item():
    response = client.get("/items/foo", headers={"X-Token": "coneofsilence"})
    assert response.status_code == 200
    assert response.json() == {
        "id": "foo",
        "title": "Foo",
        "description": "There goes my hero",
    }


def test_read_item_bad_token():
    response = client.get("/items/foo", headers={"X-Token": "hailhydra"})
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}


def test_read_nonexistent_item():
    response = client.get("/items/baz", headers={"X-Token": "coneofsilence"})
    assert response.status_code == 404
    assert response.json() == {"detail": "Item not found"}


def test_create_item():
    response = client.post(
        "/items/",
        headers={"X-Token": "coneofsilence"},
        json={"id": "foobar", "title": "Foo Bar", "description": "The Foo Barters"},
    )
    assert response.status_code == 200
    assert response.json() == {
        "id": "foobar",
        "title": "Foo Bar",
        "description": "The Foo Barters",
    }


def test_create_item_bad_token():
    response = client.post(
        "/items/",
        headers={"X-Token": "hailhydra"},
        json={"id": "bazz", "title": "Bazz", "description": "Drop the bazz"},
    )
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}


def test_create_existing_item():
    response = client.post(
        "/items/",
        headers={"X-Token": "coneofsilence"},
        json={
            "id": "foo",
            "title": "The Foo ID Stealers",
            "description": "There goes my stealer",
        },
    )
    assert response.status_code == 409
    assert response.json() == {"detail": "Item already exists"}
🤓 Otras versiones y variantes

Consejo

Preferible usar la versión con Annotated si es posible.

from fastapi.testclient import TestClient

from .main import app

client = TestClient(app)


def test_read_item():
    response = client.get("/items/foo", headers={"X-Token": "coneofsilence"})
    assert response.status_code == 200
    assert response.json() == {
        "id": "foo",
        "title": "Foo",
        "description": "There goes my hero",
    }


def test_read_item_bad_token():
    response = client.get("/items/foo", headers={"X-Token": "hailhydra"})
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}


def test_read_nonexistent_item():
    response = client.get("/items/baz", headers={"X-Token": "coneofsilence"})
    assert response.status_code == 404
    assert response.json() == {"detail": "Item not found"}


def test_create_item():
    response = client.post(
        "/items/",
        headers={"X-Token": "coneofsilence"},
        json={"id": "foobar", "title": "Foo Bar", "description": "The Foo Barters"},
    )
    assert response.status_code == 200
    assert response.json() == {
        "id": "foobar",
        "title": "Foo Bar",
        "description": "The Foo Barters",
    }


def test_create_item_bad_token():
    response = client.post(
        "/items/",
        headers={"X-Token": "hailhydra"},
        json={"id": "bazz", "title": "Bazz", "description": "Drop the bazz"},
    )
    assert response.status_code == 400
    assert response.json() == {"detail": "Invalid X-Token header"}


def test_create_existing_item():
    response = client.post(
        "/items/",
        headers={"X-Token": "coneofsilence"},
        json={
            "id": "foo",
            "title": "The Foo ID Stealers",
            "description": "There goes my stealer",
        },
    )
    assert response.status_code == 409
    assert response.json() == {"detail": "Item already exists"}

Siempre que necesites que el cliente pase información en la request y no sepas cómo hacerlo, puedes buscar (en Google) cómo hacerlo en httpx, o incluso cómo hacerlo con requests, ya que el diseño de HTTPX está basado en el diseño de Requests.

Luego simplemente haces lo mismo en tus pruebas.

Por ejemplo:

  • Para pasar un parámetro de path o query, añádelo a la URL misma.
  • Para pasar un body de JSON, pasa un objeto de Python (ej. un dict) al parámetro json.
  • Si necesitas enviar Form Data en lugar de JSON, usa el parámetro data en su lugar.
  • Para pasar headers, usa un dict en el parámetro headers.
  • Para cookies, un dict en el parámetro cookies.

Para más información sobre cómo pasar datos al backend (usando httpx o el TestClient) revisa la documentación de HTTPX.

Nota

Notarás que el TestClient recibe datos que pueden ser convertidos a JSON, no modelos de Pydantic.

Si tienes un modelo de Pydantic en tu prueba y quieres enviar sus datos a la aplicación durante el testing, puedes usar el jsonable_encoder descrito en JSON Compatible Encoder.

Ejecútalo

Después de eso, solo necesitas instalar pytest.

Asegúrate de crear un entorno virtual, activarlo, y luego instalarlo, por ejemplo:

fast →pip install pytest

restart ↻

Detectará los archivos y pruebas automáticamente, los ejecutará, y te reportará los resultados.

Ejecuta las pruebas con:

fast →pytest
================ test session starts ================
platform linux -- Python 3.6.9, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/user/code/superawesome-cli/app
plugins: forked-1.1.3, xdist-1.31.0, cov-2.8.1
collected 6 items


test_main.py ...... [100%]

================= 1 passed in 0.03s =================

restart ↻