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. Ejercicio 1 — El entorno
  2. Ejercicio 2 — Un asiento, un ganador
  3. Ejercicio 3 — Las costuras
  4. Ejercicio 4 — Aislamiento
  5. Ejercicio 5 — La tabla
  6. Resumen del profesor

Ejercicio 1 — El entorno

  1. El comando completo:
bash
docker compose -f docker-compose.test.yml up -d
DATABASE_URL=postgres://tf:tf@localhost:5433/tf_test \
REDIS_URL=redis://localhost:6380/15 \
pytest tests/integration -v

La 27 aplicada a los tests: el entorno (BD/Redis) entra por variables, cero valores clavados en el código de test.

  1. La mentira de SQLite en el test del índice parcial: el test PASA (el insert del outbox funciona, la query encuentra el evento) pero la garantía no existe: SQLite ignora condition=Q(publicado_at__isnull=True) en el índice — en producción con 1M de eventos publicados, la query del poller hace seq scan. El peor tipo de verde: el que miente. Por eso el check de versión.
  1. El guard del conftest:
python
def pytest_configure(config):
    url = os.environ.get("DATABASE_URL", "")
    if "/tf_test" not in url or "5433" not in url:
        pytest.exit("Integración requiere la BD de test (tf_test en 5433). ¿Compose levantado?", returncode=1)

Fail-fast con el remedio en el mensaje: nadie pierde 20 minutos averiguando que estaba contra la BD del dev.

Ejercicio 2 — Un asiento, un ganador

  1. El test con N=10:
python
@pytest.mark.django_db(transaction=True)
def test_diez_threads_un_ganador(evento, asiento_a1, compradores):
    with ThreadPoolExecutor(max_workers=10) as pool:
        futures = [pool.submit(reservar, u, evento, ["A1"], clock=clock_fijo) for u in compradores]
    resultados = [f.result() for f in futures]
    ganadores = [r for r in resultados if isinstance(r, Reservation)]
    perdedores = [r for r in resultados if isinstance(r, SeatUnavailable)]
    assert len(ganadores) == 1 and len(perdedores) == 9

Detalle fino: los threads comparten la conexión si usas la default de Django — cada worker necesita SU conexión (connections["default"].close() al inicio del worker o pool por hilo). El assert de la suma (1+9=10) verifica que NADIE quedó en limbo.

  1. Línea base esperada: ~400-900 ms con N=10 (locks en espera + PG en tmpfs). La 36 la comparará: bajo carga real con 500 concurrentes, el p99 de reservar() no debe superar 300 ms — si sí, el lock del asiento es el cuello y la partición por evento (55) la conversación.
  1. nowait vs espera: con espera (default), el checkout #2 aguarda hasta que el #1 commitea (algunos ms) y luego ve el asiento ocupado → 409 limpio. Con nowait, el #2 falla al instante con LockNotAvailable → hay que traducir a 409/reintento manual. Para un checkout humano: espera (el usuario percibe "procesando" 50 ms, no un error); para APIs de alta contención (pases de preventa): nowait + reintento cliente. Ambas defendibles; la decisión documentada en el servicio.

Ejercicio 3 — Las costuras

  1. El fallo forzado dentro de la TX:
python
@pytest.mark.django_db(transaction=True)
def test_outbox_atomico_con_la_reserva(evento, asiento_a1):
    with pytest.raises(IntegrityError):
        with transaction.atomic():
            r = reservar(user, evento, ["A1"], clock=clock_fijo)
            OutboxEvent.objects.create(aggregate_id=r.public_ref, aggregate_id_duplicado=None)  # trigger roto
    assert not OutboxEvent.objects.filter(aggregate_id=r.public_ref).exists()

La BD decide (el rollback es del motor); sin mocks del transaction: si alguien mueve el outbox fuera del bloque atómico (28), el test lo caza.

  1. La constraint del ledger: el insert duplicado lanza IntegrityError (el motor lo garantiza) y get_or_create lo convierte en no-op con la fila existente — el test verifica el PAR (motor + servicio): la constraint protege, el servicio no se cae.
  1. La saga reanudada: la pasarela fake registra llamadas; tras reanudar, len(gw.calls) == 2 (la primera timeout, la segunda GET) pero gw.charges con UN cargo — el idempotency-key hace el trabajo. Es la póliza de la garantía que el negocio compra: el dinero no se mueve dos veces ni se pierde.

Ejercicio 4 — Aislamiento

  1. El test pestilente: escribe cache.set("last_reservation", ref) en DB 0; el otro lo lee. Solo: rojo (clave no existe); en suite: verde POR ORDEN de ejecución — el peor tipo de verde, el que desaparece con -p no:cacheprovider o en paralelo. El fix: DB 15 + prefijo test: + cada test construye su clave.
  1. Con pytest-randomly afloran los dependientes: el orden aleatorio convierte "verde por suerte" en "rojo por diseño" — corre la suite con randomización en CI (41) para que el orden-dependencia no sobreviva al merge.
  1. El guard:
python
@pytest.fixture(autouse=True)
def _solo_bd_de_test(request):
    from django.db import connection
    db_name = connection.settings_dict["NAME"]
    if "tf_test" not in str(db_name):
        pytest.fail(f"Test corriendo contra {db_name}: solo tf_test. Revisa DATABASE_URL.")

El autouse corre en cada test: la bomba se desactiva antes de la primera query. La paranoia de infraestructura es barata comparada con "¿por qué mi dev está vacío?".

Ejercicio 5 — La tabla

  1. La tabla honesta suele revelar 2-3 garantías sin póliza: el advisory lock (nadie probó dos procesos) y la saga reanudable (el test de la 32 quedó en unitaria con mocks). La póliza de la más crítica:
python
@pytest.mark.django_db(transaction=True)
def test_saga_reanudable_un_cargo(pasarela_fake, saga_pendiente):
    pasarela_fake.timeout_luego_responde(saga_pendiente.intent_id)
    paso_cobrar(saga_pendiente.saga_id)      # 1er intento: timeout
    paso_cobrar(saga_pendiente.saga_id)      # reanudación
    assert len(pasarela_fake.charges) == 1    # UN cargo, no dos
  1. El presupuesto final: unitarias 8 s (fakes), integración 95 s (34 tests con PG real), e2e 6 min (35, subred local). El test que rompe el presupuesto es casi siempre un test de reglas que arranca BD "porque sí" — migrarlo a unitaria con fakes (33) o aceptar su costo con @pytest.mark.slow y correrlo solo en CI (41).

Resumen del profesor

  • Integración = el motor que da la garantía: Postgres 16, misma versión mayor, tmpfs para la velocidad; SQLite miente en locks, índices parciales y JSONB.
  • Cada test su mundo (fixtures + rollback/truncado + Redis DB 15): el orden no existe y la randomización de CI lo verifica.
  • La tabla de garantías es el contrato entre las lecciones de infraestructura y esta capa: cada garantía vendida tiene su test de costura.