Módulo 7 · Pruebas

Lección 33 — Pruebas unitarias bien hechas

pytest, mocks sin abusar y pruebas que sobreviven al refactor.

Publicada
En esta lección
  1. Ejercicio 1 — Auditoría
  2. Ejercicio 2 — Parametrización
  3. Ejercicio 3 — Fakes
  4. Ejercicio 4 — Los cazados
  5. Ejercicio 5 — La convención
  6. Resumen del profesor

Ejercicio 1 — Auditoría

  1. Durations típicas de un proyecto Django al llegar aquí: la E2E de compra (2.3 s, legítima pero NO unitaria), test_expira_reservas con sleep(1) (test del tiempo real), test_envia_email que arranca SMTP fake (integración disfrazada), y las unitarias honestas en 2-20 ms. La clasificación decide el destino: unitaria → tests/unit con fakes; lenta legítima → marcada @pytest.mark.slow y excluida del pre-commit.
  2. Hallazgos modelo: test_02_expira rojo si corre solo (dependencia del 01); assert "mientras completabas la compra" in str(...) (literal); time.sleep(1) tras lanzar la tarea (tiempo real); patch("reservations.models.Reservation.objects") (mock de ORM con fake disponible). Cada hallazgo con su fix en la lección.
  3. La migración: setUp → fixture (el boilerplate de 12 líneas a 3), y el parametrize reemplaza 3 tests casi-idénticos. Ambos runners en verde: la suite no es del runner, es del proyecto.

Ejercicio 2 — Parametrización

python
@pytest.mark.parametrize(
    "seats,total",
    [pytest.param(1, 5000, id="un-asiento"),
     pytest.param(3, 13500, id="tres-descuento-10"),
     pytest.param(10, 40000, id="diez-descuento-20"),
     pytest.param(0, None, id="cero-lanza", marks=pytest.mark.xfail(raises=DomainError)),
     pytest.param(-1, None, id="negativo-lanza", marks=pytest.mark.xfail(raises=DomainError)),
     pytest.param(1000, 3_500_000, id="limite-capacidad")],
)
def test_precio_por(seats, total, evento):
    assert precio_por(evento, seats) == total
  1. Los refactors (renombrar + extraer _descuento_volumen) NO matan ninguno: el test habla de entradas y salidas, no de funciones internas. Si murió alguno: probabas el CÓMO (el nombre o la firma interna) — la lección se aplica a ti mismo. 3. Los id= legibles en el reporte: test_precio_por[org-10-pct] en vez de test_precio_por[3] — el reporte es documentación.

Ejercicio 3 — Fakes

  1. El fixture configurable:
python
@pytest.fixture
def gateway_rechaza():
    gw = FakeGateway()
    gw.declina_todo = True
    return gw

def test_pago_rechazado_compensa(reserva, gateway_rechaza, clock_fijo):
    with pytest.raises(PaymentDeclined):
        confirmar_compra(reserva, gateway=gateway_rechaza, clock=clock_fijo)
    assert saga.refresh_from_db() or saga.estado == "COMPENSATED"   # assertion NUEVA: el estado del mundo

El mock de interacción solo decía "cobrar fue llamado"; el fake permite assertion sobre el ESTADO resultante (saga compensada, asiento libre) — probar efectos, no llamadas. ~30% menos líneas y assertions más fuertes.

  1. El test con repositorio en memoria: 3-8 ms (vs 120-400 ms del mismo test con BD+transacciones). La suite entera de unitarias en <10 s es la diferencia entre "corro los tests a cada guardado" y "corro los tests antes de pushear".
  1. El único mock legítimo:
python
def test_reserva_encola_email_al_commit(reserva, mocker):
    tarea = mocker.patch("reservations.tasks.enviar_email_confirmacion.delay")
    with captureOnCommitCallbacks(execute=True):
        reservar(...)
    tarea.assert_called_once_with(reserva.public_ref)

Aquí la INTERACCIÓN es el contrato (encolar al commit, la 29): el mock verifica exactamente lo observable desde fuera del proceso — no "cómo lo hizo", sino "qué dijo al mundo".

Ejercicio 4 — Los cazados

  1. La evidencia:
test_malo_con_patch (tras refactor a select_for_update) → FAILED: filter not called
test_reservar_asiento_ocupado_lanza_conflict            → PASSED (1.2s suite completa en verde)

El test del CÓMO muere con un refactor legítimo y de rendimiento (¡el select_for_update ES la mejora de la 10!): haberlo tenido en la suite habría bloqueado la optimización o forzado su edición sin cambio de comportamiento — el costo exacto que busca eliminar esta lección.

  1. Del literal al código: with pytest.raises(DomainError, match="mientras") queda descartado también (el texto es para humanos); la assertion es err.code == "seat-unavailable" — el texto puede reescribirse (26) sin tocar un test, porque el CONTRATO es el type del problem+json, no la prosa.

Ejercicio 5 — La convención

python
# conftest.py
@pytest.fixture
def clock_fijo() -> FakeClock:
    """Reloj virtual a 2027-03-01 12:00 UTC: el tiempo es inyectado, nunca dormido."""

@pytest.fixture
def evento(db) -> Event:
    """Evento futuro con capacidad 100 y precios de la tarifa base."""
    return baker.make("events.Event", starts_at=..., capacity=100)

@pytest.fixture
def comprador(db) -> User:
    """Usuario buyer con perfil completo (sin permisos de staff)."""

@pytest.fixture
def gateway() -> FakeGateway:
    """Pasarela fake que aprueba importes < 1000; configurable por test."""

@pytest.fixture
def mailbox() -> list:
    """Bandeja fake de SMTP: assertions sobre emails enviados."""
  1. pytest -k reservar encuentra los 5: los nombres son el índice de la suite. 3. La cobertura de servicios con la línea pelada: casi siempre es el except de un flujo de error (sin test: añádelo) o código muerto de un feature flag retirado (borra el código, no lo cubras). La cobertura mide lo que NO está probado; no prueba lo que está — el 100% con assertions débiles es vanity, el 90% con assertions de comportamiento es red.

Resumen del profesor

  • Unitaria = rápida, determinista, de comportamiento observable; el nombre documenta el contrato (test_<sujeto>_<escenario>_<resultado>).
  • Fake antes que mock: el fake prueba estados resultantes, el mock solo llamadas — y solo la interacción que ES contrato.
  • La suite sobrevive al refactor cuando sus assertions hablan de entradas/salidas y códigos, no de funciones internas ni prosa.