La guía completa de FastAPI × pytest: construir APIs “sin miedo a cambiar” con pruebas unitarias, pruebas de API, pruebas de integración y estrategias de mocking
Vista rápida (resumen)
- Para hacer crecer una app FastAPI con seguridad, es importante combinar de forma equilibrada pruebas unitarias, pruebas de API y pruebas de integración.
- Centra tu testing en
pytesty combínalo conTestClientde FastAPI y los dependency overrides para verificar fácilmente el comportamiento a nivel HTTP. - Para la lógica de dominio (capa de servicios) y repositorios, usa mocks o una base de datos SQLite de prueba para testear rápido sin depender de BDs reales ni APIs externas.
- En áreas de seguridad como autenticación/autorización (JWT, scopes), protege el comportamiento probando tokens válidos, tokens inválidos y permisos insuficientes, para que las nuevas funciones y cambios de especificación sigan siendo seguros.
- Por último, conecta todo a CI (p. ej., GitHub Actions) para que los tests se ejecuten automáticamente en cada push, creando un ciclo donde puedes romper cosas—y saberlo al instante.
A quién beneficia esto (perfiles concretos)
Desarrolladores en solitario / Aprendices
- Creaste una API con FastAPI, pero casi no has escrito tests todavía.
- Aún verificas todo manualmente con el navegador o
curl, y te pones nervioso cada vez que haces cambios. - Conoces lo básico de pytest, pero no sabes bien cómo combinarlo correctamente con FastAPI.
Para ti, el setup mínimo de “empieza aquí” y la separación clara entre unit tests y API tests ayudan muchísimo.
Ingenieros backend en equipos pequeños
- Están construyendo FastAPI en un equipo de 3–5, y el estilo de testing empieza a variar según la persona.
- Tienen capas (service, repository, router), pero quieren una política más limpia de qué se prueba dónde.
- Las especificaciones de auth/authz cambian a menudo, así que quieren tests que protejan esos comportamientos.
Para ti, una mentalidad de pirámide de tests (Unit > API > Integration) y los patrones de dependency override y mocking son especialmente efectivos.
Equipos SaaS / Startups
- El producto se mueve rápido y despliegan con frecuencia.
- Cuando aparece un bug, quieren tests de regresión que garanticen que no vuelva.
- Quieren una política de tests compartible a nivel equipo, incluyendo integración CI/CD.
Para ti, esta guía ayuda a convertir el testing con pytest en una plantilla de política de pruebas reutilizable para todo el equipo.
Nota de accesibilidad (legibilidad / estructura)
- El artículo fluye paso a paso: overview → setup → unit tests → API tests → DB/repositorios → auth/authz → async/background → diseño de fixtures → CI → roadmap.
- Cada sección se mantiene en 2–3 párrafos cortos cuando es posible, enfocándose en puntos clave y código de muestra.
- Los bloques de código usan formato de ancho fijo; los comentarios son mínimos para evitar sobrecarga visual.
- Los términos se definen brevemente en su primera aparición y después se mantienen consistentes para reducir la carga cognitiva.
En conjunto, la redacción apunta a legibilidad nivel WCAG AA para un artículo técnico.
1. Primero, aclara qué quieres probar
Antes de meterte en técnicas, ayuda aclarar qué significa realmente “probar una app FastAPI”.
1.1 Las tres capas de testing
-
Pruebas unitarias (Unit Tests)
- Verifican que funciones/clases pequeñas se comporten como se espera.
- Idealmente independientes de BD/red/FastAPI—Python puro.
- Ejemplos: cálculo de precio, reglas de permisos, lógica de dominio en la capa de servicios.
-
Pruebas de API (API Tests / Integration/API Tests)
- Verifican el comportamiento a través de rutas FastAPI usando requests con forma real de HTTP.
- BD/APIs externas se sustituyen por versiones de prueba o mocks para que los tests sean rápidos y repetibles.
-
Pruebas de integración / E2E (Integration / E2E Tests)
- Usan un setup similar a producción (BD real, servicios externos reales, frontend) para verificar escenarios completos.
- Son más costosas, así que mantenlas pocas y enfocadas en rutas críticas.
Este artículo se centra principalmente en pruebas unitarias y pruebas de API, que son el núcleo práctico.
1.2 La mentalidad de la pirámide de tests
La “pirámide de tests” típica dice:
- Base: pruebas unitarias (muchas, rápidas)
- Medio: pruebas de API/integración (cantidad moderada)
- Cima: pruebas E2E (pocas, pero críticas)
Si intentas cubrir todo con E2E, a menudo obtienes:
- tests demasiado lentos para ejecutarlos con frecuencia
- fallos difíciles de diagnosticar
La mejor división suele ser:
- proteger la lógica de dominio con unit tests
- validar el comportamiento específico de HTTP con API tests
2. Setup de pruebas: el kit básico de pytest + FastAPI
2.1 Instalar pytest y comandos comunes
La documentación de FastAPI suele recomendar pytest. Aquí el setup básico:
pip install pytest
Convenciones:
- nombres de archivo:
test_*.pyo*_test.py - nombres de función: empiezan con
test_ - ejecutar desde la raíz del proyecto con
pytest
pytest # ejecuta todos los tests
pytest tests/api # ejecuta un directorio específico
pytest -k "login" # ejecuta tests cuyo nombre contenga "login"
2.2 TestClient de FastAPI
FastAPI está construido sobre Starlette, así que puedes usar TestClient:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_read_root():
res = client.get("/")
assert res.status_code == 200
assert res.json() == {"message": "Hello, World"}
TestClient es síncrono (get, post, etc.), lo que facilita usarlo con tests estándar de pytest.
Para testing async, puedes combinar httpx.AsyncClient con pytest-asyncio, pero empezar con TestClient suele ser lo más simple.
3. Pruebas unitarias: protege la capa de servicios y la lógica de dominio
Empieza probando la lógica que no depende de FastAPI.
3.1 Funciones puras (ejemplo: cálculo de precio)
# app/domain/billing/service.py
def calc_price(base: int, discount_rate: float) -> int:
if not (0 <= discount_rate <= 1):
raise ValueError("discount_rate must be between 0 and 1")
price = int(base * (1 - discount_rate))
if price < 0:
price = 0
return price
# tests/domain/test_billing_service.py
from app.domain.billing.service import calc_price
import pytest
def test_calc_price_normal():
assert calc_price(1000, 0.2) == 800
def test_calc_price_zero():
assert calc_price(1000, 1.0) == 0
def test_calc_price_invalid_discount():
with pytest.raises(ValueError):
calc_price(1000, 1.5)
Incluso tests unitarios pequeños como estos dejan claro de inmediato si un cambio rompió reglas centrales del negocio.
3.2 Capa de servicios + repositorio mockeado
Asume una estructura por capas tipo “service + repository”:
# app/domain/articles/services.py
from dataclasses import dataclass
from app.domain.articles.models import Article
from app.domain.articles.schemas import ArticleCreate, ArticleRead
class ArticleRepositoryProtocol:
def add(self, article: Article) -> Article: ...
def get(self, article_id: int) -> Article | None: ...
@dataclass
class ArticleService:
repo: ArticleRepositoryProtocol
def create_article(self, author_id: int, data: ArticleCreate) -> ArticleRead:
article = Article(
title=data.title,
body=data.body,
author_id=author_id,
status="draft",
)
saved = self.repo.add(article)
return ArticleRead.model_validate(saved)
En tests, evita la BD e inyecta un repositorio en memoria:
# tests/domain/test_article_service.py
from app.domain.articles.services import ArticleService
from app.domain.articles.schemas import ArticleCreate
from app.domain.articles.models import Article
class InMemoryArticleRepo:
def __init__(self):
self.items: list[Article] = []
self._id = 1
def add(self, article: Article) -> Article:
article.id = self._id
self._id += 1
self.items.append(article)
return article
def get(self, article_id: int) -> Article | None:
for a in self.items:
if a.id == article_id:
return a
return None
def test_create_article_sets_draft_status():
repo = InMemoryArticleRepo()
service = ArticleService(repo=repo)
data = ArticleCreate(title="Hello", body="World")
result = service.create_article(author_id=42, data=data)
assert result.status == "draft"
assert result.title == "Hello"
assert result.author_id == 42
Esto hace más seguros los cambios en la capa de servicios, porque los tests detectan rápido cambios de comportamiento no deseados.
4. Pruebas de API: verifica el comportamiento a través de endpoints
Ahora pasa al comportamiento de cara a HTTP.
4.1 Probar un endpoint CRUD simple
Rutas de ejemplo:
# app/api/v1/articles.py
from fastapi import APIRouter, Depends
from app.domain.articles.schemas import ArticleCreate, ArticleRead
from app.domain.articles.services import ArticleService
from app.deps.services import get_article_service
router = APIRouter(prefix="/articles", tags=["articles"])
@router.get("", response_model=list[ArticleRead])
def list_articles(
service: ArticleService = Depends(get_article_service),
):
return service.list_articles()
@router.post("", response_model=ArticleRead, status_code=201)
def create_article(
payload: ArticleCreate,
service: ArticleService = Depends(get_article_service),
):
return service.create_article(author_id=1, data=payload)
Test básico:
# tests/api/test_articles_api.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_create_and_list_articles():
res = client.post("/articles", json={"title": "T1", "body": "B1"})
assert res.status_code == 201
created = res.json()
assert created["id"] is not None
assert created["title"] == "T1"
res_list = client.get("/articles")
assert res_list.status_code == 200
items = res_list.json()
assert len(items) >= 1
4.2 Dependency overrides
En proyectos reales, no quieres que los API tests se conecten a la BD de producción.
app.dependency_overrides te permite reemplazar dependencias durante los tests.
# tests/api/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.infra.db.base import Base
from app.infra.db.base import get_db # dependencia de producción
TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
@pytest.fixture(scope="session", autouse=True)
def setup_database():
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
@pytest.fixture()
def client():
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
yield client
app.dependency_overrides.clear()
Ahora tus tests pueden usar el fixture:
# tests/api/test_articles_api.py
def test_create_article_uses_test_db(client: TestClient):
res = client.post("/articles", json={"title": "T1", "body": "B1"})
assert res.status_code == 201
Este patrón es extremadamente útil—vale la pena convertirlo en plantilla reutilizable del proyecto.
5. Probar capas de BD / repositorio: transacciones y rollbacks
Un reto común: “¿cómo mantengo la BD limpia entre tests?”
5.1 Crear tablas una vez, hacer rollback por test
Un patrón estándar:
- crear/borrar tablas una vez por sesión
- iniciar una transacción por test
- rollback después de cada test
# tests/db/conftest.py (ejemplo)
@pytest.fixture()
def db_session():
connection = engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
try:
yield session
finally:
session.close()
transaction.rollback()
connection.close()
Luego prueba repositorios:
# tests/domain/test_article_repository.py
from app.infra.articles.sqlalchemy_repo import SqlAlchemyArticleRepository
from app.domain.articles.models import Article
def test_add_article(db_session):
repo = SqlAlchemyArticleRepository(db_session)
article = Article(title="T", body="B", author_id=1, status="draft")
saved = repo.add(article)
assert saved.id is not None
again = repo.get(saved.id)
assert again is not None
assert again.title == "T"
Los rollbacks mantienen limpia la BD incluso cuando crece la suite de tests.
6. Testing de autenticación / autorización / JWT
Los tests de seguridad dan muchísima confianza.
6.1 Probar login
# tests/api/test_auth_api.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_login_success():
res = client.post(
"/auth/token",
data={"username": "alice", "password": "password123"},
)
assert res.status_code == 200
body = res.json()
assert "access_token" in body
assert body["token_type"] == "bearer"
def test_login_failure():
res = client.post(
"/auth/token",
data={"username": "alice", "password": "wrong"},
)
assert res.status_code == 401
6.2 Ruta protegida + header de token
def get_token_for(username: str, password: str) -> str:
res = client.post(
"/auth/token",
data={"username": username, "password": password},
)
assert res.status_code == 200
return res.json()["access_token"]
def test_protected_route_requires_token():
res = client.get("/me")
assert res.status_code == 401
def test_protected_route_with_valid_token():
token = get_token_for("alice", "password123")
res = client.get("/me", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 200
body = res.json()
assert body["username"] == "alice"
6.3 Rutas basadas en scopes
def test_requires_write_scope():
# preparar un token solo con "articles:read", etc. (vía helpers de test)
token = get_token_for("alice", "password123") # ejemplo: usuario solo lectura
res = client.post(
"/articles",
json={"title": "T1", "body": "B1"},
headers={"Authorization": f"Bearer {token}"},
)
assert res.status_code in (401, 403)
Para tests de permisos, ponerles nombres basados en especificaciones (p. ej., test_admin_can_delete_user) hace mucho más fácil el mantenimiento futuro.
7. Testing de async y trabajo en background
FastAPI soporta I/O async y tareas en background, pero el testing requiere un poco de cuidado.
7.1 BackgroundTasks
Como BackgroundTasks corre después de la respuesta, suele ser mejor hacer unit-test de la función de tarea en sí.
# app/tasks/audit.py
def write_audit_log(user_id: int, action: str) -> None:
print(f"{user_id} {action}")
# tests/tasks/test_audit.py
from app.tasks.audit import write_audit_log
def test_write_audit_log_runs_without_error(capsys):
write_audit_log(1, "login")
captured = capsys.readouterr()
assert "login" in captured.out
En API tests, comprueba que la tarea se registra, y prueba la lógica de la tarea por separado.
7.2 Colas de jobs con Celery
Si usas Celery, ejecutar tareas de forma síncrona en tests suele ser más fácil:
# tests/conftest.py (ejemplo)
from app.celery_app import celery_app
def pytest_configure():
celery_app.conf.update(task_always_eager=True)
# tests/tasks/test_long_add.py
from app.tasks import long_add
def test_long_add():
res = long_add.delay(1, 2)
assert res.result == 3
8. Fixtures y datos de test: mantén los tests limpios y reutilizables
Los fixtures de pytest ayudan a compartir estado y datos de forma limpia.
8.1 Un fixture “crear usuario”
# tests/fixtures/users.py
import pytest
from sqlalchemy.orm import Session
from app.models.user import User
from app.core.security import hash_password
@pytest.fixture
def user_alice(db_session: Session) -> User:
alice = User(
username="alice",
hashed_password=hash_password("password123"),
is_active=True,
)
db_session.add(alice)
db_session.commit()
db_session.refresh(alice)
return alice
Reutilización en API tests:
# tests/api/test_auth_api.py
def test_login_with_fixture(client, user_alice):
res = client.post(
"/auth/token",
data={"username": "alice", "password": "password123"},
)
assert res.status_code == 200
8.2 Patrón factory
A medida que crecen los datos de prueba, factory_boy u otras herramientas pueden ayudar.
Pero al principio, funciones helper simples suelen bastar—no hace falta forzar complejidad.
9. Integración con CI: haz que los tests “siempre se ejecuten”
Conecta los tests a CI para que se ejecuten automáticamente en pushes y PRs.
9.1 Workflow mínimo de GitHub Actions
# .github/workflows/tests.yaml
name: Run tests
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install deps
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run pytest
run: pytest
Esto permite aplicar la regla: “No merges si los tests fallan”.
10. Errores comunes y arreglos suaves
| Síntoma | Causa probable | Arreglo |
|---|---|---|
| Los tests son lentos y nadie los corre | Cada test toca la BD de producción / APIs externas | Usa BD de test o mocks; usa dependency overrides |
| Los fallos son difíciles de diagnosticar | Un test hace demasiadas cosas | Reduce cada test a un solo propósito |
| Los tests de auth API son molestos | Haces login y pides token cada vez | Crea helpers o un fixture de cliente autenticado |
| Se filtra el estado de BD entre tests | No hay rollback / aislamiento | Usa transacciones por test y rollbacks (o setup de BD limpia) |
| No está claro el límite entre unit y API | No hay política de “qué probar dónde” | Decide “lógica de dominio en unit tests, comportamiento HTTP en API tests” |
11. Hoja de ruta de adopción (crece paso a paso)
-
Instala pytest y escribe solo un unit test
Empieza con una función pura (como cálculo de precio). -
Escribe un API test con
TestClient
Un endpoint simple como/healthfunciona bien. -
Añade una BD de test + dependency override para API tests que dependan de BD
Prueba CRUD usando SQLite + rollback por transacción. -
Aumenta unit tests de la capa de servicios usando repositorios mock
Protege reglas de negocio importantes sin BD ni FastAPI. -
Añade tests de auth/authz
Login éxito/fallo, token requerido, permiso denegado. -
Conéctalo a CI para que los tests corran automáticamente
Como mínimo, ejecuta tests antes de mergear amain. -
Amplía herramientas según haga falta
Añade factories, mocking avanzado, tests async, etc., según la complejidad del proyecto.
Resumen
- Para testing en FastAPI, el kit central es pytest + TestClient + dependency overrides.
- Protege la lógica de dominio con pruebas unitarias y valida el comportamiento HTTP (headers, códigos de estado, JSON) con pruebas de API.
- Sustituye BD/APIs externas por BDs de test y mocks para que los tests sean rápidos y repetibles.
- Cubre auth/authz (JWT, scopes) con tests para que los cambios de especificación sean más seguros.
- No necesitas construirlo todo de golpe: empieza con un unit test y un API test, y crece desde ahí.
Estoy animando en silencio a que tu proyecto quede protegido por tests—para que puedas experimentar con más libertad, sin miedo a romper cosas.
