green snake
Photo by Pixabay on Pexels.com

Estrategias de pruebas en FastAPI para elevar la calidad: pytest, TestClient/httpx, sustitución de dependencias, rollbacks de BD, mocks, pruebas de contrato y pruebas de carga


Resumen (pirámide invertida)

  • Prepare pruebas en capas—unitarias, API, integración, contrato y carga—y cúbralas paso a paso.
  • Diseñe fixtures con pytest y use TestClient vs. httpx.AsyncClient según corresponda.
  • Aproveche la inyección de dependencias (Depends) y cambie dependencias vía app.dependency_overrides de forma sucinta.
  • Mantenga la BD limpia con rollbacks de transacciones o un engine dedicado; simule APIs externas con herramientas como respx.
  • Valide OpenAPI con pruebas de contrato y añada pytest-benchmark o locust para construir confianza operativa.

A quién beneficia

  • Alumno A (tesis / solo dev)
    Quiere un starter kit pequeño de pruebas. Primero, automatizar dos rutas por API: éxito y fallo.
  • Equipo pequeño B (taller de 3 personas)
    Cambios frecuentes de especificación. Use overrides de dependencias, rollbacks de BD y mocks de APIs externas para localizar roturas rápido.
  • Equipo SaaS C (startup)
    Temor a incidentes en producción. Quiere pruebas de contrato compatibles con OpenAPI y benchmarks / carga en CI.

1. El mapa de pruebas (empiece con la visión completa)

  • Pruebas unitarias: verifican lógica de funciones/servicios rápidamente.
  • Pruebas de API: verifican entrada/salida para GET/POST (éxito/fallo/límites).
  • Pruebas de integración: verifican comportamiento usando recursos reales (BD/APIs externas).
  • Pruebas de contrato: verifican conformidad con OpenAPI y reproducibilidad de ejemplos.
  • Carga y benchmarks: entienden tendencias de latencia/rendimiento.

Puntos de decisión

  • Empiece con unitarias y API. Después, aísle efectos externos con overrides de dependencias. Finalmente añada integración y carga.

2. Configuración mínima (bases de pytest)

Ejemplo pytest.ini:

# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
addopts = -q

Dependencias de ejemplo (requirements):

pytest
httpx
pytest-asyncio
respx            # mock para httpx
pytest-benchmark # opcional: benchmarks

Estructura de proyecto ejemplo:

app/
  main.py
  routers/
    articles.py
  core/
    settings.py
    security.py
  db/
    session.py
tests/
  conftest.py
  test_articles_api.py
  test_services_unit.py

3. Levantar la app en pruebas (sync vs. async)

  • Endpoints mayormente sync: fastapi.testclient.TestClient es conveniente.
  • Para endpoints async/WebSocket o muchos async def: prefiera httpx.AsyncClient.

API de muestra (SUT):

# app/routers/articles.py
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel

router = APIRouter(prefix="/articles", tags=["articles"])

class Article(BaseModel):
    id: int
    title: str

FAKE = {1: {"id": 1, "title": "hello"}}

@router.get("/{aid}", response_model=Article)
def get_article(aid: int):
    a = FAKE.get(aid)
    if not a:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
    return a

main.py:

# app/main.py
from fastapi import FastAPI
from app.routers import articles

app = FastAPI(title="Testable API")
app.include_router(articles.router)

4. Comprobaciones rápidas sync con TestClient

# tests/test_articles_api.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_get_article_ok():
    r = client.get("/articles/1")
    assert r.status_code == 200
    body = r.json()
    assert body["id"] == 1
    assert "title" in body

def test_get_article_404():
    r = client.get("/articles/999")
    assert r.status_code == 404
    assert r.json()["detail"] == "not found"

Puntos de decisión

  • Escriba siempre un par mínimo de éxito + fallo. Añada casos límite (mín/máx/inexistente) para endurecer cobertura.

5. Pruebas async con httpx.AsyncClient

# tests/test_async_api.py
import pytest
from httpx import AsyncClient
from app.main import app

@pytest.mark.asyncio
async def test_get_article_async():
    async with AsyncClient(app=app, base_url="http://test") as ac:
        res = await ac.get("/articles/1")
        assert res.status_code == 200

Puntos de decisión

  • AsyncClient(app=app) corre en modo ASGI embebido—sin servidor externo, bucle interno rápido.

6. Overrides de dependencias (el poder de Depends)

  • Dependencias como sesiones de BD o auth pueden intercambiarse con app.dependency_overrides.
  • Esto le permite aislar comportamiento de API de auth/permisos/BD.

Ejemplo: sobreescribir una dependencia de auth

# app/core/security.py (supuesto)
from fastapi import Depends, HTTPException, status

def get_current_user():
    # En realidad verificaría JWT, etc.
    raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")

En las pruebas:

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.core import security

@pytest.fixture(autouse=True)
def override_auth():
    def fake_user():
        return {"sub": "test-user", "role": "admin"}
    app.dependency_overrides[security.get_current_user] = fake_user
    yield
    app.dependency_overrides.clear()

@pytest.fixture
def client():
    return TestClient(app)

Puntos de decisión

  • Limpie siempre los overrides al final del test para evitar fugas entre pruebas.

7. Pruebas con BD: evite contaminación con transacciones

Tres estrategias:

  1. BD en memoria (aprendizaje / ultra-rápido)
  2. BD de pruebas + rollbacks de transacción (más cercano a prod)
  3. Ejecutar migraciones primero (Alembic)

Ejemplo SQLAlchemy 2.x:

# tests/db_utils.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db.session import Base  # Declarative Base
from app.db.session import get_db # dependency function

@pytest.fixture(scope="session")
def engine():
    # Para estar más cerca de producción, use una BD de pruebas (aquí sqlite en memoria)
    eng = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
    Base.metadata.create_all(eng)
    return eng

@pytest.fixture
def db_session(engine):
    connection = engine.connect()
    trans = connection.begin()
    Session = sessionmaker(bind=connection, autoflush=False, autocommit=False)
    session = Session()
    yield session
    session.close()
    trans.rollback()
    connection.close()

@pytest.fixture(autouse=True)
def override_db(db_session):
    from app.main import app
    def _get_db():
        try:
            yield db_session
        finally:
            pass
    app.dependency_overrides[get_db] = _get_db
    yield
    app.dependency_overrides.clear()

Puntos de decisión

  • Inicie una transacción por test → rollback en teardown. Rápido y reproducible.

8. Mocking de HTTP externo (httpx × respx)

Corte la red para pruebas que dependen de APIs externas.

# tests/test_external_calls.py
import respx
from httpx import Response
import pytest
from httpx import AsyncClient
from app.main import app

@respx.mock
@pytest.mark.asyncio
async def test_external_call():
    respx.get("https://api.example.com/users/1").mock(
        return_value=Response(200, json={"id": 1, "name": "Alice"})
    )
    async with AsyncClient(app=app, base_url="http://test") as ac:
        r = await ac.get("/proxy/users/1")  # endpoint proxy de la app (supuesto)
        assert r.status_code == 200
        assert r.json()["name"] == "Alice"

Puntos de decisión

  • Simule rutas normales y de fallo (5xx/timeouts) para verificar retries/fallbacks.

9. Pruebas de AuthN/AuthZ (scopes y acceso)

  • API de login: éxitos y fallos (email incorrecto, contraseña incorrecta, usuario bloqueado).
  • Rutas protegidas: sin token → 401, scope insuficiente → 403, scope suficiente → 200.

Ejemplo:

def test_protected_401(client):
    res = client.get("/protected")
    assert res.status_code == 401

def test_protected_403(client, monkeypatch):
    from app.core import security
    def low_scope_user():
        return {"sub": "u", "scopes": {"articles:read"}}
    app = security  # alias
    from app.main import app as fastapi_app
    fastapi_app.dependency_overrides[security.get_current_user] = low_scope_user
    res = client.post("/articles")  # asuma que requiere *write scope*
    assert res.status_code == 403

Puntos de decisión

  • Mantenga los scopes requeridos mínimos por ruta. Las pruebas deben aclarar diferencias de scope vía overrides.

10. Validación y exception handlers

  • Fije la forma de errores de Pydantic y respuestas de error estandarizadas.
  • Distinga 422 Unprocessable Entity (entrada inválida) de errores de negocio personalizados.
def test_validation_422(client):
    res = client.post("/users", json={"email": "not-an-email"})
    assert res.status_code == 422
    body = res.json()
    assert "detail" in body

Puntos de decisión

  • Si cambian las formas de error, los clientes se rompen. Las snapshot tests ayudan a fijarlas.

11. Pruebas de contrato contra OpenAPI

  • Integridad del esquema: requeridos/typing/valores de enum.
  • Reproducibilidad de ejemplos: ¿las peticiones de ejemplo se comportan como se documenta?

Chequeo simple:

# tests/test_openapi_contract.py
from app.main import app

def test_openapi_basic():
    spec = app.openapi()
    assert spec["info"]["title"] == "Testable API"
    paths = spec["paths"]
    assert "/articles/{aid}" in paths

Enfoques más potentes incluyen herramientas que generan pruebas desde el esquema (ver referencias).


12. Pruebas basadas en propiedades (property-based)

Use hypothesis para generar automáticamente valores cercanos a límites.

Ejemplo (unitaria):

# tests/test_services_unit.py
from hypothesis import given, strategies as st

def safe_div(a: int, b: int) -> float | None:
    if b == 0:
        return None
    return a / b

@given(st.integers(), st.integers())
def test_safe_division(a, b):
    r = safe_div(a, b)
    if b == 0:
        assert r is None
    else:
        assert isinstance(r, float)

Puntos de decisión

  • Mejor para capas de servicio con lógica pesada en vez de la capa de API.

13. Benchmarks y pruebas de carga

Benchmarks (unitarios/API micro):

# tests/test_bench.py
import pytest
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

@pytest.mark.benchmark(min_rounds=5)
def test_get_article_bench(benchmark):
    def do():
        assert client.get("/articles/1").status_code == 200
    benchmark(do)

Pruebas de carga (proceso aparte):

  • Use locust o k6 para describir escenarios (auth → list → detail → create).
  • Enfoque métricas en distribución de latencia, tasa de error y throughput (métrica RED).

Puntos de decisión

  • En CI, ejecute benchmarks cortos para detectar regresiones; ejecute carga en staging.

14. Pruebas de WebSocket/SSE

  • WebSocket: use websockets/starlette.testclient para conectar y verificar enviar/recibir.
  • SSE: conecte con httpx y parsee eventos del flujo.

Ejemplo WebSocket:

# tests/test_ws.py
from starlette.testclient import TestClient
from app.main import app

def test_ws_echo():
    with TestClient(app).websocket_connect("/realtime/ws?room=test") as ws:
        ws.send_json({"msg": "hi"})
        data = ws.receive_json()
        assert "echo" in data

Puntos de decisión

  • Simule heartbeats y desconexiones anómalas; detecte conexiones filtradas.

15. Ejecución en CI y artefactos

  • Ejecución de tests: pytest -q
  • Cobertura: visualice módulos clave con coverage.
  • Artefactos: almacene logs de fallos, JSON de OpenAPI, capturas (si aplica).
  • Paralelismo: pytest -n auto (pytest-xdist) para acelerar.

Outline de GitHub Actions:

name: test
on: [push, pull_request]
jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11' }
      - run: pip install -r requirements.txt
      - run: pip install pytest pytest-asyncio respx pytest-benchmark
      - run: pytest -q

Puntos de decisión

  • Las pruebas deben correr en cada PR. Considere exportar OpenAPI y generar SDKs aquí también.

16. Problemas comunes y remedios

Síntoma Causa Remedio
Pruebas lentas Golpean BD/APIs reales Overrides/mocks, rollbacks de transacción, ejecuciones en paralelo
Pruebas inestables Dependencias de tiempo/aleatoriedad/orden Seeds fijos, inyección de tiempo, aislamiento de E/S, pytest-randomly
Deriva de especificación Docs no actualizadas Pruebas de contrato OpenAPI, validar ejemplos
Fallos solo en prod Diferencias de config/red .env separado, inyecte settings como dependencias, timeouts cortos
Fugas de limpieza Sesiones sin cerrar / overrides residuales Fixtures con yield, app.dependency_overrides.clear()

17. Hoja de ruta de adopción

  1. Para APIs clave, escriba dos pruebas con TestClient: éxito y fallo.
  2. Introduzca overrides de dependencias para desacoplar auth/BD y ganar velocidad.
  3. Mantenga la BD limpia con rollbacks; simule externos con respx.
  4. Añada pruebas de contrato OpenAPI para detectar roturas de esquema/ejemplos temprano.
  5. Gradualmente añada benchmarks y carga; integre en CI.

Referencias


Conclusiones

  • Separe sus capas de prueba y primero asegure éxito/fallo de las APIs con pruebas pequeñas.
  • Use overrides de Depends, rollbacks de BD y mocks externos para hacer pruebas rápidas y resilientes.
  • Ponga pruebas de contrato OpenAPI y benchmarks ligeros en CI para detectar regresiones de especificación y rendimiento temprano.
  • El objetivo es el cambio seguro. Escriba dos pruebas hoy y añada overrides de dependencias después.

por greeden

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)