backend / testing / strategy / 02_integration_and_contract_testing.md

Integration and Contract Testing

6 interview angles 6 min read source

Integration and Contract Testing

The two tiers above unit tests: integration tests (does the code work with real dependencies?) and contract tests (do two independently-deployed services still agree on their interface?).

Integration testing — test against real dependencies

A unit test of a database query mocks the database — so it tests your mock of the database, not the database. The mock doesn’t have the real schema, real constraints, real transaction semantics, or real SQL behavior. Integration tests run your code against the real thing.

What integration tests should hit for real:

  • The database — real Postgres, with the real schema and migrations applied. Catches: bad SQL, missing indexes used in tests, constraint violations, transaction/isolation behavior, ORM-generates-the-wrong-query bugs.
  • The cache — real Redis. Catches: serialization issues, TTL logic, key collisions.
  • The message queue — real broker (or a faithful local one). Catches: serialization, ack/redelivery behavior.
  • HTTP to your own API — exercise the routing/middleware/serialization stack.

What you still mock at the integration level: third-party external services (Stripe, SES, a partner API) — you don’t want your test suite calling real Stripe. Those get contract tests or recorded responses instead.

testcontainers — real dependencies, disposable

The modern way to run integration tests against real services: testcontainers spins up real Docker containers (Postgres, Redis, Kafka, …) for the test session and tears them down after.

import pytest
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def pg_url():
    with PostgresContainer("postgres:16") as pg:
        yield pg.get_connection_url()      # real Postgres, fresh, disposable

def test_order_repository(pg_url):
    # run migrations against pg_url, then test the repository against REAL Postgres
    ...

Why this beats the alternatives:

  • vs mocking the DB — you test the real query against the real engine.
  • vs SQLite-in-tests-Postgres-in-prod — SQLite and Postgres differ (types, constraints, SQL dialect, concurrency); tests pass, prod breaks. Test against the engine you actually run.
  • vs a shared test database — testcontainers is isolated and disposable; no test pollution, no “who left data in the test DB,” runs the same locally and in CI.

Test isolation for integration tests

Integration tests share a stateful dependency (the DB) — so each test must not see another’s leftovers. Strategies:

  • Transaction rollback — wrap each test in a transaction, roll it back at the end. Fast (no re-seeding), clean. The standard for DB tests. Caveat: doesn’t work if the code under test manages its own transactions / commits.
  • Truncate between testsTRUNCATE the tables after each test. Slower than rollback but works regardless of the code’s transaction handling.
  • Fresh container per session, clean per test — testcontainers gives a fresh DB per run; combine with rollback/truncate for per-test isolation.

Never: tests that depend on running in a particular order, or on data another test created.

Contract testing — for microservices

When services are independently deployed, a new question appears: service A calls service B’s API — when B’s team changes B, did they break A? Running full end-to-end tests of A+B together on every change is slow and couples the teams’ pipelines.

Contract testing decouples this. The contract is the agreed shape of the interaction (request and response). It’s verified on both sides independently:

Consumer (A)                          Provider (B)
─────────────                         ─────────────
writes a test describing what          takes that contract,
it sends and expects back              replays it against the real B,
        │                              asserts B still satisfies it
        ▼                                      ▲
   generates a "pact" (contract) ───────────────┘
  • Consumer side — A’s test declares “when I call GET /users/42 I expect {id, name, email}.” This generates a contract artifact and runs A against a mock of B that honors the contract.
  • Provider side — B’s CI takes that contract and replays it against the real B, asserting B still produces what A expects.

If B’s team removes the email field, B’s contract-test run fails — before deploy, in B’s own pipeline — flagging “this breaks consumer A.” Neither team’s CI has to run the other’s full system.

Pact is the well-known framework (pact-python). The value: catch breaking interface changes at build time, without slow coupled E2E, without the provider needing the consumer’s whole test suite.

Contract testing vs schema / OpenAPI validation

  • OpenAPI/JSON-Schema validation checks “does the response match the declared schema.” Good, but the schema can be wrong, or a consumer might depend on behavior the schema doesn’t capture.
  • Contract testing checks “does the provider satisfy what a real consumer actually expects.” It’s consumer-driven — the contract is generated from real consumer expectations.

They’re complementary: schema validation catches shape drift cheaply; contract testing catches “we broke an actual consumer.” For internal microservices that evolve independently, contract testing is the stronger guarantee.

Recorded responses for third-party APIs

For external services you can’t (and shouldn’t) call in tests — record real responses once, replay them:

  • vcrpy / responses / respx (for httpx) — record an interaction, replay it deterministically.
  • Catches: your parsing of the third-party response. Doesn’t catch: the third party changing their API — for that you need a periodic real-call canary or their changelog.

Common gotchas

  • SQLite in tests, Postgres in prod — different engines; tests pass, prod breaks on a type/constraint/dialect difference. Test against the real engine (testcontainers).
  • Mocking the DB in “integration” tests — then it’s not an integration test; you’re testing the mock.
  • No test isolation — tests pollute the shared DB; order-dependent, flaky. Rollback or truncate per test.
  • Full E2E of A+B for every change — slow, couples team pipelines. Contract testing decouples it.
  • Contract test only on the consumer side — useless without the provider verifying the contract; both sides must run it.
  • Calling real third-party APIs in the suite — slow, flaky, costs money, depends on their uptime. Record and replay.
  • A shared long-lived test database — accumulates cruft, “works because of leftover data,” not reproducible. Disposable per run.

Interview angle

  • “What’s the difference between a unit test and an integration test?” — a unit test isolates one piece, mocking dependencies; an integration test runs the code against real dependencies (real Postgres, Redis, broker). The unit test of a query tests your mock of the DB; the integration test tests the real query against the real engine.
  • “How do you run integration tests against a real database?” — testcontainers: spin up a real Postgres (Redis, Kafka) Docker container for the test session, disposable, identical locally and in CI. Beats mocking (tests the real query) and beats SQLite-in-tests (same engine as prod, so no dialect surprises).
  • “How do you isolate stateful integration tests?” — wrap each test in a transaction and roll back (fast, the standard), or truncate tables between tests (slower but works when the code manages its own transactions). Never order-dependent tests or reliance on another test’s data.
  • “What is contract testing and what problem does it solve?” — for independently-deployed services: it verifies that a provider still satisfies what its consumers actually expect, without running slow coupled end-to-end tests. The consumer’s test generates a contract; the provider’s CI replays that contract against the real provider — so a breaking change fails the provider’s build, before deploy.
  • “Contract testing vs OpenAPI schema validation?” — schema validation checks the response matches a declared schema (which can itself be wrong or incomplete). Contract testing is consumer-driven — it checks the provider satisfies what a real consumer depends on. Complementary; contract testing is the stronger guarantee for evolving microservices.
  • “How do you test code that calls Stripe?” — don’t call real Stripe in the suite. Record real responses once (vcrpy/respx) and replay them deterministically — that tests your parsing. To catch Stripe changing their API, a separate periodic real-call canary.