Free — no signup required

Integration Tests in Docker

3 min read

When Fakes Aren't Enough

Mocks and stubs are essential for unit tests, but they are still lies. A mock database connection will happily accept a SQL query with a syntax error. A stub HTTP client will return your canned response even if the real API has changed its contract. Mocks test that your code tries to do the right thing; they cannot test that it actually works with real infrastructure.

Integration tests verify that different components of your system work together correctly — your application code talking to a real database, a real message queue, or a real cache.

The Old Problem: Shared Test Environments

Historically, integration tests required a dedicated "test database server" that the whole team shared. This created chronic problems:
- Tests from different developers or CI pipelines would corrupt each other's data.
- Someone would forget to clean up after a test run, leaving stale state that caused mysterious failures.
- The test environment would drift from production over time.
- Tests became flaky — passing sometimes, failing other times, for reasons unrelated to the code.

Ephemeral Infrastructure with Testcontainers

The modern solution is Testcontainers — a library available for Java, Python, Go, Node.js, .NET, and others. It allows your test code to programmatically manage Docker containers as part of the test lifecycle.

The workflow is:

  1. Before the test suite: Ask Docker to pull and start a fresh Postgres container.
  2. Wait for readiness: Poll until the container's health check passes (the database is accepting connections).
  3. Inject the connection string: Pass the container's dynamically assigned host and port to your application.
  4. Run the tests: Your application talks to a real Postgres instance.
  5. After the test suite: Destroy the container. No cleanup scripts needed.

Here is what this looks like in Python using testcontainers-python:

from testcontainers.postgres import PostgresContainer
import psycopg2

def test_user_repository_saves_and_retrieves():
    with PostgresContainer("postgres:16-alpine") as postgres:
        # 'postgres' is a real, running container
        conn_url = postgres.get_connection_url()

        # Run your migrations against the real database
        run_migrations(conn_url)

        repo = UserRepository(connection_url=conn_url)

        # Act
        repo.save(User(id=1, email="[email protected]"))
        retrieved = repo.find_by_id(1)

        # Assert against real database behavior
        assert retrieved.email == "[email protected]"
    # Container is automatically destroyed when the 'with' block exits

The same pattern works for Redis, Kafka, MySQL, MongoDB, RabbitMQ, and any other service that has a Docker image.

The Trade-offs

Testcontainers-based integration tests are slower than unit tests — starting a Postgres container takes 2–5 seconds. This is why they sit higher in the testing pyramid. You do not run them on every file save; you run them in CI and before merging. The payoff is high-confidence tests that catch the class of bugs that mocks cannot: SQL query errors, ORM misconfiguration, schema migration issues, and connection pool exhaustion.

This is one of 18 chapters

Every chapter is free — Kubernetes, Terraform, SRE, distributed systems, and more — with fast daily review built in.

Sign up free