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,
/tmppaths, 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.