Ejercicio 1 — El catálogo
- El catálogo de TicketFlow (8 historias):
| Historia | Flujo | Dobles |
|---|---|---|
| Compra feliz | buscar→reservar→pagar→confirmar | pasarela sandbox, SMTP fake |
| Declinación → compensación | reservar→pagar(fail)→refund | pasarela sandbox |
| Un asiento, un ganador | 2 clientes HTTP simultáneos | ninguno (la BD es la juez) |
| Expiración durante el pago | reservar→(clock avanza)→pagar | FakeClock en el entorno |
| Webhook de pasarela entrante | reservar→webhook firmado (17)→confirmado | pasarela sandbox |
| Saga UNKNOWN recuperada | cobro timeout→reconciliar (32) | pasarela fake configurable |
| Export RGPD | pedir→job (31)→descargar | SMTP fake |
| Registro + MFA (20) | registrar→TOTP→login→reset | SMTP fake, secret de TOTP |
- Los reasignados: "email sin @ → 400" (unitaria del serializer), "staff no puede reservar de otro org" (integración de permissions), "el refresh token rota" (integración de la 18), "el índice parcial funciona" (integración, 34), "el limit de rate limiting dispara" (integración de la 23). Ninguno necesita el sistema entero: subirlos a E2E es pagar 20 s por lo que cuesta 3 ms.
pytest -m e2e --collect-only→ 8 tests × ~20 s = 160 s ≈ 3 min: dentro del presupuesto de PR. El catálogo es una lista viva: nueva historia crítica entra, y ALGO SALE (el presupuesto es duro).
Ejercicio 2 — Las historias
- La trágica:
def test_declinacion_compensa(client_api, pasarela_declina, mailbox):
ref = reservar_a1(client_api)
r = pagar(client_api, ref)
assert r.status_code == 402 and r.json()["type"].endswith("payment-declined")
drenar_cola()
assert estado_de(ref) == "CANCELLED"
assert asiento_libre("A1")
assert len(mailbox) == 1 # email de fallo
assert not webhook_saliente_enviado() # el tercero no ve la venta fallidaEl assert del webhook NO enviado es el que convierte esta historia en póliza: la saga (32) decide quién notifica; el test lo fija.
- La disputa:
def test_un_asiento_un_ganador(client_a, client_b, evento):
with ThreadPoolExecutor(2) as pool:
f1 = pool.submit(reservar, client_a, evento, ["A1"])
f2 = pool.submit(reservar, client_b, evento, ["A1"])
refs = [f.result() for f in (f1, f2)] # uno 201, otro 409
pagar_solo_el_ganador(...)
drenar_cola()
assert suma_de_ingresos == precio_evento # y no 2×Con los dos clientes HTTP REALES (la concurrencia del pool, la 34 la probó a nivel de servicio; aquí, a nivel de historia): la garantía entera "no vendemos el mismo asiento dos veces" queda bajo contrato.
Ejercicio 3 — El flaky
- La cura:
# antes: time.sleep(1); assert saga.estado == "CONFIRMED" # rojo en CI lento
# después:
wait_for(lambda: saga.refresh_from_db() or saga.estado == "CONFIRMED",
timeout=5, msg=f"saga {saga.saga_id} no confirmó")--count=10 en verde estable: el sleep fallaba cuando el worker tardaba >1 s (CI compartido); el wait_for espera LO NECESARIO con deadline — 10/10 verdes.
- El dump que diagnostica:
AssertionError: saga 8a2f no confirmó: dump={
"saga": {"estado": "PENDING", "intentos": 1},
"cola_pendiente": ["cobrar(int_9f2c)"],
"outbox_ultimos": ["ReservationCreated", "PaymentPending"]
}El dump dice el diagnosis: la tarea cobrar sigue PENDIENTE en la cola eager (no se drenó — el on_commit no disparó) o falló. Sin abrir el IDE: el test es su propio postmortem (47).
- La regla del CONTRIBUTING: cuarentena máx 2, issue obligatorio con causa, caducidad 2 semanas (o se cura o se borra — un flaky eterno es ruido con flag). El re-run automático en CI marca el reporte ("passed after retry") para que la estadística no mienta.
Ejercicio 4 — El contrato
- El break del contrato: rojo en
test_el_front_puede[POST /api/v1/reservations]en <5 s (es unitaria de shape, rápida). La compatibilidad dual (14):
"total": "int|obj{amount,currency}" # el consumidor acepta ambos durante el sprint de transicióny en el provider, la respuesta dual con header X-API-Version o el contenido negociado — al terminar el sprint, el test del front exige el formato nuevo (el contrato tiene versiones como la API).
- El contrato de errores:
"errores": {"4xx": {"type": "uri", "title": "str", "status": "==HTTP", "detail": "str?"},
"409-seat": {"type": "…/seat-unavailable", "extra": ["conflicting_seats"]}}El 409 del disputa lo valida: el front programa contra type+conflicting_seats (26) sin parsear prosa — el contrato de errores es el que más peleas de integración evita.
Ejercicio 5 — La sala de máquinas
- Los números del reset: truncado de 12 tablas + flush de Redis DB 15 ≈ 400 ms; compose down/up ≈ 25-40 s. Con 8 tests: reset-in-place ahorra ~4 min por corrida — y en CI (donde corre 30×/día) son 2 horas de cómputo diarias. El healthcheck del compose (
pg_isready,redis-cli ping) evita el arranque en falso del app container.
- El fixture de auth:
@pytest.fixture
def comprador_autenticado(db):
user = baker.make(User, email="buyer@tf.test")
token = str(RefreshToken.for_user(user).access_token)
return ApiClient(HTTP_AUTHORIZATION=f"Bearer {token}")- La métrica: 8 tests × 20 corridas = 160 ejecuciones, 2 fallos-no-causa (el sleep del ejercicio 3 antes de la cura) → flaky_rate 1.25% → tras la cura: 0.6%. Bajo 1%: la suite es creíble; el día que supere 2%, el equipo deja de confiar y la pirámide se hunde — la métrica es la alarma temprana de la sala.
Resumen del profesor
- E2E = la historia completa por HTTP con infraestructura real y dobles SOLO en la frontera del negocio; el catálogo es ≤8 historias, duro.
- El flaky se cura con espera activa + dump de diagnóstico; la cuarentena con caducidad evita el re-relear cultural.
- El contrato lo define el CONSUMIDOR: los breaks se ven en segundos en el pipeline, no en el teléfono del comprador.