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:
- Before the test suite: Ask Docker to pull and start a fresh Postgres container.
- Wait for readiness: Poll until the container's health check passes (the database is accepting connections).
- Inject the connection string: Pass the container's dynamically assigned host and port to your application.
- Run the tests: Your application talks to a real Postgres instance.
- 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.