Docker Compose — Testcontainers

2 minute read

Testcontainers is a library that runs real Docker containers during integration tests. If you have been writing compose.yml files to set up test dependencies, Testcontainers does the same thing — but programmatically, from your test code.

Why Testcontainers

Approach Pros Cons
Mocks/Stubs Fast, no dependencies May miss real integration issues
Shared test DB Simple Tests interfere with each other
Docker Compose Realistic Manual setup, not test-aware
Testcontainers Realistic + test-aware Slightly slower than mocks

How It Works

Test Suite Start
     │
     ├─→ Testcontainers starts a PostgreSQL container
     ├─→ Testcontainers starts a Redis container
     ├─→ Connection URLs are injected into your app config
     │
     ├─→ Tests run against real databases
     │
     └─→ Containers are automatically destroyed

Java Example (JUnit 5)

@Testcontainers
class UserServiceTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);

    @Test
    void shouldCreateUser() {
        // postgres.getJdbcUrl() returns the dynamic connection URL
        // redis.getHost() + redis.getMappedPort(6379) for Redis
        // Write your test using real databases!
    }
}

Python Example (pytest)

import testcontainers.postgres
import testcontainers.redis

def test_with_real_databases():
    with testcontainers.postgres.PostgresContainer("postgres:16-alpine") as postgres:
        with testcontainers.redis.RedisContainer("redis:7-alpine") as redis:
            # postgres.get_connection_url() returns dynamic URL
            # redis.get_container_host_ip() + redis.get_exposed_port(6379)
            # Your test code here
            assert True

Node.js Example

const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { GenericContainer } = require('testcontainers');

describe('API Tests', () => {
  let pgContainer, redisContainer;

  beforeAll(async () => {
    pgContainer = await new PostgreSqlContainer('postgres:16-alpine').start();
    redisContainer = await new GenericContainer('redis:7-alpine')
      .withExposedPorts(6379)
      .start();
  });

  afterAll(async () => {
    await pgContainer.stop();
    await redisContainer.stop();
  });

  test('health check returns ok', async () => {
    // Use pgContainer.getConnectionUri() for DB connection
    // Use redisContainer.getHost() + redisContainer.getMappedPort(6379)
  });
});

Testcontainers vs Compose for Testing

Feature Compose Testcontainers
Setup docker compose up before tests Automatic in test code
Cleanup Manual docker compose down Automatic
Port management Fixed ports (conflicts) Random ports (no conflicts)
Parallelism Hard (port conflicts) Easy (isolated containers)
CI/CD Extra step in pipeline Built into test step
Language Language-agnostic Language-specific libraries

Getting Started

# Java (Maven)
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers</artifactId>
  <scope>test</scope>
</dependency>

# Python
pip install testcontainers

# Node.js
npm install --save-dev testcontainers

Tip: You do not have to choose one or the other. Use Compose for local development environments and Testcontainers for automated integration tests. They solve related but different problems.


Quick Reference

Item Detail
Java library org.testcontainers:testcontainers
Python library pip install testcontainers
Node.js library npm install testcontainers
Supported DBs PostgreSQL, MySQL, MongoDB, Redis, and many more
Key feature Automatic container lifecycle tied to test lifecycle