
¿Por qué hacer testing en una API?
El testing automatizado garantiza que tu API funciona correctamente y sigue funcionando cuando introduces cambios. Cada test actúa como una red de seguridad que detecta problemas antes de llegar a producción.
En FastAPI, el testing es especialmente sencillo gracias a la integración con pytest (el framework de testing estándar en Python) y TestClient, un cliente HTTP que permite realizar peticiones a tu aplicación sin necesidad de ejecutar un servidor real.
Instalación de dependencias de testing
pip install pytest pytest-cov httpx
- pytest: el framework de testing
- pytest-cov: para medir la cobertura de código
- httpx: cliente HTTP asíncrono (requerido por TestClient)
Estructura de un proyecto con tests
La organización recomendada separa los tests del código de producción:
mi_proyecto/
├── app/
│ ├── __init__.py
│ ├── main.py
│ └── routers/
│ └── productos.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py ← Fixtures compartidas
│ ├── test_main.py
│ └── test_productos.py
└── requirements.txt
La aplicación de ejemplo
Para este tutorial, trabajamos con una API de gestión de tareas:
# app/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
app = FastAPI()
# Base de datos en memoria para los tests
tareas_db: dict[int, dict] = {}
contador_id = 0
class TareaCrear(BaseModel):
titulo: str
descripcion: Optional[str] = None
completada: bool = False
class TareaRespuesta(BaseModel):
id: int
titulo: str
descripcion: Optional[str] = None
completada: bool
@app.get("/")
def raiz():
return {"mensaje": "API de Tareas"}
@app.get("/tareas", response_model=list[TareaRespuesta])
def listar_tareas():
return list(tareas_db.values())
@app.post("/tareas", response_model=TareaRespuesta, status_code=201)
def crear_tarea(tarea: TareaCrear):
global contador_id
contador_id += 1
nueva = {"id": contador_id, **tarea.model_dump()}
tareas_db[contador_id] = nueva
return nueva
@app.get("/tareas/{tarea_id}", response_model=TareaRespuesta)
def obtener_tarea(tarea_id: int):
if tarea_id not in tareas_db:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
return tareas_db[tarea_id]
@app.put("/tareas/{tarea_id}", response_model=TareaRespuesta)
def actualizar_tarea(tarea_id: int, tarea: TareaCrear):
if tarea_id not in tareas_db:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
tareas_db[tarea_id] = {"id": tarea_id, **tarea.model_dump()}
return tareas_db[tarea_id]
@app.delete("/tareas/{tarea_id}", status_code=204)
def eliminar_tarea(tarea_id: int):
if tarea_id not in tareas_db:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
del tareas_db[tarea_id]
Primer test: configuración básica
El TestClient se inicializa con la instancia de la aplicación FastAPI:
# tests/test_main.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_raiz():
respuesta = client.get("/")
assert respuesta.status_code == 200
assert respuesta.json() == {"mensaje": "API de Tareas"}
Para ejecutar:
pytest tests/test_main.py -v
El patrón Arrange-Act-Assert
Los buenos tests siguen el patrón AAA (Arrange-Act-Assert):
def test_crear_tarea():
# Arrange: preparar los datos de entrada
datos_tarea = {
"titulo": "Estudiar FastAPI",
"descripcion": "Completar el módulo de testing",
"completada": False
}
# Act: realizar la acción que se quiere testear
respuesta = client.post("/tareas", json=datos_tarea)
# Assert: verificar el resultado esperado
assert respuesta.status_code == 201
datos_respuesta = respuesta.json()
assert datos_respuesta["titulo"] == "Estudiar FastAPI"
assert datos_respuesta["descripcion"] == "Completar el módulo de testing"
assert datos_respuesta["completada"] is False
assert "id" in datos_respuesta
Tests para cada método HTTP
Test de GET - listar recursos
def test_listar_tareas_vacia():
"""Verifica que la lista de tareas devuelve una lista vacía inicialmente."""
respuesta = client.get("/tareas")
assert respuesta.status_code == 200
assert isinstance(respuesta.json(), list)
def test_listar_tareas_con_datos():
"""Verifica que las tareas creadas aparecen en el listado."""
# Crear una tarea primero
client.post("/tareas", json={"titulo": "Tarea de prueba"})
# Listar y verificar
respuesta = client.get("/tareas")
assert respuesta.status_code == 200
tareas = respuesta.json()
assert len(tareas) >= 1
titulos = [t["titulo"] for t in tareas]
assert "Tarea de prueba" in titulos
Test de GET - obtener recurso específico
def test_obtener_tarea_existente():
"""Verifica que se puede obtener una tarea por su ID."""
# Crear la tarea
crear = client.post("/tareas", json={"titulo": "Mi tarea"})
tarea_id = crear.json()["id"]
# Obtener por ID
respuesta = client.get(f"/tareas/{tarea_id}")
assert respuesta.status_code == 200
assert respuesta.json()["id"] == tarea_id
assert respuesta.json()["titulo"] == "Mi tarea"
def test_obtener_tarea_no_existente():
"""Verifica que devuelve 404 cuando la tarea no existe."""
respuesta = client.get("/tareas/99999")
assert respuesta.status_code == 404
assert "no encontrada" in respuesta.json()["detail"].lower()
Test de PUT - actualización completa
def test_actualizar_tarea():
"""Verifica que una tarea se actualiza correctamente."""
# Crear la tarea original
crear = client.post("/tareas", json={"titulo": "Tarea original"})
tarea_id = crear.json()["id"]
# Actualizar
datos_actualizados = {
"titulo": "Tarea actualizada",
"descripcion": "Con nueva descripción",
"completada": True
}
respuesta = client.put(f"/tareas/{tarea_id}", json=datos_actualizados)
assert respuesta.status_code == 200
datos = respuesta.json()
assert datos["titulo"] == "Tarea actualizada"
assert datos["completada"] is True
def test_actualizar_tarea_no_existente():
"""Verifica que actualizar una tarea inexistente devuelve 404."""
respuesta = client.put(
"/tareas/99999",
json={"titulo": "No importa", "completada": False}
)
assert respuesta.status_code == 404
Test de DELETE
def test_eliminar_tarea():
"""Verifica que una tarea se elimina correctamente."""
# Crear la tarea
crear = client.post("/tareas", json={"titulo": "A eliminar"})
tarea_id = crear.json()["id"]
# Eliminar
respuesta = client.delete(f"/tareas/{tarea_id}")
assert respuesta.status_code == 204
# Verificar que ya no existe
obtener = client.get(f"/tareas/{tarea_id}")
assert obtener.status_code == 404
def test_eliminar_tarea_no_existente():
"""Verifica que eliminar una tarea inexistente devuelve 404."""
respuesta = client.delete("/tareas/99999")
assert respuesta.status_code == 404
Tests de validación
FastAPI válida automáticamente los datos con Pydantic. Es importante testear también los casos de error de validación:
def test_crear_tarea_sin_titulo():
"""Verifica que se rechaza una tarea sin título (campo requerido)."""
respuesta = client.post("/tareas", json={"descripcion": "Sin título"})
assert respuesta.status_code == 422 # Unprocessable Entity
def test_crear_tarea_datos_invalidos():
"""Verifica el rechazo de datos con tipos incorrectos."""
respuesta = client.post("/tareas", json={
"titulo": "Tarea",
"completada": "no-es-booleano" # Debe ser bool
})
# Pydantic intentará convertir "no-es-booleano" a bool
# Si no puede, devuelve 422
assert respuesta.status_code in [201, 422]
def test_ruta_con_id_invalido():
"""Verifica que un ID no numérico devuelve 422."""
respuesta = client.get("/tareas/no-es-un-numero")
assert respuesta.status_code == 422
Fixtures de pytest con conftest.py
Las fixtures de pytest permiten compartir configuración entre múltiples tests. En el conftest.py puedes definir el cliente de prueba para reutilizarlo:
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app, tareas_db, contador_id
@pytest.fixture
def client():
"""Fixture que proporciona un TestClient con la BD limpia."""
# Limpiar la BD antes de cada test
tareas_db.clear()
with TestClient(app) as test_client:
yield test_client
# Limpiar después del test también
tareas_db.clear()
@pytest.fixture
def tarea_creada(client):
"""Fixture que proporciona un cliente con una tarea ya creada."""
respuesta = client.post("/tareas", json={
"titulo": "Tarea de fixture",
"descripcion": "Creada por fixture"
})
return respuesta.json()
Usando las fixtures:
# tests/test_con_fixtures.py
def test_listar_con_tarea(client, tarea_creada):
"""Usa las fixtures para tener datos listos."""
respuesta = client.get("/tareas")
assert respuesta.status_code == 200
tareas = respuesta.json()
assert len(tareas) == 1
assert tareas[0]["titulo"] == "Tarea de fixture"
def test_eliminar_con_fixture(client, tarea_creada):
"""Elimina la tarea creada por la fixture."""
tarea_id = tarea_creada["id"]
respuesta = client.delete(f"/tareas/{tarea_id}")
assert respuesta.status_code == 204
Ejecutar los tests
# Ejecutar todos los tests
pytest
# Con salida detallada
pytest -v
# Solo un archivo
pytest tests/test_main.py -v
# Solo tests que contengan "crear" en el nombre
pytest -k "crear" -v
# Con informe de cobertura
pytest --cov=app tests/
# Informe de cobertura en HTML
pytest --cov=app --cov-report=html tests/
El informe de cobertura HTML se genera en la carpeta htmlcov/ y muestra exactamente qué líneas de código están cubiertas por los tests.
Verificar respuestas HTTP de forma completa
Un test completo verifica no solo el status code, sino también el cuerpo, las cabeceras y la estructura de la respuesta:
def test_crear_tarea_completo(client):
"""Test completo que verifica todos los aspectos de la respuesta."""
datos = {
"titulo": "Test completo",
"descripcion": "Verificando todos los campos"
}
respuesta = client.post("/tareas", json=datos)
# Status code
assert respuesta.status_code == 201
# Content-Type
assert "application/json" in respuesta.headers["content-type"]
# Cuerpo de la respuesta
cuerpo = respuesta.json()
assert isinstance(cuerpo["id"], int)
assert cuerpo["titulo"] == "Test completo"
assert cuerpo["descripcion"] == "Verificando todos los campos"
assert cuerpo["completada"] is False
# Verificar que el recurso es accesible después de la creación
obtener = client.get(f"/tareas/{cuerpo['id']}")
assert obtener.status_code == 200
assert obtener.json()["titulo"] == "Test completo"
Fuentes y referencias
Documentación oficial y recursos externos para profundizar en FastAPI
Documentación oficial de FastAPI
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, FastAPI es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de FastAPI
Explora más contenido relacionado con FastAPI y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Configurar el entorno de testing para FastAPI con pytest y TestClient. Escribir tests para endpoints GET, POST, PUT y DELETE verificando status codes y respuestas. Probar el manejo de errores y validaciones de Pydantic en los endpoints. Organizar los tests en ficheros y usar fixtures de pytest para reutilizar configuración. Ejecutar la suite de tests y generar informes de cobertura con pytest-cov.