Skip to content
End To End Tester

Testcontainers

Running real databases, brokers and services as disposable containers from inside your test suite — the pattern that made integration testing cheap.

2 min read · updated 19 September 2026

Testcontainers starts a real dependency as a container, from inside your test process, and throws it away afterwards. It is the single change that made integration testing cheap enough to do properly, and it is why the modern testing pyramid is drawn with a fatter middle than it used to be.

The alternative it replaced — a shared test database that drifts from production, cannot be reset, and prevents parallel runs — is worth remembering when the one-second startup cost feels annoying.

#The basic shape

java
// Java, JUnit 5
@Testcontainers
class OrderRepositoryTest {

    @Container
    static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine");   // the same image production runs

    static DataSource dataSource;

    @BeforeAll
    static void migrate() {
        dataSource = dataSourceFor(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
        Flyway.configure().dataSource(dataSource).load().migrate();
    }

    @Test
    void rejectsADuplicateReference() {
        var repository = new OrderRepository(dataSource);
        repository.save(new Order("REF-1", 4_000));

        assertThatThrownBy(() -> repository.save(new Order("REF-1", 5_000)))
            .isInstanceOf(DuplicateReferenceException.class);
    }
}

static matters: one container per class rather than per test. Container startup dominates the runtime, and this single detail is usually the difference between a suite people run and one they skip.

Running the real migrations is the other important part — it proves the migration produces the schema the code expects, which is a surprisingly common production incident.

#Other languages

typescript
// TypeScript
import { PostgreSqlContainer } from '@testcontainers/postgresql';

let container: StartedPostgreSqlContainer;

beforeAll(async () => {
  container = await new PostgreSqlContainer('postgres:16-alpine').start();
  await migrate(container.getConnectionUri());
}, 60_000);                      // generous timeout: the first pull is slow

afterAll(async () => container.stop());
csharp
// C#
public sealed class PostgresFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
        .WithImage("postgres:16-alpine")
        .WithCleanUp(true)
        .Build();

    public string ConnectionString => _container.GetConnectionString();

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        await Migrations.ApplyAsync(ConnectionString);
    }

    public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}
python
# Python
@pytest.fixture(scope="session")
def postgres():
    with PostgresContainer("postgres:16-alpine") as container:
        run_migrations(container.get_connection_url())
        yield container

#Beyond databases

java
// Kafka
@Container static final KafkaContainer KAFKA =
    new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));

// Redis, or anything else, via GenericContainer
@Container static final GenericContainer<?> REDIS =
    new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379)
        .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1));

// LocalStack for AWS services
@Container static final LocalStackContainer AWS =
    new LocalStackContainer(DockerImageName.parse("localstack/localstack:3"))
        .withServices(S3, SQS);

// Your own service, from its real Dockerfile
@Container static final GenericContainer<?> BILLING =
    new GenericContainer<>(new ImageFromDockerfile().withDockerfile(Path.of("../billing/Dockerfile")))
        .withExposedPorts(8080)
        .waitingFor(Wait.forHttp("/health").forStatusCode(200));

That last one is significant: a real instance of another team's service, run from its own Dockerfile, has none of the drift problems of a stub.

#Wait strategies

A started container is not a ready one, and this is the most common source of a flaky Testcontainers suite.

java
.waitingFor(Wait.forHttp("/health").forStatusCode(200))
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*", 2))
.waitingFor(Wait.forListeningPort())
.withStartupTimeout(Duration.ofSeconds(120))

The Postgres module's built-in strategy waits for the log line twice, because Postgres logs it once during initialisation and again when it is genuinely accepting connections. Homemade wait strategies that miss this kind of detail are exactly the flakiness source to watch for.

#Making it fast

Share at the right scope. Per class, or per worker. Never per test.

Reuse across runs, locally:

properties
# ~/.testcontainers.properties
testcontainers.reuse.enable=true
java
new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true)

The container survives the JVM exiting, so the second local run starts instantly. Do not enable reuse in CI — you want a clean container there, and a reused one will eventually accumulate state that makes a build pass for the wrong reason.

Template databases for a per-test clean slate without a restart:

sql
CREATE DATABASE test_run_1 TEMPLATE app_template;   -- milliseconds, not seconds

One database per parallel worker — see parallel test execution.

#In CI

yaml
# GitHub Actions: ubuntu-latest already has Docker. That is the whole setup.
jobs:
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin', cache: 'gradle' }
      - run: ./gradlew integrationTest

No services: block, no health-check scripting, no port allocation. The tests own their dependencies, which means they run identically on a laptop and on a runner. Full pipelines in GitHub Actions.

#The trade-off, stated plainly

Testcontainers moves cost from maintaining fakes to starting containers. For repository code, query code, migrations and message handling that is a clearly good trade: the fake could not have caught the bugs that matter, and a second of startup is cheap.

For domain logic it is a bad trade. A pricing rule tested through a database is a hundred times slower and no more correct. Keep the unit suite free of containers entirely.

Common questions

Are Testcontainers tests too slow for CI?
Not if the container is shared at the right scope. Starting Postgres takes about a second; doing it once per test class or once per worker rather than once per test is the difference between a four-minute suite and a forty-second one.
Can I use Testcontainers without Docker?
It needs a container runtime, but not Docker Desktop specifically — Podman, Colima, Rancher Desktop and remote Docker hosts all work. Testcontainers Cloud runs the containers remotely if local Docker is not available.
Does this replace mocking the database?
For repository and query code, largely yes — and it should, because a mocked database cannot tell you that your SQL is wrong. Domain logic above the repository still belongs in fast unit tests with no container at all.

Runnable samples for this page

last test results ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?