Test Containers: Isolated Environments for Every Test Run
The biggest source of unreliable integration tests is shared state. When multiple developers run tests against the same test database, or when CI pipelines reuse a database that was not properly cleaned up from the last run, tests fail for reasons unrelated to the code being tested. Someone else's test data causes unexpected query results. A schema migration was applied to one environment but not another. The test database is running PostgreSQL 14 while production uses PostgreSQL 16. Testcontainers eliminates all of these problems by creating a disposable environment per test run.
How Testcontainers Works
The lifecycle is simple. Before tests run, Testcontainers starts one or more Docker containers using images you specify (postgres:16, redis:7, mongo:7, etc.). Each container gets a random available port on the host machine, so multiple test runs can execute simultaneously without port conflicts. Your test code retrieves the container's host and port, uses them to configure the application's database connection or service client, and runs tests against the real service. When the test suite finishes (or the JVM/process exits), the containers are automatically stopped and removed.
The startup time for most containers is 2 to 5 seconds. PostgreSQL starts in about 3 seconds, Redis in under 1 second, Elasticsearch in about 8 seconds. This overhead is negligible compared to the time spent on the tests themselves, and it is a one-time cost per test run rather than per individual test. The containers persist for the duration of the test suite, so hundreds of tests share the same container without repeated startup costs.
Testcontainers requires Docker to be available. On developer machines, Docker Desktop or OrbStack handles this. In CI environments, most platforms provide Docker by default (GitHub Actions, GitLab CI, CircleCI all run jobs in environments with Docker available). For CI platforms that run jobs inside containers (Docker-in-Docker scenarios), Testcontainers supports alternative container runtimes and can be configured to use the host's Docker daemon.
Setup by Language
Node.js (JavaScript/TypeScript)
Install the testcontainers package:
npm install --save-dev testcontainers
Start a PostgreSQL container in your test setup:
import { PostgreSqlContainer } from '@testcontainers/postgresql';
let container;
let connectionUrl;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16')
.withDatabase('testdb')
.withUsername('test')
.withPassword('test')
.start();
connectionUrl = container.getConnectionUri();
// Pass connectionUrl to your ORM or database client
}, 30000);
afterAll(async () => {
await container.stop();
});
test('inserts and retrieves a user', async () => {
// Your test code using the real PostgreSQL instance
});
The getConnectionUri() method returns a full connection string like postgresql://test:test@localhost:55432/testdb with the dynamically assigned port. Pass this to your application's database configuration and your tests run against a real PostgreSQL 16 instance that is completely isolated from everything else.
Python
Install the testcontainers package:
pip install testcontainers[postgres]
Use it in a pytest fixture:
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:16") as pg:
yield pg
@pytest.fixture
def db_connection(postgres):
engine = create_engine(postgres.get_connection_url())
# Run migrations
Base.metadata.create_all(engine)
with engine.connect() as conn:
yield conn
def test_insert_user(db_connection):
# Test against real PostgreSQL
pass
The scope="session" on the fixture means the container starts once for the entire test session and is shared across all tests. If you need complete isolation per test (each test gets its own database), change the scope to "function" and accept the additional startup time.
Java
Add the Testcontainers dependency to your build file:
// Gradle
testImplementation 'org.testcontainers:testcontainers:1.20.0'
testImplementation 'org.testcontainers:postgresql:1.20.0'
testImplementation 'org.testcontainers:junit-jupiter:1.20.0'
Use the @Testcontainers annotation with JUnit 5:
@Testcontainers
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@BeforeAll
static void setUp() {
// Configure datasource with:
// postgres.getJdbcUrl()
// postgres.getUsername()
// postgres.getPassword()
}
@Test
void shouldInsertUser() {
// Test against real PostgreSQL
}
}
Java's Testcontainers has the most mature ecosystem with dedicated modules for over 50 services including PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Elasticsearch, Kafka, RabbitMQ, Localstack (AWS services emulation), and many more.
Go
Install the testcontainers-go module:
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
Use it in a test function:
func TestUserRepository(t *testing.T) {
ctx := context.Background()
pgContainer, err := postgres.Run(ctx,
"postgres:16",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForListeningPort("5432/tcp"),
),
)
defer pgContainer.Terminate(ctx)
connStr, _ := pgContainer.ConnectionString(ctx)
// Use connStr to connect and run tests
}
Common Service Containers
Redis
Redis containers start in under 1 second, making them nearly free to include in test suites. Use them to test caching logic, session storage, and pub/sub functionality against the real Redis engine rather than mocks:
// Node.js
import { GenericContainer } from 'testcontainers';
const redis = await new GenericContainer('redis:7')
.withExposedPorts(6379)
.start();
const redisHost = redis.getHost();
const redisPort = redis.getMappedPort(6379);
MongoDB
MongoDB containers test document operations, aggregation pipelines, and index behavior against the real MongoDB engine:
# Python
from testcontainers.mongodb import MongoDbContainer
with MongoDbContainer("mongo:7") as mongo:
client = MongoClient(mongo.get_connection_url())
db = client.testdb
# Run tests against real MongoDB
Kafka
Kafka containers test event-driven architectures end to end: producer, broker, consumer. Testcontainers handles the ZooKeeper dependency (or uses KRaft mode for Kafka 3.3+) automatically:
// Java
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
// Use kafka.getBootstrapServers() to configure producers/consumers
LocalStack (AWS Services)
LocalStack emulates AWS services (S3, DynamoDB, SQS, SNS, Lambda, and more) in a single container. Test your AWS integration code without connecting to real AWS, avoiding costs and credential management in test environments:
// Node.js
import { LocalstackContainer } from '@testcontainers/localstack';
const localstack = await new LocalstackContainer('localstack/localstack:3')
.start();
const s3Client = new S3Client({
endpoint: localstack.getConnectionUri(),
region: 'us-east-1',
credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
});
Patterns for Effective Use
Session Scoped Containers
Start containers once per test session rather than per test. A PostgreSQL container takes 3 seconds to start. Starting it once for 200 tests adds 3 seconds total. Starting it per test adds 600 seconds (10 minutes). Use session/module-level fixtures or static containers with JUnit's @Container annotation.
Database Per Test with Schemas
Instead of starting a new container per test, create a new database or schema within the same container for each test. PostgreSQL supports creating databases in milliseconds. Each test gets complete isolation without container startup overhead:
// Create a fresh schema per test
const schemaName = `test_${randomUUID().replace(/-/g, '')}`;
await client.query(`CREATE SCHEMA ${schemaName}`);
await client.query(`SET search_path TO ${schemaName}`);
Transaction Rollback
Wrap each test in a database transaction that gets rolled back at the end. The test sees its own writes during execution, but nothing persists after the test finishes. This is the fastest isolation technique because it avoids both container startup and schema creation overhead.
Custom Docker Images
When your tests need specific database extensions, seed data, or configuration, build a custom Docker image and use it with Testcontainers. The image can include pre-loaded schemas, custom PostgreSQL extensions (PostGIS, pgvector), or specific configuration files that mirror production.
CI Configuration
Most CI platforms provide Docker by default, making Testcontainers work without special configuration. A few considerations:
GitHub Actions: Docker is available on ubuntu-latest runners out of the box. No additional setup needed. Set the environment variable TESTCONTAINERS_RYUK_DISABLED=true if you encounter issues with the resource reaper (Ryuk) container, though this is rarely needed on modern runners.
GitLab CI: When running jobs in Docker containers (the default), enable Docker-in-Docker by adding the docker:dind service and setting DOCKER_HOST=tcp://docker:2375. Alternatively, use the shell executor or a Kubernetes executor with Docker socket mounting.
Jenkins: Docker must be available on the agent where tests run. When using the Docker pipeline plugin, mount the host's Docker socket into the build container: -v /var/run/docker.sock:/var/run/docker.sock. This lets Testcontainers create sibling containers on the host rather than nested containers.
Cache Docker images in CI to avoid pulling them from the registry on every run. GitHub Actions can cache the Docker layer cache, and all platforms support pulling images in a setup step that runs before the test job.
Testcontainers vs Docker Compose vs Service Containers
Testcontainers provides the finest-grained control. Containers are started and stopped from within test code, so different test classes can use different database versions or configurations. The test code is self-documenting: you can see exactly which services a test needs by looking at its setup. Best for integration tests that need precise control over the environment.
Docker Compose defines the complete environment in a YAML file. The CI pipeline starts all services with one command, runs all tests, and tears everything down. Simpler to set up than Testcontainers but less flexible: all tests share the same containers, and you cannot vary the environment per test class. Best for smaller projects or when all tests need the same set of services.
CI service containers (GitHub Actions services, GitLab services) are similar to Docker Compose but managed by the CI platform. They are the simplest option, requiring just a few lines in the CI configuration file. Limited to services that can be started before the job begins and shared across all steps. Best for straightforward setups with one or two dependencies.
Testcontainers eliminates "works on my machine" problems by giving every test run a fresh, isolated environment with real databases and services. Start containers at the session level to keep overhead low, use transaction rollback or schema isolation for per-test freshness, and let Docker images match your production versions exactly.