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, 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
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());// 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
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:16-alpine") as container:
run_migrations(container.get_connection_url())
yield container#Beyond databases
// 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.
.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:
# ~/.testcontainers.properties
testcontainers.reuse.enable=truenew 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:
CREATE DATABASE test_run_1 TEMPLATE app_template; -- milliseconds, not secondsOne database per parallel worker — see parallel test execution.
#In CI
# 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 integrationTestNo 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 ↗- TypeScript
typescript/src/tools/testcontainers
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Integration TestingTesting your code against real dependencies — databases, HTTP clients, message brokers — with Testcontainers, and what belongs at this level rather than above or below it.
- Database TestingTesting migrations, constraints, queries and transactions — the layer everything else depends on, and the one most suites replace with a mock.
- Running Tests in ParallelHow parallelism turns hidden coupling into failures, the shared resources that clash, and how to make a suite genuinely safe to run concurrently.
- Testing in GitHub ActionsA complete pipeline — unit tests, integration with containers, sharded Playwright, coverage and artefacts — plus the caching and concurrency settings that make it fast.
- Unit Testing without MocksThe sociable approach — real collaborators, state-based assertions, and tests that survive refactoring. What it buys, where it breaks down, and how to keep it fast.
- Testing in Azure DevOps PipelinesA full YAML pipeline with stages, jobs, parallel test slicing and the best built-in test reporting of any CI platform.
- Testing in JenkinsDeclarative pipelines, parallel stages, agent control and JUnit reporting — how to run a modern test suite on the CI server you probably inherited.
- Terraform for Test InfrastructureProvisioning ephemeral test environments as code — per-branch stacks, workspaces, cost controls, and testing the Terraform itself.