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(). Usingcreate_allmeans 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-xdistand per-worker databases. - Mark them:
@pytest.mark.integration, sopytest -m "not integration"gives a fast inner loop. - Run the fast set on every commit, the full set on PR.
- Reuse containers locally (
testcontainersreuse 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.