Skip to content

Lesson 3 of 9 · 22m

Integration Tests: Where the Bugs Live

Unit tests tell you parts work; integration tests tell you they work together — the database boundary, the HTTP boundary, and what 'realistic' actually means.

"The unit tests pass" and "the service works" are different claims. Integration tests are where you verify the seams — the ORM actually talks to Postgres, the HTTP handler actually deserializes the request body, the queue consumer actually gets the message it expects.

Test at the boundary, not the whole stack

A good integration test exercises one external dependency at a time:

def test_repository_round_trip(db_session):
    repo = OrderRepository(db_session)
    saved = repo.save(Order(total=100))
    assert repo.get(saved.id).total == 100    # real DB, real SQL — not a mock

Testcontainers, a real Postgres in CI, or an ephemeral database per suite — the point is testing against the real dependency's behavior, not a mock of your assumptions about it. A mock can't tell you that LIKE is case-insensitive but ILIKE exists, or that Postgres transactions have savepoints.

The HTTP boundary

For a REST service, the integration test exercises the whole request cycle:

def test_post_order_returns_201(client, db):
    resp = client.post("/orders", json={"sku": "W-1", "qty": 2})
    assert resp.status_code == 201
    assert db.query(Order).count() == 1    # verify the side effect, not the route internals

Test the contract (status, headers, body shape) and the side effect — not middleware internals. The service layer's job is the logic; the handler's job is the translation.

Contracts over compatibility

When two services talk, the dangerous bug is a drifted contract — the consumer expects email, the producer sends emailAddress. Contract testing (Pact, OpenAPI conformance) pins both sides to a shared schema so a breaking change fails in CI instead of at runtime.

Field notes

  • The test database should look like production — same schema, same indexes, seeded with enough data to make queries realistic. A one-row test table hides cardinality bugs.
  • Use db.rollback() or a fresh schema per test for isolation — tests that share state are order-dependent and lie about each other.
  • If an integration test needs mocking, it's probably testing too many things at once — split it.