green snake
Photo by Pixabay on Pexels.com
目次

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 pytest y combínalo con TestClient de 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

  1. 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.
  2. 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.
  3. 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_*.py o *_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)

  1. Instala pytest y escribe solo un unit test
    Empieza con una función pura (como cálculo de precio).

  2. Escribe un API test con TestClient
    Un endpoint simple como /health funciona bien.

  3. Añade una BD de test + dependency override para API tests que dependan de BD
    Prueba CRUD usando SQLite + rollback por transacción.

  4. Aumenta unit tests de la capa de servicios usando repositorios mock
    Protege reglas de negocio importantes sin BD ni FastAPI.

  5. Añade tests de auth/authz
    Login éxito/fallo, token requerido, permiso denegado.

  6. Conéctalo a CI para que los tests corran automáticamente
    Como mínimo, ejecuta tests antes de mergear a main.

  7. 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.


por greeden

Deja una respuesta

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

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