Objetivos de Aprendizaje
- Testear APIs FastAPI con TestClient
- Escribir tests para rutas CRUD
- Usar fixtures de pytest
- Medir cobertura de código
- Testear autenticación
TestClient (15 min)
FastAPI incluye TestClient para testear sin levantar el servidor.
Instalación
pip install httpx pytest
Estructura de Tests
# main.py (tu aplicación)
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello World"}
@app.get("/items/{item_id}")
def read_item(item_id: int):
return {"item_id": item_id}
# test_main.py (tests)
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_read_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello World"}
def test_read_item():
response = client.get("/items/42")
assert response.status_code == 200
assert response.json() == {"item_id": 42}
Ejecutar Tests
pytest test_main.py -v
# Salida:
# test_main.py::test_read_root PASSED
# test_main.py::test_read_item PASSED
Tests CRUD Completo (25 min)
# test_tareas.py
import pytest
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
class TestTareas:
"""Tests para la API de Tareas."""
def test_crear_tarea(self):
"""POST /tareas - Crear nueva tarea."""
response = client.post(
"/tareas",
json={"titulo": "Nueva tarea", "descripcion": "Descripción"}
)
assert response.status_code == 201
data = response.json()
assert data["titulo"] == "Nueva tarea"
assert "id" in data
def test_listar_tareas(self):
"""GET /tareas - Listar todas las tareas."""
# Primero crear una tarea
client.post("/tareas", json={"titulo": "Tarea 1"})
response = client.get("/tareas")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
def test_obtener_tarea(self):
"""GET /tareas/{id} - Obtener tarea específica."""
# Crear tarea
create_response = client.post("/tareas", json={"titulo": "Específica"})
tarea_id = create_response.json()["id"]
# Obtenerla
response = client.get(f"/tareas/{tarea_id}")
assert response.status_code == 200
assert response.json()["titulo"] == "Específica"
def test_obtener_tarea_no_existe(self):
"""GET /tareas/{id} - Tarea no encontrada."""
response = client.get("/tareas/99999")
assert response.status_code == 404
assert response.json()["detail"] == "Tarea no encontrada"
def test_actualizar_tarea(self):
"""PUT /tareas/{id} - Actualizar tarea."""
# Crear
create_response = client.post("/tareas", json={"titulo": "Original"})
tarea_id = create_response.json()["id"]
# Actualizar
response = client.put(
f"/tareas/{tarea_id}",
json={"titulo": "Actualizada", "descripcion": "Nueva desc", "completada": True}
)
assert response.status_code == 200
assert response.json()["titulo"] == "Actualizada"
def test_eliminar_tarea(self):
"""DELETE /tareas/{id} - Eliminar tarea."""
# Crear
create_response = client.post("/tareas", json={"titulo": "A eliminar"})
tarea_id = create_response.json()["id"]
# Eliminar
response = client.delete(f"/tareas/{tarea_id}")
assert response.status_code == 200
# Verificar que ya no existe
get_response = client.get(f"/tareas/{tarea_id}")
assert get_response.status_code == 404
Fixtures (15 min)
Las fixtures preparan datos para los tests.
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def client():
"""Proporciona un TestClient."""
return TestClient(app)
@pytest.fixture
def tarea_existente(client):
"""Crea una tarea y retorna su ID."""
response = client.post("/tareas", json={"titulo": "Tarea fixture"})
return response.json()
class TestConFixtures:
def test_usar_tarea_existente(self, client, tarea_existente):
"""Usa la fixture de tarea."""
tarea_id = tarea_existente["id"]
response = client.get(f"/tareas/{tarea_id}")
assert response.status_code == 200
assert response.json()["titulo"] == "Tarea fixture"
def test_eliminar_tarea_existente(self, client, tarea_existente):
"""Elimina la tarea creada por la fixture."""
tarea_id = tarea_existente["id"]
response = client.delete(f"/tareas/{tarea_id}")
assert response.status_code == 200
Cobertura (10 min)
Instalar pytest-cov
pip install pytest-cov
Medir Cobertura
# Ver cobertura en terminal
pytest --cov=main --cov-report=term-missing
# Generar reporte HTML
pytest --cov=main --cov-report=html
# Abrir htmlcov/index.html en navegador
Meta: Para la Evaluación 3, se requiere cobertura mínima del 80%.
Ejercicio: Tests Completos (5 min)
Escribe tests para:
- Validación de datos (título vacío debe retornar 422)
- Autenticación (sin token debe retornar 401)
- Permisos (usuario normal no puede eliminar de otro)
def test_crear_tarea_sin_titulo(client):
"""Título vacío debe fallar."""
response = client.post("/tareas", json={"titulo": ""})
assert response.status_code == 422
def test_acceso_sin_token(client):
"""Sin token debe retornar 401."""
response = client.get("/tareas/protegidas")
assert response.status_code == 401
def test_eliminar_tarea_de_otro_usuario(client):
"""No se puede eliminar tarea de otro usuario."""
# Crear tarea con usuario 1
client.headers["X-Token"] = "token1"
tarea = client.post("/tareas", json={"titulo": "Mia"}).json()
# Intentar eliminar con usuario 2
client.headers["X-Token"] = "token2"
response = client.delete(f"/tareas/{tarea['id']}")
assert response.status_code == 403
Resumen
- TestClient: Simula peticiones HTTP
- status_code: Verifica código de respuesta
- .json(): Obtiene datos de respuesta
- Fixtures: Preparan datos reutilizables
- Cobertura: Mide % de código testeado
Evaluación 3: API funcional + tests con 80% cobertura.