Módulo 7 · Pruebas

Lección 34 — Pruebas de integración

Bases de datos reales en contenedores: nada de SQLite fingiendo ser Postgres.

Publicada
En esta lección
  1. Objetivos
  2. 1. Por qué Postgres real (y no SQLite)
  3. 2. Qué se prueba aquí (y qué no)
  4. 3. Aislamiento: cada test su mundo
  5. 4. La frontera práctica unitaria/integración
  6. 5. La lista de integraciones de TicketFlow
  7. Autoevaluación

Stack: Django/DRF · Proyecto: TicketFlow Estado: Publicada — Postgres real, nada de SQLite fingiendo Prerrequisito: Lección 33 — Pruebas unitarias bien hechas


Objetivos

  1. Montar Postgres/Redis reales para tests con contenedores y probar lo que las unitarias no pueden: transacciones, locks, constraints y outbox.
  2. Aislar los tests entre sí (transacciones, truncado) sin pasos de orden ni estados compartidos.
  3. Saber cuándo integración y cuándo unitaria: la frontera práctica que mantiene la suite rápida Y honesta.

1. Por qué Postgres real (y no SQLite)

El SQLite de manage.py test miente en todo lo que esta parte del curso construyó: select_for_update es no-op SQLite (¡el corazón de la 10!); los índices parciales (condition=Q(...), 25) no existen; los tipos JSONField son TEXT con JSON parseado (sin operadores @> ni GIN, 12); las constraints UniqueConstraint(condition=...) se ignoran. Un suite verde en SQLite con rojo en Postgres es la sorpresa de staging (47). La regla del proyecto: integración = el mismo motor, misma versión mayor (Postgres 16 en prod, 16 en test). La versión menor puede variar; la mayor, jamás.

yaml
# docker-compose.test.yml
services:
  db:
    image: postgres:16-alpine
    environment: {POSTGRES_USER: tf, POSTGRES_PASSWORD: tf, POSTGRES_DB: tf_test}
    tmpfs: [/var/lib/postgresql/data]     # en RAM: los tests borran y crean sin I/O de disco
    ports: ["5433:5432"]
  redis:
    image: redis:7-alpine
    ports: ["6380:6379"]

tmpfs para la BD de test: el disco no es el cuello de la suite. El puerto desplazado (5433) evita pisa tu Postgres de desarrollo.

2. Qué se prueba aquí (y qué no)

La integración prueba las costuras con infraestructura real: la transacción con lock de la 10 (dos transacciones concurrentes, una espera), el índice parcial y la constraint del outbox (25), el advisory lock (31), el JSONB con GIN (12), el select_related/prefetch en queries reales (09), la saga reanudable (32). NO prueba: lógica pura (eso es unitaria, 33), ni el flujo HTTP completo (E2E, 35), ni el rendimiento (36). El test firma de esta capa:

python
@pytest.mark.django_db(transaction=True)     # transaction=True: TX reales (no la envoltura de TestCase)
def test_dos_reservas_concurrentes_un_ganador(evento, asientos, dos_conexiones):
    with ThreadPoolExecutor(max_workers=2) as pool:
        f1 = pool.submit(reservar, u1, evento, ["A1"], clock=clock_fijo)
        f2 = pool.submit(reservar, u2, evento, ["A1"], clock=clock_fijo)
    resultados = [f.result() for f in (f1, f2)]
    exitosos = [r for r in resultados if not isinstance(r, Exception)]
    assert len(exitosos) == 1                 # el lock decide; el otro: SeatUnavailable

Este test es IMPOSIBLE en SQLite y en TestCase (que envuelve todo en una TX): necesita transaction=True y conexiones reales. Es la prueba que valida la garantía entera del inventario de TicketFlow: un asiento, un ganador.

3. Aislamiento: cada test su mundo

La jerarquía de aislamiento en pytest-django: @pytest.mark.django_db (transacción por test, rollback al final — rápido, cubre el 90%), transaction=True (TX reales dentro, truncado al final — para lo de arriba), y django_db_blocker para las raras que crean la BD. Reglas: ningún test escribe fuera de su fixture; las migraciones corren UNA vez por sesión (--create-db controla); y los ficheros/Redis de test llevan prefijo (test:cache:{...} — el Redis de tests usa la DB 15, NUNCA la del dev).

python
# conftest.py de integración
@pytest.fixture(scope="session")
def db_url():
    return os.environ["DATABASE_URL"]         # del compose de test: la 27 aplica también aquí

@pytest.fixture(autouse=True)
def _redis_test_db(settings):
    settings.CACHES["default"]["LOCATION"] = "redis://localhost:6380/15"

El orden de ejecución NO existe como concepto: cada test levanta su mundo (baker/fixtures) y lo derrumba con rollback. Si un test "solo funciona si corre después de otro", es un bug del test, no una optimización.

4. La frontera práctica unitaria/integración

El criterio de asignación: ¿la cosa bajo prueba ES la interacción con la infraestructura? (locks, constraints, SQL: integración). ¿O es lógica con infraestructura al lado como detalle? (unitaria con fakes). El servicio reservar() produce DOS tests: el unitario (fakes: reglas y máquina de estados, 3 ms) y el integrado (Postgres real: lock y outbox, 150 ms). No es duplicación: prueban CONTRATOS distintos — "la regla funciona" y "la regla sobrevive a la concurrencia real". La suite entera: unitarias <10 s en cada guardado; integración <2 min en cada push; E2E <10 min en PR (35).

El error de asignación clásico: TODO a integración "porque así es más real" (suite de 30 min, nadie la corre, muere el feedback loop) o TODO a unitarias con mocks (verde en test, rojo en prod: nada probó los locks). El balance del proyecto: 70/25/5 (unit/integración/e2e) con las integraciones concentradas en las garantías que venden el producto (concurrencia, dinero, outbox).

5. La lista de integraciones de TicketFlow

GarantíaTest firmaPor qué no unitaria
Un asiento, un ganador2 threads + TX realesel lock es la infraestructura
Outbox tras commitrollback forzado → 0 eventosla atomicidad es del motor
UniqueConstraint (org, mes)insert duplicado → IntegrityErrorla constraint es del motor
Saga reanudableproceso "muere" a mitad, reanudaestado en BD, no en memoria
Advisory lock de corrida2 procesos, 1 entrapg_locks es infra
Consulta del dashboard con GINdataset 10k, EXPLAIN sin seq scanel índice es del motor

Esta tabla es el contrato de la capa: cada fila es una garantía que el negocio compra (la 09-10-25-31-32 las construyeron) y el test de integración es su póliza. Cuando algo se rompa en producción (47), el primer reflejo: ¿qué fila de esta tabla falló? Y añadir el test que lo habría cazado.


Autoevaluación

  1. ¿Qué cuatro cosas miente SQLite sobre lo construido en las lecciones 10-25 y cuál sería la consecuencia en staging?
  2. @pytest.mark.django_db vs transaction=True: ¿qué envuelve cada uno y cuándo el segundo es obligatorio?
  3. ¿Por qué el test de "un asiento, un ganador" no es duplicación del unitario de reservar()?
  4. El criterio de asignación unitaria/integración en una frase, y el error de los dos extremos (todo integrado / todo mockeado).
  5. ¿Qué relación tiene la tabla de garantías del §5 con un postmortem (47)?

Continúa con los ejercicios. Las solutions.md solo tras intentarlo.