# Testing con httpx y pytest — FastAPI

## Configuración base: pytest-asyncio

```ini
# pytest.ini o pyproject.toml [tool.pytest.ini_options]
[tool.pytest.ini_options]
asyncio_mode = "auto"          # Todos los tests async sin @pytest.mark.asyncio
testpaths = ["tests"]
addopts = "--cov=app --cov-report=term-missing --cov-fail-under=80"
```

```toml
# pyproject.toml — dependencias de testing
[tool.poetry.group.test.dependencies]
pytest = "^8.0"
pytest-asyncio = "^0.23"
pytest-cov = "^5.0"
httpx = "^0.27"
factory-boy = "^3.3"
faker = "^24.0"
```

---

## Fixtures base: motor, sesión y cliente

```python
# tests/conftest.py
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import (
    create_async_engine,
    async_sessionmaker,
    AsyncSession,
)
from typing import AsyncGenerator

from app.main import app
from app.db.base import Base
from app.dependencies.database import get_db
from app.dependencies.auth import get_current_user

# URL de BD de prueba — separada de producción/desarrollo
TEST_DATABASE_URL = "postgresql+asyncpg://test:test@localhost/test_app"


@pytest_asyncio.fixture(scope="session")
async def engine_test():
    """Motor async compartido en toda la sesión de testing."""
    engine = create_async_engine(TEST_DATABASE_URL, echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    await engine.dispose()


@pytest_asyncio.fixture
async def db_session(engine_test) -> AsyncGenerator[AsyncSession, None]:
    """Sesión por test con rollback automático — cada test parte de un estado limpio."""
    session_factory = async_sessionmaker(
        engine_test,
        expire_on_commit=False,
        autoflush=False,
    )
    async with session_factory() as session:
        yield session
        await session.rollback()  # Deshace todos los cambios del test


@pytest_asyncio.fixture
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
    """Cliente HTTP sin autenticación — para endpoints públicos."""
    async def _override_db():
        yield db_session

    app.dependency_overrides[get_db] = _override_db
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as ac:
        yield ac
    app.dependency_overrides.clear()


@pytest_asyncio.fixture
async def client_auth(db_session: AsyncSession, usuario_test) -> AsyncGenerator[AsyncClient, None]:
    """Cliente con usuario autenticado inyectado — no requiere token real."""
    async def _override_db():
        yield db_session

    async def _override_user():
        return usuario_test

    app.dependency_overrides[get_db] = _override_db
    app.dependency_overrides[get_current_user] = _override_user
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as ac:
        yield ac
    app.dependency_overrides.clear()
```

---

## Factories con factory-boy async

```python
# tests/factories.py
import factory
from factory.alchemy import SQLAlchemyModelFactory
from faker import Faker
import uuid
from app.models import Usuario, Factura, Cliente

faker = Faker("es_MX")


class UsuarioFactory(SQLAlchemyModelFactory):
    class Meta:
        model = Usuario
        sqlalchemy_session = None          # Se asigna en el fixture
        sqlalchemy_session_persistence = "flush"

    id = factory.LazyFunction(uuid.uuid4)
    email = factory.Sequence(lambda n: f"usuario{n}@empresa.com.mx")
    nombre = factory.LazyFunction(faker.name)
    rol = "lector"
    activo = True
    empresa_id = factory.LazyFunction(uuid.uuid4)


class ClienteFactory(SQLAlchemyModelFactory):
    class Meta:
        model = Cliente
        sqlalchemy_session = None
        sqlalchemy_session_persistence = "flush"

    id = factory.LazyFunction(uuid.uuid4)
    nombre = factory.LazyFunction(faker.company)
    rfc = factory.LazyFunction(lambda: faker.bothify("???######???"))
    pais = "MX"


class FacturaFactory(SQLAlchemyModelFactory):
    class Meta:
        model = Factura
        sqlalchemy_session = None
        sqlalchemy_session_persistence = "flush"

    id = factory.LazyFunction(uuid.uuid4)
    folio = factory.Sequence(lambda n: f"F-{n:04d}")
    estatus = "borrador"
    total = factory.LazyFunction(lambda: float(faker.pricetag().replace("$", "").replace(",", "")))
    cliente = factory.SubFactory(ClienteFactory)


# tests/conftest.py — fixtures usando las factories
@pytest_asyncio.fixture
async def usuario_test(db_session: AsyncSession) -> Usuario:
    UsuarioFactory._meta.sqlalchemy_session = db_session
    usuario = UsuarioFactory(rol="admin")
    await db_session.flush()
    return usuario


@pytest_asyncio.fixture
async def factura_borrador(db_session: AsyncSession, usuario_test: Usuario) -> Factura:
    ClienteFactory._meta.sqlalchemy_session = db_session
    FacturaFactory._meta.sqlalchemy_session = db_session
    factura = FacturaFactory(empresa_id=usuario_test.empresa_id)
    await db_session.flush()
    return factura
```

---

## Tests de endpoints: status codes, schemas y headers

```python
# tests/test_facturas.py
import pytest
from httpx import AsyncClient


async def test_listar_facturas_retorna_200(client_auth: AsyncClient, factura_borrador):
    respuesta = await client_auth.get("/api/v1/facturas")
    assert respuesta.status_code == 200
    data = respuesta.json()
    assert "items" in data
    assert "total" in data
    assert isinstance(data["items"], list)


async def test_crear_factura_retorna_201_y_schema(client_auth: AsyncClient):
    payload = {
        "folio": "F-TEST-001",
        "fecha": "2024-01-15",
        "cliente_id": "some-uuid",
        "estatus": "borrador",
    }
    respuesta = await client_auth.post("/api/v1/facturas", json=payload)
    assert respuesta.status_code == 201
    data = respuesta.json()
    # Verificar campos requeridos del schema de respuesta
    assert "id" in data
    assert data["folio"] == "F-TEST-001"
    assert data["estatus"] == "borrador"
    # Verificar que no expone campos internos
    assert "empresa_id" not in data
    assert "hash_interno" not in data


async def test_obtener_factura_inexistente_retorna_404(client_auth: AsyncClient):
    respuesta = await client_auth.get("/api/v1/facturas/no-existe")
    assert respuesta.status_code == 404
    assert "detail" in respuesta.json()


async def test_headers_de_respuesta(client_auth: AsyncClient):
    """Verificar headers de seguridad y trazabilidad."""
    respuesta = await client_auth.get("/api/v1/facturas")
    assert "X-Request-ID" in respuesta.headers
    # El middleware de timing debe agregar este header
    assert "X-Response-Time" in respuesta.headers
```

---

## Tests de autenticación: token válido, inválido y expirado

```python
# tests/test_autenticacion.py
import pytest
from httpx import AsyncClient
from datetime import datetime, timedelta
from jose import jwt
from app.core.config import settings


def crear_token_de_prueba(
    sub: str,
    rol: str = "lector",
    expire_delta: timedelta = timedelta(minutes=30),
) -> str:
    expire = datetime.utcnow() + expire_delta
    return jwt.encode(
        {"sub": sub, "rol": rol, "exp": expire},
        settings.SECRET_KEY,
        algorithm="HS256",
    )


async def test_endpoint_protegido_sin_token(client: AsyncClient):
    """Sin token debe retornar 401 o 403."""
    respuesta = await client.get("/api/v1/facturas")
    assert respuesta.status_code in (401, 403)


async def test_endpoint_protegido_token_invalido(client: AsyncClient):
    respuesta = await client.get(
        "/api/v1/facturas",
        headers={"Authorization": "Bearer token-invalido-aqui"},
    )
    assert respuesta.status_code == 401
    assert "inválido" in respuesta.json()["detail"].lower()


async def test_endpoint_protegido_token_expirado(client: AsyncClient, usuario_test):
    token_expirado = crear_token_de_prueba(
        sub=str(usuario_test.id),
        expire_delta=timedelta(minutes=-5),  # Expirado hace 5 minutos
    )
    respuesta = await client.get(
        "/api/v1/facturas",
        headers={"Authorization": f"Bearer {token_expirado}"},
    )
    assert respuesta.status_code == 401


async def test_token_valido_permite_acceso(client: AsyncClient, usuario_test, db_session):
    """Test de integración completo con token JWT real."""
    # Sobrescribir el override de get_db pero NO el de get_current_user
    from app.dependencies.database import get_db
    app.dependency_overrides[get_db] = lambda: db_session  # type: ignore

    token = crear_token_de_prueba(sub=str(usuario_test.id), rol=usuario_test.rol)
    respuesta = await client.get(
        "/api/v1/facturas",
        headers={"Authorization": f"Bearer {token}"},
    )
    assert respuesta.status_code == 200
```

---

## Tests de WebSocket

```python
# tests/test_websocket.py
import pytest
import json
from httpx import AsyncClient
from httpx_ws import aconnect_ws


async def test_chat_enviar_y_recibir_mensaje(client: AsyncClient):
    """Conectar, enviar un mensaje y verificar que llega de vuelta."""
    async with aconnect_ws("/chat/sala-test/usuario-1", client) as ws:
        await ws.send_text(json.dumps({"contenido": "Hola sala"}))
        respuesta = await ws.receive_text()
        data = json.loads(respuesta)
        assert data["tipo"] == "mensaje"
        assert data["contenido"] == "Hola sala"
        assert data["autor"] == "usuario-1"


async def test_websocket_sin_autenticacion_rechaza_conexion(client: AsyncClient):
    """WebSockets protegidos deben rechazar conexiones sin token."""
    # En endpoints WS con autenticación, el handshake debe fallar
    with pytest.raises(Exception):  # WebSocketUpgradeError o similar
        async with aconnect_ws("/chat/privado/sala-1/usuario-x", client) as ws:
            pass


async def test_heartbeat_responde_a_ping(client: AsyncClient):
    """Verificar que el servidor responde a pings del cliente."""
    async with aconnect_ws("/chat/sala-ping/usuario-ping", client) as ws:
        await ws.send_text('{"tipo":"ping"}')
        # El servidor debe responder o al menos no desconectar
        # Dependiendo del protocolo — ajustar según implementación
```

---

## Override de dependencias: mock de BD y autenticación

```python
# tests/helpers/overrides.py
from contextlib import asynccontextmanager
from app.main import app
from app.dependencies.database import get_db
from app.dependencies.auth import get_current_user


@asynccontextmanager
async def simular_usuario(usuario, db_session):
    """Context manager para simular un usuario autenticado en tests puntuales."""
    app.dependency_overrides[get_db] = lambda: db_session  # type: ignore
    app.dependency_overrides[get_current_user] = lambda: usuario
    try:
        yield
    finally:
        app.dependency_overrides.clear()


# Uso en un test específico
async def test_solo_admin_puede_eliminar(
    client: AsyncClient,
    db_session,
    usuario_test,
    factura_borrador,
):
    # Probar con rol insuficiente
    usuario_test.rol = "lector"
    async with simular_usuario(usuario_test, db_session):
        respuesta = await client.delete(f"/api/v1/facturas/{factura_borrador.id}")
    assert respuesta.status_code == 403

    # Probar con rol correcto
    usuario_test.rol = "admin"
    async with simular_usuario(usuario_test, db_session):
        respuesta = await client.delete(f"/api/v1/facturas/{factura_borrador.id}")
    assert respuesta.status_code == 204
```

---

## Coverage con pytest-cov en proyectos async

```ini
# .coveragerc
[run]
source = app
omit =
    app/db/migrations/*
    app/core/config.py       # Configuración — no hay lógica que testear
    */__init__.py

[report]
exclude_lines =
    pragma: no cover
    if TYPE_CHECKING:
    @abstractmethod
    raise NotImplementedError
    \.\.\.$              # Métodos abstractos con ...
```

```bash
# Ejecutar tests con reporte de coverage
pytest --cov=app --cov-report=term-missing --cov-report=html:coverage_html

# Verificar umbral mínimo (falla si coverage < 80%)
pytest --cov=app --cov-fail-under=80
```

### Marcar código no testeable

```python
async def _tarea_background_critica(datos: dict) -> None:
    try:
        await procesar(datos)
    except SomeRareException:  # pragma: no cover
        # Error que no puede reproducirse en tests unitarios
        logger.critical("Error crítico irrecuperable", exc_info=True)
```
