Skip to content

Integration

The question: do we talk to that database, broker or external API correctly?

Not does the database work — that is someone else’s test. Whether our query is valid against the real query planner, our migration applies to the real schema, our mapping round-trips through the real driver, our consumer handles the real broker’s redelivery.

Two things get called integration testing and are neither.

A unit test with a mocked repository. That verifies the code calls the mock as expected, which is a statement about the mock. The interesting failures — wrong column type, a constraint nobody knew about, a lazy-loading exception outside the session, a timezone conversion in the driver — all live on the other side of the mock.

An end-to-end test. If it goes through the UI and three services, it is end-to-end: slower, flakier, and it fails for twenty reasons of which “our query is wrong” is one.

The integration test sits precisely between: our code, the real technology, nothing else.

Against a real instance of the real technology, started for the test and thrown away after it. Testcontainers made this the default and it is the single biggest improvement in this typology in a decade — the alternative it replaced was a shared integration environment, which fails in a particular and familiar way:

  • Tests interfere through shared state, so they cannot run in parallel.
  • Someone’s failed run leaves data behind, and the next failure is unrelated to the change.
  • The schema drifts from what the code expects, and nobody can say when.
  • It is down, and nothing can be merged.

The cost of “start a container per test class” is a few seconds, and it is usually much less than the cost of one afternoon spent on the four bullets above. See environments.

Enough to prove the boundary is crossed correctly; not the business logic behind it, which belongs in unit tests.

Databases — each non-trivial query against real data, migrations applying forward from the current production schema, constraints and unique indexes doing what the code assumes, transaction and isolation behaviour where it matters, and the mapping round-tripping every type you actually use (decimals, timestamps with zones, enums, JSON columns).

Message brokers — publishing and consuming with the real serialiser, redelivery and acknowledgement, dead-letter routing, and idempotent consumption of the same message twice, because at-least-once delivery means it will happen.

Third-party HTTP APIs — against a mock server rather than a mocked client: the real HTTP stack, the real serialisation, the real timeouts. Where a contract exists, the mock should be generated from it, which is what Microcks is for. Also test the failures the provider will eventually produce — 500, 429 with a Retry-After, a connection reset mid-body, a response that takes 30 seconds.

That last group matters more than the happy path. Nobody’s integration is wrong when the third party behaves; it is wrong at 3am when the third party does not, and that is a resilience question the integration level can start answering cheaply.

One concern per test. A test that inserts, updates, queries and deletes tells you something failed somewhere in a chain.

Own your data. Each test creates what it needs and does not depend on a seeded fixture that fifty other tests also read. See test data.

Never against a shared instance. The moment two runs share state, the suite starts producing failures that are not about the change. See determinism.

Whether the other side agrees. A test can prove our client sends {"customerId": "..."} perfectly and the provider can still be expecting customer_id — because our mock was written from the same misunderstanding as our code.

That is exactly the gap contract testing exists to close, and it is the reason the two typologies are listed separately rather than merged.

See API and contract tooling for Testcontainers, WireMock, Microcks and the per-stack integration test support.