Testcontainers Advanced: Multi-Container Setups, Compose Modules, and Parallel Tests
Testcontainers starts real Docker containers in tests, replacing mocks and in-memory fakes. The basics — starting a single Postgres or Redis container — are covered in the official docs. The advanced features are what scale: multi-container networks, Docker Compose module integration, parallel test execution, custom wait strategies, and resource reuse across test suites.
Multi-Container Setup with Networks
When your application needs multiple services (database + cache + message broker), create a shared Docker network:
Java / JUnit 5
import org.testcontainers.containers.*;
import org.testcontainers.junit.jupiter.*;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
class OrderServiceIntegrationTest {
static Network network = Network.newNetwork();
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withNetwork(network)
.withNetworkAliases("postgres")
.withDatabaseName("orders")
.withUsername("app")
.withPassword("secret");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withNetwork(network)
.withNetworkAliases("redis")
.withExposedPorts(6379);
@Container
static GenericContainer<?> rabbitmq = new GenericContainer<>("rabbitmq:3.13-management-alpine")
.withNetwork(network)
.withNetworkAliases("rabbitmq")
.withExposedPorts(5672, 15672)
.waitingFor(Wait.forHttp("/api/overview")
.forPort(15672)
.withBasicCredentials("guest", "guest"));
@Test
void orderFlowPublishesToQueueAndCachesResult() {
String jdbcUrl = postgres.getJdbcUrl();
String redisHost = redis.getHost();
int redisPort = redis.getMappedPort(6379);
String amqpUrl = String.format("amqp://guest:guest@%s:%d",
rabbitmq.getHost(), rabbitmq.getMappedPort(5672));
// Initialize your service with these connection details
OrderService service = new OrderService(jdbcUrl, redisHost, redisPort, amqpUrl);
Order order = service.createOrder("customer-1", List.of("item-a", "item-b"));
assertNotNull(order.getId());
assertTrue(service.isCached(order.getId()));
assertTrue(service.isQueuedForFulfillment(order.getId()));
}
}Network aliases (postgres, redis, rabbitmq) let containers reach each other by hostname — useful if you're running an application container that connects to these services by name.
Docker Compose Module
For complex multi-service setups, use the Docker Compose module to reuse your existing docker-compose.yml:
import org.testcontainers.containers.DockerComposeContainer;
import org.testcontainers.containers.wait.strategy.Wait;
class DockerComposeIntegrationTest {
@ClassRule
public static DockerComposeContainer<?> compose =
new DockerComposeContainer<>(new File("docker-compose.test.yml"))
.withExposedService("postgres", 5432,
Wait.forListeningPort())
.withExposedService("redis", 6379,
Wait.forListeningPort())
.withExposedService("app", 8080,
Wait.forHttp("/health").forStatusCode(200))
.withLocalCompose(true); // Use locally installed docker-compose
@Test
void appHealthCheckReturns200() throws Exception {
String host = compose.getServiceHost("app", 8080);
int port = compose.getServicePort("app", 8080);
// HTTP request to http://host:port/health
}
}docker-compose.test.yml can override the main compose file with test-specific settings (different image tags, smaller resource limits, or extra services for test helpers).
Python / pytest Integration
import pytest
import testcontainers.postgres as pg
import testcontainers.redis as rd
from testcontainers.core.container import DockerContainer
from testcontainers.core.network import Network
@pytest.fixture(scope='session')
def docker_network():
with Network() as network:
yield network
@pytest.fixture(scope='session')
def postgres_container(docker_network):
with pg.PostgresContainer('postgres:16-alpine') \
.with_network(docker_network) \
.with_network_aliases('postgres') as container:
yield container
@pytest.fixture(scope='session')
def redis_container(docker_network):
with rd.RedisContainer('redis:7-alpine') \
.with_network(docker_network) \
.with_network_aliases('redis') as container:
yield container
def test_order_creation(postgres_container, redis_container):
db_url = postgres_container.get_connection_url()
redis_url = f'redis://{redis_container.get_container_host_ip()}:{redis_container.get_exposed_port(6379)}'
# test codescope='session' starts containers once per pytest session — all tests share the same containers, dramatically reducing startup overhead.
Custom Wait Strategies
The default Wait.forListeningPort() checks if TCP accepts connections, but many services aren't ready when the port opens. Use specific wait strategies:
// HTTP endpoint returns 200
container.waitingFor(Wait.forHttp("/health").forStatusCode(200));
// Log message appears
container.waitingFor(Wait.forLogMessage(".*database system is ready.*\\n", 1));
// Multiple conditions (all must pass)
container.waitingFor(
new WaitAllStrategy()
.withStrategy(Wait.forListeningPort())
.withStrategy(Wait.forLogMessage(".*ready to accept connections.*", 1))
.withStartupTimeout(Duration.ofSeconds(60))
);For Kafka:
KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
.withNetwork(network)
.withNetworkAliases("kafka");
// KafkaContainer has a built-in wait strategy — no need to configure itParallel Test Execution
Running tests in parallel with shared containers requires careful lifecycle management.
Shared Containers (Session Scope)
The safest pattern: start containers once, share across all tests, use separate database schemas or key prefixes:
@Testcontainers
class ParallelIntegrationTest {
// Static = one container per JVM (shared across parallel classes)
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // Ryuk won't kill it between test classes
@BeforeEach
void createSchema() {
// Each test gets its own schema
String schema = "test_" + Thread.currentThread().threadId();
jdbc.execute("CREATE SCHEMA IF NOT EXISTS " + schema);
jdbc.execute("SET search_path TO " + schema);
}
@AfterEach
void dropSchema() {
String schema = "test_" + Thread.currentThread().threadId();
jdbc.execute("DROP SCHEMA " + schema + " CASCADE");
}
}Container Reuse
Enable container reuse with .withReuse(true) to keep containers running between test runs (useful during development):
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true)
.withLabel("testcontainers.label", "my-project-postgres");Also enable in ~/.testcontainers.properties:
testcontainers.reuse.enable=trueTestcontainers computes a hash of the container configuration and reuses an existing container if the hash matches.
JUnit 5 Parallel Configuration
# junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=4With @Container on static fields, containers are shared across parallel class executions. With @Container on instance fields, each test class instance gets its own container (more isolation, more overhead).
Node.js / Vitest
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { RedisContainer, StartedRedisContainer } from '@testcontainers/redis';
let postgres: StartedPostgreSqlContainer;
let redis: StartedRedisContainer;
beforeAll(async () => {
[postgres, redis] = await Promise.all([
new PostgreSqlContainer('postgres:16-alpine').start(),
new RedisContainer('redis:7-alpine').start(),
]);
}, 60_000);
afterAll(async () => {
await Promise.all([postgres.stop(), redis.stop()]);
});
test('saves and retrieves cached user', async () => {
const db = createDbClient(postgres.getConnectionUri());
const cache = createRedisClient({
host: redis.getHost(),
port: redis.getMappedPort(6379),
});
const user = await db.user.create({ data: { name: 'Alice', email: 'alice@example.com' } });
await cache.set(`user:${user.id}`, JSON.stringify(user));
const cached = JSON.parse(await cache.get(`user:${user.id}`) ?? '{}');
expect(cached.name).toBe('Alice');
});Starting containers in parallel with Promise.all cuts startup time roughly in half.
Resource Cleanup
Testcontainers uses Ryuk (a cleanup container) to remove test containers even if tests crash. No manual cleanup needed in most cases. For CI environments where Docker socket access is restricted:
TESTCONTAINERS_RYUK_DISABLED=trueDisable Ryuk only if your CI runner handles cleanup via a separate step.
Key Points
- Create a
Networkto connect multiple containers and reference each other by network alias - Use the Docker Compose module to reuse your existing
docker-compose.ymlin tests .withReuse(true)keeps containers running between test runs — speeds up development iteration- Schema-per-test or key-prefix-per-test lets multiple parallel tests share a single container safely
WaitAllStrategycombines multiple wait conditions for services that aren't ready when the port opens- Start multiple containers in parallel (
Promise.allin Node, parallel@Containerfields in Java) to reduce total startup time scope='session'in pytest fixtures starts containers once per session — the biggest latency win for Python test suites