Stack: Django/DRF · Proyecto: TicketFlow Estado: Publicada — Postgres real, nada de SQLite fingiendo Prerrequisito: Lección 33 — Pruebas unitarias bien hechas
Objetivos
- Montar Postgres/Redis reales para tests con contenedores y probar lo que las unitarias no pueden: transacciones, locks, constraints y outbox.
- Aislar los tests entre sí (transacciones, truncado) sin pasos de orden ni estados compartidos.
- 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.
# 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:
@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: SeatUnavailableEste 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).
# 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ía | Test firma | Por qué no unitaria |
|---|---|---|
| Un asiento, un ganador | 2 threads + TX reales | el lock es la infraestructura |
| Outbox tras commit | rollback forzado → 0 eventos | la atomicidad es del motor |
| UniqueConstraint (org, mes) | insert duplicado → IntegrityError | la constraint es del motor |
| Saga reanudable | proceso "muere" a mitad, reanuda | estado en BD, no en memoria |
| Advisory lock de corrida | 2 procesos, 1 entra | pg_locks es infra |
| Consulta del dashboard con GIN | dataset 10k, EXPLAIN sin seq scan | el í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
- ¿Qué cuatro cosas miente SQLite sobre lo construido en las lecciones 10-25 y cuál sería la consecuencia en staging?
@pytest.mark.django_dbvstransaction=True: ¿qué envuelve cada uno y cuándo el segundo es obligatorio?- ¿Por qué el test de "un asiento, un ganador" no es duplicación del unitario de
reservar()? - El criterio de asignación unitaria/integración en una frase, y el error de los dos extremos (todo integrado / todo mockeado).
- ¿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.