Exercise 1 — The environment
- The complete command:
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 -v27 applied to tests: the environment (DB/Redis) enters by variables, zero values nailed into test code.
- SQLite's lie in the partial-index test: the test PASSES (the outbox insert works, the query finds the event) but the guarantee doesn't exist: SQLite ignores
condition=Q(publicado_at__isnull=True)on the index — in production with 1M published events, the poller's query does a seq scan. The worst kind of green: the lying one. Hence the version check.
- The conftest guard:
def pytest_configure(config):
url = os.environ.get("DATABASE_URL", "")
if "/tf_test" not in url or "5433" not in url:
pytest.exit("Integration requires the test DB (tf_test on 5433). Is the compose up?", returncode=1)Fail-fast with the remedy in the message: nobody spends 20 minutes figuring out they were against the dev DB.
Exercise 2 — One seat, one winner
- The N=10 test:
@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) == 9Fine detail: threads share the connection if you use Django's default — each worker needs ITS connection (connections["default"].close() at worker start or a per-thread pool). The sum assert (1+9=10) verifies NOBODY ended up in limbo.
- Expected baseline: ~400-900 ms with N=10 (locks waiting + PG on tmpfs). 36 will compare it: under real load with 500 concurrent, reservar()'s p99 must not exceed 300 ms — if it does, the seat lock is the bottleneck and partitioning by event (55) is the conversation.
nowaitvs wait: with wait (default), checkout #2 waits until #1 commits (a few ms) and then sees the seat taken → clean 409. Withnowait, #2 fails instantly with LockNotAvailable → you must translate it to a 409/manual retry. For a human checkout: wait (the user perceives "processing" for 50 ms, not an error); for high-contention APIs (presale passes): nowait + client retry. Both defensible; the decision documented in the service.
Exercise 3 — The seams
- The forced failure inside the 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) # broken trigger
assert not OutboxEvent.objects.filter(aggregate_id=r.public_ref).exists()The DB decides (the rollback belongs to the engine); no transaction mocks: if anyone moves the outbox outside the atomic block (28), this test catches it.
- The ledger's constraint: the duplicate insert raises
IntegrityError(the engine guarantees it) andget_or_createturns it into a no-op with the existing row — the test verifies the PAIR (engine + service): the constraint protects, the service doesn't crash.
- The resumed saga: the fake gateway records calls; after resuming,
len(gw.calls) == 2(first timeout, then GET) butgw.chargeshas ONE charge — the idempotency-key does the work. It is the policy for the guarantee the business buys: money neither moves twice nor gets lost.
Exercise 4 — Isolation
- The smelly test: writes
cache.set("last_reservation", ref)to DB 0; the other reads it. Alone: red (key missing); in the suite: green BY ORDER — the worst kind of green, the one that vanishes with-p no:cacheprovideror in parallel. The fix: DB 15 +test:prefix + every test builds its own key.
- With
pytest-randomlythe dependents surface: random order turns "green by luck" into "red by design" — run the suite with randomization in CI (41) so order-dependence doesn't survive the merge.
- The 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 running against {db_name}: only tf_test. Check DATABASE_URL.")The autouse runs on every test: the bomb is defused before the first query. Infrastructure paranoia is cheap compared to "why is my dev DB empty?".
Exercise 5 — The table
- The honest table usually reveals 2-3 guarantees without a policy: the advisory lock (nobody tested two processes) and the resumable saga (32's test stayed a mocked unit). The most critical's policy:
@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) # 1st attempt: timeout
paso_cobrar(saga_pendiente.saga_id) # resume
assert len(pasarela_fake.charges) == 1 # ONE charge, not two- The final budget: units 8 s (fakes), integration 95 s (34 tests with real PG), e2e 6 min (35, local subnet). The test breaking the budget is almost always a rules test that boots a DB "just because" — migrate it to unit with fakes (33) or accept its cost with
@pytest.mark.slowand run it only in CI (41).
Professor's summary
- Integration = the engine granting the guarantee: Postgres 16, same major version, tmpfs for speed; SQLite lies about locks, partial indexes and JSONB.
- Every test its own world (fixtures + rollback/truncation + Redis DB 15): order doesn't exist and CI's randomization verifies it.
- The guarantees table is the contract between the infrastructure lessons and this layer: every sold guarantee has its seam test.