Testing¶
Gracias a Starlette, escribir pruebas para aplicaciones de FastAPI es fácil y agradable.
Está basado en HTTPX, que a su vez está diseñado basado en Requests, por lo que es muy familiar e intuitivo.
Con él, puedes usar pytest directamente con FastAPI.
Usando TestClient
¶
Información
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 de FastAPI.
Crea funciones con un nombre que comience con test_
(esta es la convención estándar de pytest
).
Usa el objeto TestClient
de la misma manera que con httpx
.
Escribe declaraciones assert
simples con las expresiones estándar de Python que necesites revisar (otra vez, estándar de pytest
).
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
Nota que las funciones de prueba 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.
Nota Técnica
También podrías usar from starlette.testclient import TestClient
.
FastAPI proporciona el mismo starlette.testclient
como fastapi.testclient
solo por conveniencia para ti, el desarrollador. Pero proviene directamente de Starlette.
Consejo
Si quieres llamar a funciones async
en tus pruebas además de enviar solicitudes a tu aplicación FastAPI (por ejemplo, funciones asincrónicas de bases de datos), echa un vistazo a las Pruebas Asincrónicas en el tutorial avanzado.
Separando pruebas¶
En una aplicación real, probablemente tendrías tus pruebas en un archivo diferente.
Y tu aplicación de FastAPI también podría estar compuesta de varios archivos/módulos, etc.
Archivo de aplicación FastAPI¶
Digamos que tienes una estructura de archivos como se describe en Aplicaciones Más Grandes:
.
├── app
│ ├── __init__.py
│ └── main.py
En el archivo main.py
tienes tu aplicación de FastAPI:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def read_main():
return {"msg": "Hello World"}
Archivo de prueba¶
Entonces podrías tener un archivo test_main.py
con tus pruebas. Podría estar en el mismo paquete de Python (el mismo directorio con un archivo __init__.py
):
.
├── app
│ ├── __init__.py
│ ├── main.py
│ └── test_main.py
Debido a que este archivo está en el mismo paquete, puedes usar importaciones relativas para importar el objeto app
desde el 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 tal como antes.
Pruebas: ejemplo extendido¶
Ahora extiende este ejemplo y añade más detalles para ver cómo escribir pruebas para diferentes partes.
Archivo de aplicación 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 aplicación de 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 X-Token
header.
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/", response_model=Item)
async def create_item(item: Item, x_token: Annotated[str, Header()]):
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
return item
from typing import Annotated, Union
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: Union[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/", response_model=Item)
async def create_item(item: Item, x_token: Annotated[str, Header()]):
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
return item
from typing import Union
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
from typing_extensions import Annotated
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: Union[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/", response_model=Item)
async def create_item(item: Item, x_token: Annotated[str, Header()]):
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
return item
Consejo
Prefiere usar la versión 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/", response_model=Item)
async def create_item(item: Item, x_token: str = Header()):
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
return item
Consejo
Prefiere usar la versión Annotated
si es posible.
from typing import Union
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: Union[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/", response_model=Item)
async def create_item(item: Item, x_token: str = Header()):
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
return item
Archivo de prueba extendido¶
Podrías entonces 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"}
Cada vez que necesites que el cliente pase información en el request y no sepas cómo, puedes buscar (Googlear) cómo hacerlo en httpx
, o incluso cómo hacerlo con requests
, dado 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 cuerpo JSON, pasa un objeto de Python (por ejemplo, un
dict
) al parámetrojson
. - 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ámetroheaders
. - Para cookies, un
dict
en el parámetrocookies
.
Para más información sobre cómo pasar datos al backend (usando httpx
o el TestClient
) revisa la documentación de HTTPX.
Información
Ten en cuenta 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 las pruebas, puedes usar el jsonable_encoder
descrito en Codificador Compatible con JSON.
Ejecútalo¶
Después de eso, solo necesitas instalar pytest
.
Asegúrate de crear un entorno virtual, activarlo y luego instalarlo, por ejemplo:
$ pip install pytest
---> 100%
Detectará los archivos y pruebas automáticamente, ejecutará las mismas y te reportará los resultados.
Ejecuta las pruebas con:
$ 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
---> 100%
test_main.py <span style="color: green; white-space: pre;">...... [100%]</span>
<span style="color: green;">================= 1 passed in 0.03s =================</span>