backend / testing / integration / 01_integration_tests.md

Integration tests

5 interview angles 4 min read source

Integration tests

The layer between unit tests and end-to-end. The interview question is usually about the boundary — what you fake and what you run for real.

What counts as integration

A test that exercises your code against a real dependency: a database, a cache, a broker, another service. The distinction from a unit test is that something outside your process is involved.

Level Runs against Speed Catches
Unit nothing external ms logic errors
Integration real DB, cache, broker 100s of ms wiring, SQL, serialisation, migrations
Contract a recorded API contract fast provider/consumer drift
E2E the whole system slow user-visible flows

The class of bug integration tests catch and unit tests cannot: your mock lied. A repository unit-tested against a mocked session passes while the actual SQL is invalid, the migration didn’t run, or the JSON column round-trips badly.

Testcontainers

The standard answer in 2026. Spin up real dependencies in Docker, per test session.

import pytest
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def pg_url():
    with PostgresContainer("postgres:18") as pg:
        yield pg.get_connection_url()

@pytest.fixture(scope="session")
def engine(pg_url):
    engine = create_engine(pg_url)
    run_migrations(engine)          # test the real migrations, not metadata.create_all
    return engine

Two deliberate choices there:

  • scope="session" — container startup is seconds. Starting one per test is the difference between a 30-second suite and a 30-minute one.
  • Run your actual migrations, not Base.metadata.create_all(). Using create_all means you never test the migration path, and schema drift between models and migrations goes undetected until production.

That second point is the one worth making in an interview.

Isolation between tests

Tests must not see each other’s data. Options, in order of preference:

@pytest.fixture
def session(engine):
    """Transaction-per-test: fast, and rolls back everything including the test's own writes."""
    conn = engine.connect()
    trans = conn.begin()
    session = Session(bind=conn)
    yield session
    session.close()
    trans.rollback()
    conn.close()
Strategy Speed Caveat
Transaction rollback fastest breaks if code under test commits
Truncate tables between tests fast must respect FK order
Recreate schema per test slow only for small schemas
Separate database per worker fast, parallel more setup

Transaction rollback is the default. If your code calls commit(), use nested transactions (savepoints) or fall back to truncation.

For parallel runs with pytest-xdist, give each worker its own database or schema — otherwise tests interfere non-deterministically, which is worse than slow tests.

What to fake anyway

Even in an integration test, some things stay faked:

Real Faked
your database third-party HTTP APIs
your cache and broker payment providers
your own services (sometimes) email and SMS delivery
the clock, for time-dependent logic

Third-party APIs stay mocked — with responses, respx or a recorded VCR cassette. Real calls make your suite slow, flaky, rate-limited and dependent on someone else’s uptime. The risk this leaves is drift between your mock and the real API, which is what contract testing addresses. See ../strategy/02_integration_and_contract_testing.md.

Testing async code

@pytest.mark.asyncio
async def test_creates_order(async_session):
    repo = OrderRepository(async_session)
    order = await repo.create(user_id=1, total=Decimal("10.00"))
    assert (await repo.get(order.id)).total == Decimal("10.00")

Use asyncio_mode = "auto" in your pytest config so you don’t decorate every test. The recurring async testing trap is an event-loop mismatch between a session-scoped fixture and function-scoped tests — pin the loop scope explicitly. See ../pytest/06_pytest_asyncio.md.

Keeping the suite usable

The failure mode is an integration suite so slow nobody runs it locally.

  • Session-scoped containers, function-scoped transactions.
  • Parallelise with pytest-xdist and per-worker databases.
  • Mark them: @pytest.mark.integration, so pytest -m "not integration" gives a fast inner loop.
  • Run the fast set on every commit, the full set on PR.
  • Reuse containers locally (testcontainers reuse mode) to skip repeated startup.

Interview angle

  • “What’s the difference between a unit and an integration test?” — an integration test exercises real external dependencies. It catches the class of bug where your mock lied: invalid SQL, missing migrations, serialisation problems, wiring errors.
  • “How do you get a real database into tests?” — Testcontainers, session-scoped so startup cost is paid once, with your real migrations run against it rather than metadata.create_all — otherwise you never test the migration path.
  • “How do you isolate tests sharing one database?” — a transaction per test, rolled back afterwards. If the code under test commits, use savepoints or truncate between tests. For parallel workers, give each its own database.
  • “Do you call real third-party APIs?” — no. Mock them for speed and determinism, and cover the drift risk with contract tests against the provider’s published contract.
  • “Your integration suite takes 25 minutes and nobody runs it. Fix?” — session-scoped containers, transaction-based isolation instead of schema recreation, parallel workers with separate databases, and markers so the fast subset runs on every commit while the full suite runs on PR.