# Patterns — Testing Backend Services ## Contents - Testcontainers setup (pytest + Postgres) - Per-test isolation via transaction rollback - Polling instead of sleeping - Frozen clock and seeded randomness - Data factories - Contract test sketch - Gotchas ## Testcontainers setup (pytest + Postgres) ```python # pip install testcontainers[postgres] pytest import pytest from testcontainers.postgres import PostgresContainer @pytest.fixture(scope="session") def pg_url(): # One container per session: startup (~2-5s) is paid once, not per test. with PostgresContainer("postgres:17") as pg: yield pg.get_connection_url() ``` ## Per-test isolation via transaction rollback ```python @pytest.fixture def db(pg_url): engine = get_engine(pg_url) conn = engine.connect() tx = conn.begin() yield conn # test runs inside the transaction tx.rollback() # everything the test wrote vanishes conn.close() ``` Escape hatch: code under test that commits internally needs per-test schemas (`CREATE SCHEMA test_{uuid}`) instead of rollback. ## Polling instead of sleeping ```python import time def wait_until(predicate, timeout=5.0, interval=0.05): # 50ms interval: fast feedback without hammering; 5s cap: fail loudly. deadline = time.monotonic() + timeout while time.monotonic() < deadline: if predicate(): return time.sleep(interval) raise AssertionError(f"condition not met within {timeout}s") publish(event) wait_until(lambda: repo.count(status="processed") == 1) ``` ## Frozen clock and seeded randomness ```python # pip install freezegun from freezegun import freeze_time @freeze_time("2026-08-05T12:00:00Z") def test_subscription_expires(): sub = make_subscription(days=30) assert sub.expires_at == datetime(2026, 9, 4, 12, tzinfo=UTC) # conftest.py — same failures every run import random random.seed(1337) ``` ## Data factories ```python import itertools _seq = itertools.count() def make_user(**over): n = next(_seq) defaults = dict(email=f"u{n}@test.local", name=f"User {n}", plan="free") return User(**{**defaults, **over}) def make_order(user=None, **over): user = user or make_user() defaults = dict(user_id=user.id, total_cents=1000, status="pending") return Order(**{**defaults, **over}) ``` Unique-per-call defaults keep parallel tests from colliding on unique constraints. ## Contract test sketch Consumer publishes expectations; provider CI verifies against the real app: ```python # consumer side (pact-python): "GET /users/9 returns id+email" pact.given("user 9 exists").upon_receiving("get user") \ .with_request("GET", "/users/9") \ .will_respond_with(200, body={"id": 9, "email": Like("a@b.c")}) ``` Lighter alternative: validate provider responses in integration tests against the OpenAPI schema (`schemathesis` or response-validation middleware). ## Gotchas - Mock-heavy tests rot: they keep passing while real integration breaks — the DB mock never raises `UniqueViolation`. - `scope="session"` containers + tests that commit = cross-test contamination; pair session containers with rollback/schema isolation. - Asserting exact timestamps or auto-increment IDs couples tests to execution order. - Parallel runners (pytest-xdist) expose hidden shared state — fixed ports, `/tmp` paths, same S3 bucket keys. - A retried-until-green test is a deleted test with extra steps; fix the race instead of adding `--reruns`. - E2E through the public API only — reaching into another service's DB in a test welds the deploy order together.