Integration Testing with Real Dependencies
Integration tests prove behavior that exists at a real boundary: SQL translation and transactions, broker delivery, filesystem semantics, wire formats or third-party protocols. They need production-equivalent dependency types, isolated state, real migrations and evidence that distinguishes readiness from correct behavior.
An integration test earns its cost when the behavior under test exists only across a boundary. SQL dialect and query translation do not exist in a repository mock. Broker acknowledgement does not exist in an in-memory list. File locking does not exist in a byte array. The test must include the mechanism that owns the claim, while keeping unrelated parts outside its scope.
Name the boundary and the claim #
“Integration test” is incomplete. State what is real and what the test proves.
| Boundary | Claims available only with the real mechanism |
|---|---|
| application ↔ database | migrations, SQL translation, constraints, collation, transactions, locking |
| producer ↔ broker | serialization, routing, acknowledgement, redelivery, ordering scope |
| HTTP client ↔ server | wire encoding, headers, authentication, error mapping, streaming |
| process ↔ filesystem | paths, permissions, atomic rename, locking, durability behavior |
| service ↔ identity provider | token validation, key rotation, claims and expiry semantics |
Do not bring the whole application into the test by default. A repository test can use the real database while calling the repository directly. A message adapter test can use the real broker without starting the browser. Scope is chosen around the first boundary where the risk becomes observable.
flowchart LR H["Test harness"] --> A["Production adapter"] --> P["Real dependency type"] M["Production migrations / schema"] --> P P --> O["Observable result and boundary evidence"] O --> H
Use the same dependency type as production #
Replacing PostgreSQL with an in-memory collection or SQLite changes the semantics. Queries can differ in case sensitivity, null ordering, supported syntax, generated SQL and transaction behavior. A fake can be appropriate for application logic above a repository, but it cannot validate the repository's database behavior.
The same principle applies beyond databases: an embedded queue is not automatically equivalent to Kafka; a local filesystem is not object storage; a stub server is not the provider. The substitute may support faster tests at a higher abstraction. It does not remove the need for representative real-boundary evidence.
Containerized dependencies make this practical by starting a pinned database, broker or service for the suite. The container is an orchestration mechanism, not proof by itself. Readiness means the process accepts connections; correctness still requires applying production migrations, exercising the boundary and checking the result.
Build state from the same schema path #
A credible database test starts from an empty or declared baseline and applies the production migration chain. Creating tables with a separate test-only script can leave the test green while deployment fails.
Useful isolation strategies include:
- a fresh database or schema per test worker;
- a fresh container per suite plus unique schemas/databases per test;
- transaction rollback when the application shares the same transaction and commit behavior is not under test;
- deterministic cleanup by ownership key when state must survive multiple connections.
Rollback is fast but has limits. It cannot test commits, post-commit hooks, multiple independent transactions or behavior that crosses processes. Truncating shared tables is vulnerable to parallel tests and interrupted cleanup. Fresh state costs more but gives the strongest independence.
Seed the minimum facts needed for the scenario through stable builders or public APIs. Copying a large production-shaped dump makes tests slow, obscure and coupled to unrelated columns. If a migration needs production-scale or production-distribution validation, treat that as a separate rehearsal with its own data governance.
Test the failure semantics #
Happy-path connectivity is weak integration evidence. Exercise the boundary behaviors the application promises to handle:
- duplicate keys and optimistic-concurrency conflicts;
- serialization failures and unknown fields;
- connection loss before versus after commit;
- deadlock/serialization retry signals;
- broker redelivery and consumer restart;
- HTTP timeout, cancellation and partial response;
- credential expiry or rotated verification keys.
Some failures require a proxy or fault-injection layer because killing a process does not distinguish where the failure occurred. Preserve server logs, client error type, transaction identifier and resulting state so the assertion can tell “request rejected” from “commit succeeded but acknowledgement was lost.”
Contract tests and real integrations complement each other #
A consumer contract test can rapidly verify that a client still accepts provider examples. A provider verification can prove those examples against the implementation. Neither reproduces network policy, deployment configuration or every runtime failure. Keep at least one real wire-level integration for the protocol stack and reserve full system tests for composed journeys.
For a fake, run a shared conformance suite against fake and real implementations when callers depend on common semantics. This detects drift but cannot make the fake equivalent outside the tested contract.
Worked example: idempotent order creation #
To prove “the same idempotency key creates at most one order,” a useful integration test:
- starts the production database version and applies all migrations;
- creates two independent connections;
- submits the same key concurrently through the repository or service boundary;
- waits for both results with a bounded synchronization primitive;
- asserts one durable order, one stable returned order id and the expected conflict/replay semantics;
- records SQL errors and final rows on failure.
An in-memory dictionary with a lock may verify application intent. It does not prove the unique index, isolation level, transaction retry path or query actually used in production. Those are precisely why the integration test exists.
Keep the suite operable #
Pin dependency versions deliberately, expose startup and migration failures directly, and cap resource use in parallel CI. Measure duration and flake rate by test. When a test fails, retain container logs, executed migration version, relevant request/response and database state. A broad green badge is useful; a failing integration suite must also be a diagnostic instrument.