Ejercicio 1 — El entorno
- El comando completo:
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 -vLa 27 aplicada a los tests: el entorno (BD/Redis) entra por variables, cero valores clavados en el código de test.
- 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.
- El guard del conftest:
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
- El test con N=10:
@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) == 9Detalle 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.
- 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.
nowaitvs espera: con espera (default), el checkout #2 aguarda hasta que el #1 commitea (algunos ms) y luego ve el asiento ocupado → 409 limpio. Connowait, 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
- El fallo forzado dentro de la TX:
@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.
- La constraint del ledger: el insert duplicado lanza
IntegrityError(el motor lo garantiza) yget_or_createlo convierte en no-op con la fila existente — el test verifica el PAR (motor + servicio): la constraint protege, el servicio no se cae.
- La saga reanudada: la pasarela fake registra llamadas; tras reanudar,
len(gw.calls) == 2(la primera timeout, la segunda GET) perogw.chargescon 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
- 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:cacheprovidero en paralelo. El fix: DB 15 + prefijotest:+ cada test construye su clave.
- Con
pytest-randomlyafloran 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.
- El guard:
@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
- 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:
@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- 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.slowy 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.