Testcontainers Advanced: Multi-Container Setups, Compose Modules, and Parallel Tests

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 code

scope='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 it

Parallel 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=true

Testcontainers 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=4

With @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=true

Disable Ryuk only if your CI runner handles cleanup via a separate step.

Key Points

  • Create a Network to connect multiple containers and reference each other by network alias
  • Use the Docker Compose module to reuse your existing docker-compose.yml in 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
  • WaitAllStrategy combines multiple wait conditions for services that aren't ready when the port opens
  • Start multiple containers in parallel (Promise.all in Node, parallel @Container fields 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

Read more