Module 7 · Testing

Lesson 34 — Integration testing

Real databases in containers: no SQLite pretending to be Postgres.

Published
In this lesson
  1. Exercise 1 — The environment
  2. Exercise 2 — One seat, one winner
  3. Exercise 3 — The seams
  4. Exercise 4 — Isolation
  5. Exercise 5 — The table
  6. Professor's summary

Exercise 1 — The environment

  1. The complete command:
bash
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 -v

27 applied to tests: the environment (DB/Redis) enters by variables, zero values nailed into test code.

  1. 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.
  1. The conftest guard:
python
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

  1. The N=10 test:
python
@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) == 9

Fine 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.

  1. 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.
  1. nowait vs wait: with wait (default), checkout #2 waits until #1 commits (a few ms) and then sees the seat taken → clean 409. With nowait, #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

  1. The forced failure inside the TX:
python
@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.

  1. The ledger's constraint: the duplicate insert raises IntegrityError (the engine guarantees it) and get_or_create turns it into a no-op with the existing row — the test verifies the PAIR (engine + service): the constraint protects, the service doesn't crash.
  1. The resumed saga: the fake gateway records calls; after resuming, len(gw.calls) == 2 (first timeout, then GET) but gw.charges has 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

  1. 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:cacheprovider or in parallel. The fix: DB 15 + test: prefix + every test builds its own key.
  1. With pytest-randomly the 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.
  1. The guard:
python
@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

  1. 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:
python
@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
  1. 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.slow and 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.