Test Types Deep Dive · 2 of 8

Integration Testing — When the Wires Have to Match

Unit tests verify each piece in isolation; integration tests verify the pieces work together. Your code plus a real database. Your service plus a real message broker. Your API plus a real HTTP layer. They're slower than units, fewer in number, and — when designed well — the tests that catch the bugs no mock will ever surface.

Pyramid MiddleReal DependenciesTestcontainersDB Tests
← Back to Testing
Quick Facts

What an Integration Test Is

Basic Concepts

  • Multiple components, real edges. The component under test runs against a real database, real HTTP, real queue — not mocks of those things.
  • Scope is bounded. Not the whole product (that's E2E). One service, its DB, maybe one or two adjacent collaborators.
  • Slower than unit, faster than E2E. Tens to hundreds of milliseconds each. A suite of a few hundred runs in minutes.
  • The classic targets: repositories against the DB, HTTP controllers end-to-end through the framework, service-to-service calls, message handlers, migrations.
Why It Matters

What Integration Tests Catch That Units Don't

Wiring and Configuration

The unit test of the controller works. The unit test of the service works. The integration test discovers that the controller wasn't wired to the service in the DI config, or the route was registered with the wrong path, or the JSON serializer drops a field. Most "it broke in staging" bugs are wiring.

SQL That Lies

Repository code mocked against a fake DB always passes. The same code against a real Postgres reveals: missing index causing slow queries under load, type coercion silently truncating a value, a constraint that fires only on real data, an isolation level that lets a phantom read through.

Migrations That Don't Apply Cleanly

The migration runs against an empty DB in unit tests; against a populated DB it locks a table for 20 minutes or fails on a NULL row. Integration tests run the full migration chain on a real engine, every time.

Framework Behavior

Spring's @Transactional propagation, ASP.NET model binding, Express middleware order, Django authentication backends — these only behave correctly when the full framework runs. Mocking them out in units misses every framework-driven bug.

Building Them

The Patterns That Make Integration Tests Bearable

Real Engines via Testcontainers

Testcontainers spins up real Postgres, Kafka, Redis, MongoDB, Elasticsearch in Docker for each test run. Tests get production-like behavior; CI gets reproducibility. Available for Java, .NET, Python, Node, Go, Ruby. The current default for serious integration testing.

Alternatives: embedded engines (H2 for relational, fakeredis for Redis) are fast but lie about behavior. Use real engines unless startup time is genuinely prohibitive.

Per-Test Isolation

Each test should start from a known state. Three working strategies:

  • Wrap each test in a transaction and roll back at the end — fast, works for most ORM tests.
  • Truncate tables between tests — slower but works when tests need their own transactions.
  • Recreate the DB per test class with template DBs — clean but heavier.
Test Data Builders

Stop sprinkling raw object construction across tests. A builder DSL — aUser().withRole("admin").withVerifiedEmail().build() — keeps tests readable and resists field churn. Libraries: AutoFixture (.NET), Faker (multiple), FactoryBot (Ruby), factory-boy (Python).

Stub External Services, Don't Mock In-Process

For HTTP services you call (Stripe, SendGrid, your own internal APIs), use WireMock, Mock Service Worker (MSW), Prism, or the third-party's official sandbox. The HTTP path stays real; the remote response is controllable. Don't mock at the language-binding level — that misses serialization and HTTP-layer bugs.

HTTP-Layer Tests

Test the controller through a real HTTP request, not by calling the controller method directly. Frameworks: Spring's MockMvc / TestRestTemplate, ASP.NET WebApplicationFactory, FastAPI's TestClient, Supertest. Catches routing, middleware, content-type, status-code, and serialization bugs in one stroke.

Parallelize Carefully

Integration tests share heavyweight resources — DB connections, container ports. Default to running tests sequentially within a class, parallel across classes with separate DB schemas. Random shared-state failures destroy trust in a suite faster than anything else.

Where to Put Them

The Test Pyramid in Practice

A healthy ratio for a typical service: thousands of unit tests, hundreds of integration tests, a dozen E2E tests. The pyramid shape isn't dogma — it's the only ratio where the suite is fast enough to run on every commit while still catching wiring bugs.

Common anti-shape: "ice cream cone" — almost all tests are E2E, with a handful of integrations and units below. Slow, flaky, painful to debug. When you see this, the fix is to push tests down the pyramid: replace E2E with integration where the test only really needs one service.

Common Mistakes

What Goes Wrong

  • Treating them as units. If hundreds of "integration tests" each take 5ms because everything's mocked, they're units. Use real engines or admit the layer.
  • Treating them as E2E. One service plus its DB is integration. Three services across the network with a real Auth0 is E2E. Don't conflate.
  • Tests that share a DB and depend on order. "Test C only passes after test A creates user X." Catastrophic on parallel runs.
  • Long-running suites that no one runs locally. Once integration tests take 20 minutes, developers stop running them. Optimize ruthlessly, or split.
  • Real production credentials in test config. Test environments need their own credentials, and those credentials have to be safe to leak. Don't point the test suite at production "just to be sure."
Continue

Other Test Types