Module 7 · Testing

Lesson 33 — Unit testing done well

pytest, mocks without abusing them and tests that survive refactors.

Published
In this lesson
  1. Exercise 1 — Audit
  2. Exercise 2 — Parametrization
  3. Exercise 3 — Fakes
  4. Exercise 4 — The hunted
  5. Exercise 5 — The convention
  6. Professor's summary

Exercise 1 — Audit

  1. Typical durations of a Django project arriving here: the purchase E2E (2.3 s, legitimate but NOT unit), test_expira_reservas with sleep(1) (real-time test), test_envia_email that boots a fake SMTP (integration in disguise), and the honest unit tests at 2-20 ms. The classification decides the destination: unit → tests/unit with fakes; slow-legitimate → marked @pytest.mark.slow and excluded from pre-commit.
  2. Model findings: test_02_expira red when run alone (dependency on 01); assert "mientras completabas la compra" in str(...) (literal); time.sleep(1) after launching the task (real time); patch("reservations.models.Reservation.objects") (ORM mock with a fake available). Each finding with its fix in the lesson.
  3. The migration: setUp → fixture (12 lines of boilerplate down to 3), and the parametrize replaces 3 near-identical tests. Both runners green: the suite belongs to the project, not the runner.

Exercise 2 — Parametrization

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. The refactors (rename + extract _descuento_volumen) kill none: the test speaks of inputs and outputs, not internal functions. If one died: you were testing the HOW (the name or the internal signature) — the lesson applies to yourself. 3. The readable id=s in the report: test_precio_por[org-10-pct] instead of test_precio_por[3] — the report is documentation.

Exercise 3 — Fakes

  1. The configurable fixture:
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"   # NEW assertion: the world's state

The interaction mock only said "cobrar was called"; the fake allows asserting on the resulting STATE (compensated saga, free seat) — testing effects, not calls. ~30% fewer lines and stronger assertions.

  1. The in-memory repository test: 3-8 ms (vs 120-400 ms for the same test with DB+transactions). The whole unit suite in <10 s is the difference between "I run the tests on every save" and "I run the tests before pushing".
  1. The one legitimate mock:
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)

Here the INTERACTION is the contract (enqueue at commit, 29): the mock verifies exactly what is observable from outside the process — not "how it did it", but "what it said to the world".

Exercise 4 — The hunted

  1. The evidence:
test_malo_con_patch (after refactoring to select_for_update) → FAILED: filter not called
test_reservar_asiento_ocupado_lanza_conflict                 → PASSED (whole suite green in 1.2s)

The HOW test dies with a legitimate and performant refactor (the select_for_update IS 10's improvement!): having it in the suite would have blocked the optimization or forced editing it with no behavior change — the exact cost this lesson seeks to eliminate.

  1. From literal to code: with pytest.raises(DomainError, match="mientras") is also ruled out (the text is for humans); the assertion is err.code == "seat-unavailable" — the text can be rewritten (26) without touching a test, because the CONTRACT is the problem+json's type, not the prose.

Exercise 5 — The convention

python
# conftest.py
@pytest.fixture
def clock_fijo() -> FakeClock:
    """Virtual clock at 2027-03-01 12:00 UTC: time is injected, never slept."""

@pytest.fixture
def evento(db) -> Event:
    """Future event with capacity 100 and base-tier prices."""
    return baker.make("events.Event", starts_at=..., capacity=100)

@pytest.fixture
def comprador(db) -> User:
    """Buyer user with a full profile (no staff permissions)."""

@pytest.fixture
def gateway() -> FakeGateway:
    """Fake gateway approving amounts < 1000; configurable per test."""

@pytest.fixture
def mailbox() -> list:
    """Fake SMTP inbox: assertions over sent emails."""
  1. pytest -k reservar finds the 5: the names are the suite's index. 3. The services coverage with the bare line: it is almost always an error-flow except (no test: add one) or dead code from a retired feature flag (delete the code, don't cover it). Coverage measures what is NOT tested; it doesn't prove what is — 100% with weak assertions is vanity, 90% with behavior assertions is a net.

Professor's summary

  • Unit test = fast, deterministic, observable behavior; the name documents the contract (test_<subject>_<scenario>_<result>).
  • Fake before mock: the fake tests resulting states, the mock only calls — and only the interaction that IS contract.
  • The suite survives refactoring when its assertions speak of inputs/outputs and codes, not internal functions or prose.