Stack: Django/DRF · Proyecto: TicketFlow Estado: Publicada — apertura del módulo de pruebas Prerrequisito: Lección 32 — Consistencia eventual y saga
Objetivos
- Escribir pruebas unitarias que sobreviven al refactor: probar el QUÉ (contrato), no el CÓMO (estructura interna).
- Dominar pytest con fixtures y parametrización en Django sin renunciar al ecosistema DRF.
- Usar dobles de prueba (fake/mock/stub) con criterio: cuando hay fake disponible, no se maquea lo que ya existe.
1. Qué hace buena a una unitaria
Tres propiedades (F.I.R.S.T. reducido): rápida (milisegundos: sin BD, sin red, sin reloj real), determinista (misma entrada, misma salida — por eso el Clock inyectado de la 24) y probar comportamiento observable ("reservar con asiento ocupado lanza SeatUnavailable"), no estructura ("que llama a Seat.objects.filter dos veces" — ese test muere con el primer refactor legítimo). La suite de TicketFlow corre en <10 s: si tu unitaria tarda 500 ms, es una integración disfrazada y va al foso de los tests lentos.
# tests/unit/test_resumen.py — pytest, puro, sin Django
def test_resumen_incluye_expiracion(reserva, clock_fijo):
r = resumen(reserva, clock=clock_fijo)
assert r.expires_in_seconds == 600La prueba de calidad de una unitaria: ¿falla cuando rompes el comportamiento y pasa cuando refactorizas? Si falla al renombrar un método privado (sin cambiar comportamiento), probabas el CÓMO.
2. pytest en Django: la instalación mínima
pytest + pytest-django: settings por env var, fixtures en lugar de setUp, y la potencia real en la parametrización:
# pytest.ini / pyproject
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "ticketflow.settings"
python_files = ["test_*.py"]
# conftest.py — fixtures compartidas, la base de la suite
import pytest
from model_bakery import baker
@pytest.fixture
def clock_fijo():
return FakeClock(datetime(2027, 3, 1, 12, 0, tzinfo=dt.UTC))
@pytest.fixture
def evento(db): # db: habilita acceso a BD (transacciones de test)
return baker.make("events.Event", starts_at=..., capacity=100)
@pytest.mark.parametrize("seats,total", [(1, 5000), (3, 13500), (10, 40000)])
def test_total_con_descuento_por_volumen(seats, total, evento, comprador):
assert calcular_total(evento, seats) == totalLa parametrización convierte una tabla de casos en un test: 3 casos = 3 ejecuciones con reporte individual — el informe te dice CUÁL fila falló, no "falló la función". model_bakery para la masa de datos (los fixtures verbosos Event.objects.create(venue=...,...)) con los campos que SÍ importan explícitos y el resto por defecto.
3. Dobles de prueba: la taxonomía y la regla
La jerarquía (de mejor a peor): objeto real en memoria (una lista) > fake (implementación simple del Protocol: InMemoryReservationRepository, FakeClock, FakeGateway de la 24) > stub (devuelve valores fijos) > mock (verifica INTERACCIONES: "se llamó a X con Y"). La regla del curso: maquea fronteras, finge estados — el gateway (red, dinero) se maquea o finge SIEMPRE; el repositorio ya TIENE fake (25), úsalo en vez de patch("...Reservation.objects"); y el mock de interacción solo para efectos externos sin valor de retorno que quieras verificar (el email sí se envió: assertion sobre el fake de SMTP, no sobre "send_mail fue llamado con...").
def test_pago_rechazado_compensa(gateway_rechaza, mailbox):
with pytest.raises(PaymentDeclined):
confirmar_compra(reserva, gateway=gateway_rechaza, clock=clock_fijo)
assert reserva.refresh_from_db() or reserva.estado == "CANCELLED" # comportamiento
# NO: gateway.cobrar.assert_called_once_with(...) # interacción: solo si el efecto es el contratoEl exceso de mocks es el olor #1 (Sullivan): una suite con 30 mocks por test prueba TU IMAGINACIÓN sobre las llamadas internas, no el sistema. El test de la 28 que caza el outbox-después-del-commit es posible porque NO maqueamos la transacción: usamos la real.
4. Los anti-patrones que matan suites
Los cuatro jinetes: (1) el test del refactor: assertion de llamadas internas (arriba); (2) el test dependiente: test_02_expira asume que test_01_reserva corrió y dejó datos — cada test construye SU mundo (fixtures) y corre solo (pytest test_x.py::test_02 en verde); (3) el test adivino: assertions sobre mensajes de error literales completos (assert "El asiento A12 fue reservado por otra persona mientras" in str(err)) — assertion sobre err.code/tipo, el texto es para humanos y cambia; (4) el test del tiempo real: time.sleep(1) esperando un worker — el tiempo es inyectado (Clock/29), nunca dormido.
Y el quinto: snapshot mental: actualizar assertions "porque sí" cuando un test falla tras un cambio legítimo (blind update). Cada assertion que cambias a mano debe pasar por tu cabeza: ¿era el comportamiento viejo o el nuevo? Si no lo sabes, el test no era de comportamiento.
5. La estructura de la suite de TicketFlow
tests/
unit/ # servicios puros con fakes: <10 s, corre en cada guardado (33)
integration/ # Postgres real en contenedor, transacciones/locks/outbox (34)
e2e/ # HTTP completo, <20 tests, la red de seguridad (35)
contract/ # shapes y problem+json (15/26/28)Convenciones: nombre test_<sujeto>_<escenario>_<resultado> (test_reservar_asiento_ocupado_lanza_conflict); un assert-compuesto por concepto; sin lógica condicional dentro del test (un if en un test es dos tests sin nombre — usa parametrización). Cobertura: la métrica que IMPORTA es la de los servicios de dominio (>90%), no el vanity total: el settings.py "cubierto" al importar no prueba nada.
Autoevaluación
- Las tres propiedades de una unitaria: ¿qué test de tu suite las incumple hoy y cómo lo remiendas?
- ¿Qué convierte a un test en "test del refactor" y cuál es la regla de oro del QUÉ vs CÓMO?
- Taxonomía de dobles: ¿cuándo fake, cuándo stub, cuándo mock? ¿Por qué NO maquear
Reservation.objectssi ya existeInMemoryReservationRepository? - El test dependiente: ¿qué rompe (paralelización, orden, CI) y qué convención lo elimina?
- ¿Por qué la cobertura de servicios de dominio >90% importa y la cobertura total no?
Continúa con los ejercicios. Las solutions.md solo tras intentarlo.