Integration Testing

Unit tests verify that individual components work correctly in isolation. Integration tests verify that those components work correctly together. While unit tests mock external dependencies, integration tests use real (or near-real) versions of those dependencies to catch the bugs that only surface when components interact — mismatched data formats, incorrect API contracts, transaction handling issues, and configuration mismatches.

Integration tests sit in the middle of the testing pyramid. They are slower than unit tests because they involve real databases, HTTP calls, or message queues. But they are faster and more targeted than full end-to-end tests. A well-balanced test suite uses integration tests to verify the boundaries between components where unit tests cannot reach.

Testing How Components Work Together

The defining characteristic of an integration test is that it crosses at least one boundary. That boundary might be between your application code and a database, between two microservices, between your backend and a third-party API, or between your code and the file system. The test verifies that the interaction across that boundary works correctly.

Consider a user registration flow. A unit test might verify that the validateEmail() function rejects invalid email addresses. An integration test would verify that the entire registration endpoint accepts a POST request, validates the input, hashes the password, stores the user in the database, and returns the correct response — all working together as a cohesive flow.

Integration tests catch problems that unit tests structurally cannot:

  • Serialization mismatches: Your code serializes a date as an ISO string, but the API you call expects a Unix timestamp. Unit tests with mocks never discover this because the mock returns whatever you tell it to.
  • Query bugs: Your SQL query has a subtle JOIN condition error that returns too many rows. A mock would return a predetermined result set and miss this entirely.
  • Configuration issues: The database connection pool is configured with a timeout that is too short for certain operations. Only a real database connection reveals this.
  • Transaction boundaries: Two database operations need to be atomic, but a bug causes the second operation to run outside the transaction. Mocks do not model transaction semantics.

Database Integration Tests

Database integration tests are among the most valuable integration tests you can write. They verify that your queries, migrations, and data access layer work correctly against a real database engine.

Testcontainers is the gold standard for database integration testing. It uses Docker to spin up a real database instance (PostgreSQL, MySQL, MongoDB, Redis, or dozens of others) for the duration of your test suite. Each test run gets a fresh, isolated database. When the tests finish, the container is destroyed.

// Java example with Testcontainers
@Testcontainers
class UserRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:15");

    @Test
    void shouldSaveAndRetrieveUser() {
        UserRepository repo = new UserRepository(postgres.getJdbcUrl());

        User saved = repo.save(new User("alice@example.com", "Alice"));
        User found = repo.findByEmail("alice@example.com");

        assertEquals(saved.getId(), found.getId());
        assertEquals("Alice", found.getName());
    }
}

For environments where Docker is not available or is too slow, in-memory databases provide an alternative. SQLite's in-memory mode works well for simple schemas. H2 (for Java) can emulate PostgreSQL or MySQL syntax. However, in-memory databases have limitations — they may not support the same features, data types, or query syntax as your production database, so use Testcontainers when possible.

Key practices for database integration tests:

  • Run migrations before tests: Apply your full migration chain to the test database so you are testing against a schema that matches production.
  • Isolate between tests: Either wrap each test in a transaction that rolls back at the end, or truncate all tables between tests. This prevents test pollution where one test's data affects another.
  • Test queries, not the ORM: If you are using an ORM, write integration tests for the complex queries and data access patterns. Simple CRUD operations are usually safe because the ORM generates the SQL.
  • Test edge cases in data: NULL values, empty strings, extremely long strings, Unicode characters, and concurrent writes are common sources of database bugs that only surface in integration tests.

API Integration Tests

API integration tests verify that your HTTP endpoints behave correctly end to end — from receiving the request through processing it and returning a response. Unlike unit tests that test controller methods in isolation, API integration tests send real HTTP requests to your application.

Most web frameworks provide test utilities that start the application in a test server and let you send HTTP requests without opening a network port:

  • JavaScript (Express/Koa): Use supertest to send HTTP requests to your app instance without starting a real server.
  • Python (Django/Flask): Use the framework's built-in test client. Django's TestCase provides self.client.get() and self.client.post().
  • Java (Spring Boot): Use @SpringBootTest with MockMvc or TestRestTemplate to test endpoints.
  • Go: Use httptest.NewServer() to create a test server and http.Client to send requests.

API integration tests should verify:

  • Status codes: Does the endpoint return 200 for valid requests, 400 for bad input, 401 for unauthenticated requests, and 404 for missing resources?
  • Response bodies: Does the JSON response contain the expected fields and values? Does the response format match your API contract or schema?
  • Headers: Are content-type, caching, CORS, and security headers set correctly?
  • Side effects: Does the endpoint actually create the resource in the database? Does it send the expected events to the message queue?
  • Error responses: Are error messages helpful but not leaky? Does the endpoint return structured error objects rather than stack traces?

Service-to-Service Integration

In a microservice or distributed architecture, services communicate over the network — via REST, gRPC, message queues, or event buses. Integration tests at this boundary verify that services can communicate correctly.

There are two main approaches:

Contract testing (using tools like Pact) verifies that the consumer's expectations match the provider's actual behavior without requiring both services to be running simultaneously. The consumer records its expectations as a "contract," and the provider runs those contracts against its own implementation. If both sides satisfy the contract, they can communicate correctly.

Component testing starts one service and mocks its downstream dependencies. This tests the service's behavior in near-production conditions without needing the entire system running. Tools like WireMock or MockServer can simulate downstream APIs with configurable responses, latency, and failure modes.

Key insight: In distributed systems, the most common integration bugs are at service boundaries: serialization mismatches, incorrect HTTP methods, missing headers, and wrong URL paths. Contract testing catches these efficiently without the overhead of running the full system.

Test Data Management

Integration tests need realistic data. Managing that data well is critical to having tests that are reliable, readable, and maintainable. There are three common approaches:

Fixtures are static data files (JSON, YAML, SQL) that are loaded into the database before tests run. They are simple and predictable but can become hard to maintain as your schema evolves. If you add a required column, you need to update every fixture file.

Factories are code-based data builders that generate test objects with sensible defaults. Libraries like FactoryBot (Ruby), factory_boy (Python), and Fishery (JavaScript) let you create test data with a single line while overriding only the fields relevant to your test:

// Using a factory in JavaScript
const user = UserFactory.build({ email: 'test@example.com' });
// All other fields (name, role, createdAt, etc.) get sensible defaults

Seeds are scripts that populate the database with a known dataset. They are useful for development environments and can also serve integration tests that need a large, realistic dataset. Seeds are heavier than fixtures or factories and are typically used for scenario-based testing rather than individual test cases.

Regardless of your approach, follow these principles:

  • Create only the data you need. If your test verifies that a search query returns the right results, create only the records relevant to that search. Creating an entire application's worth of data makes tests slow and hard to debug.
  • Make data creation explicit. The test should clearly show what data exists. Hidden setup in fixtures or shared beforeEach blocks makes tests harder to understand.
  • Clean up after each test. Whether through transaction rollback, truncation, or container recreation, ensure that each test starts with a known state.

When Integration Tests Are More Valuable Than Unit Tests

There are scenarios where integration tests provide dramatically more value than unit tests:

  • Data access layers: Testing your repository or DAO against a real database catches SQL bugs, migration issues, and ORM misconfigurations that mocks would hide.
  • API controllers: Testing the full request-response cycle verifies routing, middleware, serialization, and response formatting in one test.
  • Third-party integrations: When your code integrates with an external service (payment processor, email provider, cloud storage), an integration test against a sandbox or test environment catches real compatibility issues.
  • Configuration-heavy code: Code that depends heavily on environment variables, feature flags, or configuration files benefits from integration tests that use real configuration.
  • Glue code: Some code is primarily responsible for wiring components together. Unit testing glue code means mocking almost everything, which provides little value. An integration test verifies that the wiring is correct.

Balancing Speed and Confidence

Integration tests are inherently slower than unit tests. A unit test suite might run in 5 seconds; an integration test suite might take 2 minutes. This tradeoff between speed and confidence requires deliberate management:

  • Run unit tests on every save (in your editor or with a watch mode). They are fast enough to provide instant feedback.
  • Run integration tests on every commit or push. They should still be fast enough to complete before a code review begins.
  • Parallelize integration tests. Use separate database containers or schemas for parallel test processes. Most CI systems support parallel test execution.
  • Cache infrastructure startup. Testcontainers supports reusable containers that persist between test runs during local development, dramatically reducing startup time.
  • Be selective. Not every component boundary needs an integration test. Focus on the boundaries where bugs are most likely and most costly.
Common mistake: Writing integration tests for everything instead of just the integration points. If a function contains pure business logic with no external dependencies, test it with a unit test. Reserve integration tests for code that crosses boundaries.

Resources