Testcontainers
Testcontainers
Testing code that depends on external systems like a DB, Redis, or Kafka usually splits into two paths. We either mock the dependency, or we boot a real instance and verify against it. Testcontainers is the library that makes the latter approach simple. It launches throwaway containers on Docker only during tests, and cleans them up automatically.
1. About Testcontainers
It started in 2015 as a Java library by Sergei Egorov and Richard North. SDKs for Node, Python, Go, .NET, and Rust have grown since, and modules cover systems like PostgreSQL, MySQL, Redis, Kafka, MongoDB, Elasticsearch, LocalStack, and Selenium.
The site is testcontainers.com, and the org is github.com/testcontainers. Most of it ships under the MIT license.
The core idea boils down to four lines.
- A Docker container starts when the test starts.
- It exposes ports on a random host port.
- The host (the test code) connects to the container.
- The container is cleaned up automatically when the test ends.
2. What it solves
Mocked behavior often diverges from the real DB's behavior, and that hides regressions. Mocking PostgreSQL's ON CONFLICT and then hitting different results in production is a common story.
The DB setup on a developer's PC also tends to diverge from CI's setup, making test results inconsistent. Containers unify the image, so tests run on the same artifact everywhere.
When a shared DB backs the tests, leftover data leaks between cases. A container that starts fresh each time gives proper isolation.
3. How it works
When a test framework calls the Testcontainers SDK, the SDK sends a container request to the Docker daemon. The container publishes its internal port (5432 for PostgreSQL, for instance) to a random host port. The SDK waits until a health check (e.g. pg_isready) passes. The test code then connects to the mapped host:port and runs assertions. Once the test ends, the SDK shuts down and cleans up the container.
Ryuk
Ryuk is a helper container that Testcontainers boots alongside. It cleans up leftover containers even when the test process dies abnormally, by tracking labels on the Docker engine.
Modules
Per-system modules exist so we don't have to assemble the connection string and port mapping by hand each time.
PostgreSQLContainer · MySQLContainer · MongoDBContainer
KafkaContainer · RedisContainer · RabbitMQContainer
LocalStackContainer
GenericContainer
Each module exposes helpers like getJdbcUrl() and getMappedPort(...).
4. Code shape per language
Java · JUnit
This is the best-known pairing. Spring Boot integration examples show up frequently in the official docs.
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> pg =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("app")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", pg::getJdbcUrl);
r.add("spring.datasource.username", pg::getUsername);
r.add("spring.datasource.password", pg::getPassword);
}
}
Node.js
import { GenericContainer } from "testcontainers";
const container = await new GenericContainer("redis:7-alpine")
.withExposedPorts(6379)
.start();
const url = `redis://${container.getHost()}:${container.getMappedPort(6379)}`;
Python · pytest
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def pg():
with PostgresContainer("postgres:16-alpine") as c:
yield c
The flow is the same across all three SDKs. Boot, connect, clean up.
5. Comparison with other paths
On the mocking side we have in-memory DBs like H2, SQLite, and embedded Postgres. They are fast but easy to lose regressions through SQL behavior gaps. Mock objects like JMock, sinon, and unittest.mock belong in unit tests, not integration tests. HTTP stubs like WireMock, MockServer, and Prism work well for emulating external APIs.
On the real-instance side, a Docker Compose dev environment is one option — a human boots it ahead of time, and tests reuse it. A team-shared DB makes isolation hard. CI service containers like GitHub Actions' services: are another shape.
What sets Testcontainers apart is that the test itself owns booting and cleaning up its dependency. That brings strong isolation and portability.
6. Reuse mode
Booting a fresh container per test makes startup time pile up. Testcontainers offers a label-based reuse mode.
TESTCONTAINERS_REUSE_ENABLE=true
It fits the local-dev iteration loop. CI usually keeps it off (isolation first).
7. CI environment
Default GitHub-hosted runners ship with Docker installed, so things often work out of the box. Linux runners are the natural fit; Docker availability differs on macOS and Windows runners.
Self-hosted runners or other CI may run Docker daemon inside a container (DinD). Testcontainers may need extra environment variables to handle the host mapping for child containers.
TESTCONTAINERS_HOST_OVERRIDE=...
Container pulling and startup time pile up on the first run. Alpine variants are usually faster, and image caching helps a lot.
8. Easy stumbling spots
If the local machine or CI has no Docker daemon, tests can't even start. Image pulling is often blocked behind proxies and VPNs, so an internal mirror or pre-cache is worth setting up.
Pinning host ports collides with parallel tests. Use auto mapping wherever possible. When security policy restricts label or socket access, the Ryuk cleanup container can't operate. Disabling it via env var is possible but leaves a risk of leftover containers.
Sharing one container across multiple tests creates data interference. Block it with transaction rollback or schema isolation. Booting a heavy container for a small unit test slows down feedback, so split unit and integration tests.
Depending on the latest tag breeds regressions. Pin down to the minor version. ARM hosts running amd64-only images can be slow or break — verify multi-arch coverage. If Testcontainers runs PostgreSQL 16 while production runs PostgreSQL 14, some regressions slip through, so keep versions aligned.
Closing thoughts
Testcontainers sits between mocks and real instances. Mocks are fast but lie sometimes. Shared DBs are hard to isolate. Letting a test boot and clean up its own dependency turns out to be the most relaxed option. The next post moves into Vitest and the real-world vitest/pytest setup.
Next
- vitest-philosophy
- vitest-pytest-real-world
Reads well alongside LocalStack and MiniStack — emulating AWS locally. See also Testcontainers official, the PostgreSQL module, the LocalStack module, and the Spring Boot guide.