top of page

Integration Testing: When, How, and Why

  • Contributor
  • Mar 7
  • 5 min read

Integration tests verify that two or more components work together correctly. They sit between unit tests (which test one component in isolation) and end-to-end tests (which exercise the entire system). They're often the most valuable layer of the test pyramid and the most underused.

What Counts as Integration

There's no single definition. Common scopes:

  • Module integration: two or more classes in the same codebase working together

  • Service-to-database: code working with the real database

  • Service-to-service: two services communicating

  • Application: an HTTP request through the API to a response, with the database real

The choice of scope affects what bugs you catch and what the test costs.

A useful working definition: any test that exercises the real interaction between at least two components, with at least one of them not stubbed.

Why Integration Tests Matter

Unit tests with mocks can pass even when the system is broken. The mock returns what the test author expected; production returns something else. The bug only surfaces with real components.

Common integration-only bugs:

  • SQL queries that work in isolation but fail with real schemas

  • API contracts that don't match between caller and callee

  • Race conditions only visible with real timing

  • Transaction boundaries that don't behave as the unit tests assumed

  • Serialization issues between systems

  • Configuration that's correct in isolation but wrong in context

These bugs are exactly what integration tests catch and unit tests don't.

When to Use Integration Tests

Heavy use:

  • Code interacting with databases. The database is the application; testing without it tests fiction.

  • API endpoints. What a client actually calls.

  • Inter-service communication. Where contract breaks happen.

  • Stateful workflows. Multi-step operations that span components.

Light use:

  • Pure logic (use unit tests)

  • UI rendering (use higher-level tests if needed)

  • Third-party SaaS behavior (test the contract, not their internals)

The "Test Trophy" Shape

The traditional test pyramid (many unit, few integration, fewer e2e) is increasingly being challenged. Many modern test strategies use the "trophy" shape:

  • Foundation: static analysis (types, lint)

  • Many integration tests

  • Some unit tests for pure logic

  • Few end-to-end tests

The shift reflects modern realities: tooling makes integration tests fast enough to use heavily, and unit tests with heavy mocking often miss real bugs.

You don't need to commit to one shape. Use the test that catches the bug at the cost you're willing to pay.

Writing Integration Tests

A typical integration test:

def test_create_user_persists_and_returns_user():
    # Arrange: clean database state
    db = test_database()
    service = UserService(db=db)
    
    # Act: real call through the service to the database
    user = service.create_user(name="Sam", email="sam@example.com")
    
    # Assert: real query verifies the data
    fetched = db.users.find_by_id(user.id)
    assert fetched.name == "Sam"
    assert fetched.email == "sam@example.com"

Key differences from a unit test:

  • Real database (in a test environment)

  • Real service-to-database call

  • Verification by re-querying, not by trusting the return value

Speed

Integration tests are slower than unit tests. Acceptable ranges:

  • Single integration test: 10ms - 500ms

  • Full integration suite: under 5 minutes ideally

Tests slower than that get run less often, which defeats the purpose.

Common speed killers:

  • Spinning up new database instances per test (use one shared instance with cleanup)

  • Setting up too much fixture data

  • Not using transactions or other rollback mechanisms

  • Network calls to slow external systems

Profile slow tests. The 80/20 rule: a few slow tests account for most of the suite time.

Test Isolation

The hardest part of integration testing. Tests can't depend on each other's state.

Strategies:

  • Transactions: each test runs in a transaction that's rolled back at the end

  • Truncation: delete relevant tables between tests

  • Unique data per test: each test uses isolated IDs, namespaces, or accounts

  • Fresh databases: new database per test (slow but cleanest)

Transactions are fast and clean for most cases. They don't work when the code under test commits its own transactions.

Test Doubles for Externals

Even in integration tests, you don't include every external system. Lines you draw:

  • Real: database, cache, your own services

  • Real-but-isolated: message queues with test topics

  • Faked: in-memory equivalents for things that are expensive (S3 → in-memory storage)

  • Stubbed: third-party services with canned responses

  • Mocked rarely: only when no other option

For third-party APIs, contract tests (verifying you produce/consume the agreed format) plus stubs in integration tests typically work better than calling the real third-party service.

API Integration Tests

A common shape: tests that exercise the HTTP API end-to-end, with real database, but mocked external services.

def test_post_user_creates_resource():
    response = test_client.post("/users", json={
        "name": "Sam",
        "email": "sam@example.com"
    })
    
    assert response.status_code == 201
    assert response.json()["id"]
    
    # Verify side effect
    user_id = response.json()["id"]
    fetched = test_client.get(f"/users/{user_id}")
    assert fetched.json()["email"] == "sam@example.com"

This style catches API contract issues, HTTP-layer concerns (status codes, content types, validation), and basic correctness — without the cost of e2e tests.

Test Data

A common stumbling block. Approaches:

  • Per-test data: each test creates the data it needs. Slow but clear.

  • Shared fixtures: common data loaded once. Fast but can produce hidden dependencies between tests.

  • Factories/builders: programmatic data generation with sensible defaults. Fast, clear, flexible.

  • Snapshots/seeds: pre-built database states. Brittle as the schema evolves.

Factories are usually the sweet spot. Each test creates the specific data it needs from the factory, with the factory handling sensible defaults.

What Not to Do

Integration tests as a replacement for unit tests. They're slower and more brittle. Use unit tests where they fit; integration where they're needed.

Integration tests with full stubs. If everything is stubbed, you're not testing integration — you're testing the stubs.

Tests dependent on test order. Brittle. Tests must be independent.

Tests with environmental coupling. "This test only passes on Linux." Reveal the coupling and remove it.

Integration tests that lie. Pretending to test the real database while actually using SQLite when production is Postgres. The differences will bite.

Flakiness in Integration Tests

Integration tests tend to be flakier than unit tests because there are more moving parts.

Common causes:

  • Timing assumptions (async operations without proper waits)

  • Test data conflicts (tests sharing state they shouldn't)

  • Slow database operations under load

  • Race conditions in shared infrastructure

Flaky integration tests should be quarantined immediately and fixed. The team's tolerance for occasional failures destroys the value of the suite.

Key Takeaway

Integration tests verify components work together, catching bugs that unit tests with mocks miss. Most valuable when code interacts with databases, APIs, or other services. Aim for fast tests (under 500ms each, full suite under 5 minutes). Use real dependencies where possible; stub only external services. Keep tests isolated through transactions or per-test data. Treat flakiness as a defect. The "test trophy" with heavy integration coverage is often a better fit for modern services than the traditional pyramid.

Related reading

Keep learning. This article is part of the Software Testing Foundations path in the ShiftQuality Learning Center. Learn to design tests that catch real bugs.

bottom of page