Skip to main content

Integration Tests: Boundaries to Test, Fakes to Use

What This Concept Is

An integration test exercises your code against one or more real seams that unit tests deliberately skip: a real database, a real message queue, a real file system, a real HTTP transport. It is slower than a unit test (tens of milliseconds to a few seconds) but proves something a unit test cannot -- that your code and its environment actually agree.

In the capstone, the integration layer is where you typically test:

  • service + repository + real database;
  • API handler + service + repository (a request-level slice);
  • adapters for external systems with a fake or recorded version of the system;
  • message producers and consumers against a real (in-memory or containerized) broker.

The word "real" is load-bearing. Replacing the database with a mock turns the test back into a unit test. Gerard Meszaros's taxonomy in xUnit Test Patterns is the one to hold in mind: dummy -> stub -> fake -> spy -> mock. Fakes are small, in-process replacements that behave like the real thing; mocks record interactions; the two are not interchangeable, and almost every "mock the DB" anti-pattern is a case of reaching for a mock where a fake or a real container would have served.

Testcontainers (for DB, Redis, Kafka) has made this level dramatically cheaper over the past five years; it is now cheaper to start a real Postgres in 2 seconds than to stub one to a meaningful degree.

Why It Matters Here (In the Capstone)

Most capstone defects live at integration points: a wrong SQL migration, a JSON field named differently than the model, a retry policy that reads the wrong error code, a timezone handled inconsistently between layers. None of these are visible in unit tests. Integration tests catch them cheaply if they are aimed at the right boundaries.

The opinionated target for a capstone is roughly 25% of total tests at this level. That is enough to cover every important seam once or twice without slowing CI below a one-minute unit-test budget.

Concrete Example(s) -- from a real capstone

Using the task-manager capstone:

Integration test 1: repository against a real Postgres container

def test_task_repo_creates_and_reads(real_db):
repo = TaskRepository(real_db)
created = repo.create(Task(title="Write tests"))
fetched = repo.get(created.id)
assert fetched.title == "Write tests"

Runs against a throwaway Postgres instance (Testcontainers), migrates the schema, asserts end-to-end behavior through the ORM, and tears down. Catches migration bugs, type mismatches, and ORM misuse. A unit test cannot.

Integration test 2: API handler end-to-end inside the process

def test_post_tasks_creates_row(client, real_db):
response = client.post("/tasks", json={"title": "Ship steel thread"})
assert response.status_code == 201
assert real_db.execute("SELECT COUNT(*) FROM tasks").scalar() == 1

Starts the app, hits the HTTP handler in-process, goes through the service, hits the repo, writes to real Postgres. If any seam is wrong, this test fails loudly.

Integration test 3: external API adapter against a fake

The GitHub adapter is tested against a fake server (a small in-process HTTP server that you control) rather than the real GitHub. The fake implements the minimum of the real API for your tests to run against it. That is how you get integration coverage at a boundary you do not own.

Common Confusion / Misconceptions

The biggest confusion is when to mock versus when to fake versus when to use the real thing. A rough rule:

  • Real -- anything your capstone owns and can spin up cheaply (your DB, your code).
  • Fake -- anything your capstone does not own but can replace with a small, in-process implementation (HTTP adapters for third-party APIs, message brokers you treat as a dependency).
  • Mock -- sparingly, and mostly to assert a call was made at a specific seam (e.g., "the service emitted one event").

A test that mocks the database is not an integration test. It is a unit test with extra ceremony; it will pass while the schema is broken.

The second confusion: making integration tests too wide. If one test goes through ten layers, a failure tells you nothing specific. Aim each integration test at one or two seams.

The third confusion is leaving integration tests non-deterministic because "the DB is real, so a little flakiness is fine." Real does not mean messy: transactional rollback, container-per-suite isolation, and deterministic seed data are table stakes.

The fourth is treating the integration test as a documentation replacement for the adapter. Tests document behavior, not intent; keep an ADR or module README alongside.

How To Use It (In Your Capstone)

When adding an integration test, ask:

  1. What real seam is this test exercising that the unit tests cannot?
  2. Do I own this seam, or do I need a fake?
  3. Can it fit under a few seconds, including setup?
  4. Will it stay deterministic on CI, or am I inviting flakiness?
  5. Is the test isolated per-run (transactional rollback, dropped schema, fresh container)?
  6. Does it live under tests/integration/ so the unit pass can be run standalone?
  7. Does it run on every push in CI, after the fast unit pass?

Prefer integration tests that run against a containerized real dependency where possible, tear down fully after each test, and live in a separate directory so the fast unit pass can be run standalone.

Seam Catalog for a Capstone

SeamPreferred test styleReason
Own database (Postgres, MySQL)Containerized real DBCheapest way to catch schema, ORM, and migration bugs.
Own cache (Redis)Containerized real cacheFast; catches serialization and TTL bugs.
External HTTP APIIn-process fake HTTP serverDeterministic and offline; pairs with contract tests for shape drift.
Email / SMS providerIn-process fake that records callsAssert "would have sent X to Y." No real send.
Message broker (Kafka, RabbitMQ)Containerized real brokerIn-memory substitutes hide ordering and partitioning bugs.
FilesystemTemp directory via tmp_path or equivalentReal FS semantics are cheap locally.
Clock / randomnessInject a seam, use a fake in testsThese are not infrastructure; they are collaborators.

Operational Rules

  • Keep integration tests under one second each. If they creep past five seconds, they stop getting run locally and CI gets slow.
  • Use transactional rollback for per-test isolation against the real DB. Every test sees an empty schema; no shared fixtures.
  • Put integration tests in a separate directory (e.g. tests/integration/) so the fast unit pass can be run standalone.
  • Run them on every push in CI, but after the unit pass. Failure there is noisier but more informative.
  • Fail fast on the first seam. A test that exercises three seams and fails tells you nothing specific; one seam per test is the default.

Anti-Patterns to Recognize

  • Mock DB integration test. Named integration_test but mocks the database.
  • Full-system integration test. Tries to touch every seam in one test.
  • Leaky test. Touches a real external API in CI without a fake, introducing flakiness and cost.

See also (integrative)

External references:

Check Yourself

  1. Why is a test that mocks the database not an integration test?
  2. What is the difference between a fake and a mock in Meszaros's taxonomy?
  3. Why should an integration test target one or two seams rather than the whole system?
  4. Why is one second per integration test the practical upper bound, and what breaks if you exceed it?
  5. What makes Testcontainers cheaper than hand-rolled test doubles for a capstone-sized codebase?

Mini Drill or Application (Capstone-scoped)

  1. In your capstone, identify each external seam (DB, queue, cache, external APIs, file storage). For each, classify as "owned" or "not owned", decide real/fake/mock, and write one integration test that exercises it.
  2. Wire Testcontainers (or your language's equivalent) for the primary database. Confirm the integration test suite starts from a clean schema on each run.
  3. Build one in-process fake HTTP server for a third-party dependency; write an adapter integration test against it.
  4. Measure the integration-test suite's wall-clock time and paste into your journal. If it exceeds two minutes on CI, pick one test to split or speed up.
  5. Pair one integration test with a contract test (Concept 9) at the same seam and confirm both run on every push in CI.

Source Backbone

Capstone implementation applies earlier code-quality, testing, and refactoring material. These books are the source backbone for that practice.