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.
← Back to TestingThe 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.
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.
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.
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.
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.
Each test should start from a known state. Three working strategies:
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).
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.
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.
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.
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.