QA How-To
Testcontainers for integration tests: Step by Step (2026)
Use Testcontainers for integration tests with Java, JUnit, PostgreSQL, migrations, wait strategies, parallel CI, lifecycle control, and cleanup patterns.
23 min read | 3,149 words
TL;DR
Declare a real dependency container in the test class, let JUnit manage its lifecycle, read dynamic connection details from the running container, apply production schema migrations, and test observable behavior. Pin compatible images and current Testcontainers 2.x modules so local and CI runs use the same disposable environment.
Key Takeaways
- Use real disposable dependencies when database, broker, or protocol behavior is part of the risk, and keep mocks for narrower collaboration tests.
- Testcontainers 2.x uses prefixed module artifacts and relocated module classes, so copy dependencies and imports from current documentation.
- Let the JUnit extension own container start and stop, and choose static or instance fields based on the isolation boundary you need.
- Apply the same production migration path to the container before executing repository tests.
- Wait for application readiness, not merely a listening port, when using generic containers.
- Use mapped host ports from container APIs and network aliases only for container-to-container communication.
- Make parallel tests own their schemas or data, and diagnose CI runtime capability separately from test assertions.
Testcontainers for integration tests gives Java test code a real, disposable database, broker, cache, or service dependency through a Docker-compatible runtime. Instead of pointing every developer and CI job at one fragile shared database, the test starts a known dependency, discovers its mapped connection details, verifies behavior, and removes the dependency afterward.
The result is not automatically a good integration test. You still need the right boundary, production-like schema setup, readiness checks, deterministic data, useful failure logs, and a CI runtime that can launch containers. This guide builds those pieces with current Testcontainers 2.x and JUnit APIs.
TL;DR
| Decision | Recommended default | Change it when |
|---|---|---|
| Dependency type | Real container for infrastructure behavior | A mock fully represents the risk |
| Container lifecycle | Static field per test class | Every test needs a pristine process |
| Database state | Migrate once, reset owned data per test | Schema-per-test isolation is affordable |
| Image | Explicit compatible tag | A controlled update validates a new tag |
| Readiness | Module default or semantic wait | Listening port is not enough |
| CI | Same Maven test command as local | Runtime needs remote or rootless configuration |
| Parallelism | Separate data namespace per worker | Tests are proven read-only |
The central rule is simple: connect through values returned by the running container. Do not assume localhost ports, container IP addresses, startup order, or credentials.
1. Decide when to use Testcontainers for integration tests
Use a container when correctness depends on behavior that an in-memory substitute or mock may not reproduce. SQL dialect, transaction isolation, indexes, case rules, JSON operators, message acknowledgment, cache expiry, object-store metadata, and HTTP proxy behavior are common examples.
Keep the boundary intentional. A repository integration test might start PostgreSQL and call repository methods directly. A service integration test might start PostgreSQL plus a broker and call a service method. A component test might start the packaged application and call its HTTP API. Wider boundaries increase confidence in wiring, but also increase setup, diagnosis, and runtime cost.
| Dependency approach | Strength | Blind spot | Good use |
|---|---|---|---|
| Mock | Fast, precise interaction control | Does not prove protocol behavior | Unit and collaboration tests |
| In-memory substitute | Fast and simple | May differ in dialect and concurrency | Compatible algorithms |
| Shared test service | Real implementation | State collision, drift, availability | Governed managed services |
| Testcontainers dependency | Real implementation and isolated lifecycle | Requires a compatible runtime | Databases, brokers, caches |
Do not containerize everything by habit. A containerized database cannot prove a managed cloud service's identity policy. A fake HTTP server may be a better boundary for a vendor API. The WireMock stubbing guide explains when controlled HTTP behavior is more valuable than a full downstream service.
Write the risk statement in the test name. For example: verify that an upsert preserves one row under a PostgreSQL unique constraint. That risk justifies real PostgreSQL behavior.
2. Prepare Java, the runtime, and the project
Use a supported Java release, Maven or Gradle, and a container runtime recognized by Testcontainers. Confirm all three before debugging test code:
java -version
mvn -version
docker version
docker run --rm hello-world
The last command proves more than a running desktop icon. It verifies that the current user can reach the daemon, pull an image, create a container, and receive output.
Testcontainers 2.x introduced changes that matter to 2026 projects:
| Concern | Testcontainers 1.x example | Testcontainers 2.x |
|---|---|---|
| PostgreSQL artifact | org.testcontainers:postgresql | org.testcontainers:testcontainers-postgresql |
| JUnit Jupiter artifact | org.testcontainers:junit-jupiter | org.testcontainers:testcontainers-junit-jupiter |
| PostgreSQL package | org.testcontainers.containers | org.testcontainers.postgresql |
| JUnit 4 support | Present in old releases | Removed |
| Module naming | Mixed names | testcontainers prefix |
Generic container infrastructure remains under org.testcontainers.containers, while module-specific classes moved to module packages. Do not combine a current artifact with an import from an old tutorial.
Create a standard project with test code under src/test/java. Keep container libraries in test scope unless product runtime intentionally launches containers, which is uncommon.
3. Add current Testcontainers 2.x dependencies
The following Maven fragments use Java 21, JUnit Jupiter 6.0.3, Testcontainers 2.0.5, PostgreSQL support, and PostgreSQL JDBC 42.7.13. The driver version includes current maintenance and security fixes available on the publication date.
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>2.0.5</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.13</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
</plugins>
</build>
The BOM keeps Testcontainers modules aligned. The image tag is a separate version decision from the Java library and JDBC driver. Updating PostgreSQL server versions can change application behavior even when the test library stays constant.
Run Maven after adding the class in the next section. A dependency-resolution failure is different from a runtime failure, so read the first cause before changing Docker configuration.
4. Write a runnable PostgreSQL integration test
The JUnit extension discovers fields annotated with @Container. A static field starts once before tests in the class and stops after the class. PostgreSQL exposes a random host port, so the test reads its JDBC URL instead of assuming 5432.
Create src/test/java/com/qajobfit/ProductRepositoryTest.java:
package com.qajobfit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Testcontainers
class ProductRepositoryTest {
@Container
static final PostgreSQLContainer postgres =
new PostgreSQLContainer("postgres:17-alpine")
.withDatabaseName("catalog")
.withUsername("catalog_user")
.withPassword("catalog_password");
@BeforeEach
void resetSchema() throws Exception {
try (Connection connection = openConnection();
var statement = connection.createStatement()) {
statement.execute("""
CREATE TABLE IF NOT EXISTS products (
id BIGSERIAL PRIMARY KEY,
sku VARCHAR(40) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL
)
""");
statement.execute("TRUNCATE TABLE products RESTART IDENTITY");
}
}
@Test
void insertsAndReadsProductUsingRealPostgres() throws Exception {
try (Connection connection = openConnection();
PreparedStatement insert = connection.prepareStatement(
"INSERT INTO products(sku, name) VALUES (?, ?)")) {
insert.setString(1, "KB-100");
insert.setString(2, "Mechanical Keyboard");
assertEquals(1, insert.executeUpdate());
}
try (Connection connection = openConnection();
PreparedStatement query = connection.prepareStatement(
"SELECT name FROM products WHERE sku = ?")) {
query.setString(1, "KB-100");
try (ResultSet result = query.executeQuery()) {
assertTrue(result.next());
assertEquals("Mechanical Keyboard", result.getString("name"));
}
}
}
private Connection openConnection() throws Exception {
return DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword()
);
}
}
Run it with mvn test. The example proves lifecycle, mapped connectivity, PostgreSQL SQL behavior, prepared statements, and state reset. Replace direct JDBC with your real repository after this foundation works.
5. Apply production migrations and seed essential data
Creating DDL inline makes the example self-contained, but a real suite should execute the same migration path as the application, such as Flyway or Liquibase. That tests migration order, SQL compatibility, constraints, and the state a new deployment receives.
Apply migrations once after the container starts, then reset mutable rows between tests. Rebuilding a large schema before every method is wasteful unless pristine schema state is the requirement. A clean database process plus idempotent production migrations is usually the class-level baseline.
Seed only reference data required by the scenario. Huge snapshots slow startup, hide intent, and become stale. Builders or fixture APIs should create the exact customer, product, or permission a test needs.
| Reset strategy | Isolation | Speed | Main caution |
|---|---|---|---|
| Transaction rollback | Strong in one transaction boundary | Fast | Product code may open another transaction |
| Truncate owned tables | Good with correct ordering | Fast | Parallel tests can delete shared data |
| Schema per worker | Strong | Moderate | Migration and cleanup cost |
| Container per test | Strongest process reset | Slowest | Startup and migration cost |
| Run-specific keys | Good logical isolation | Fast | Every query must scope correctly |
Test migrations as first-class release assets. A fresh container proves a clean install, not an upgrade from an older production schema. Maintain focused upgrade tests for supported starting versions.
6. Choose lifecycle and state isolation deliberately
A static @Container field follows the class lifecycle. An instance field follows each test-method lifecycle. Static is usually the best starting point for databases because startup and migration cost more than row cleanup.
Container lifetime does not automatically define data lifetime. A class-scoped process can still provide isolated transactions, schemas, or unique keys. A method-scoped process can still conflict through an external shared API or static application cache.
Use manual start() and stop() only when the extension cannot express the lifecycle. Manual ownership requires try/finally cleanup even when setup throws. The extension is easier to review and less likely to leak resources.
Development-time reuse can reduce local startup, but suite correctness must not depend on preserved containers. Reused state can make an order-dependent test appear green. Treat reuse as an optional developer optimization, not a CI isolation model.
JUnit parallel execution requires a separate data strategy. Two methods sharing one database and both truncating tables are unsafe. Disable method parallelism for that class, use one schema per test or worker, or create unique data with scoped cleanup. For Java framework lifecycle tradeoffs, see TestNG vs JUnit 5 for testers.
7. Configure images, ports, files, and environment safely
Pin an explicit compatible image tag. A floating latest tag can make an unchanged commit fail after an image update. Review image compatibility, vulnerabilities, architecture, and database upgrade implications.
Never connect to a fixed host port without a specific requirement. Module URLs and getMappedPort return how the runtime actually published the service. Container internal IP addresses are not a durable host connection contract.
For a generic service, expose its internal port and read the mapped value:
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine"))
.withExposedPorts(6379);
redis.start();
String host = redis.getHost();
Integer port = redis.getMappedPort(6379);
redis.stop();
In production test code, manage this with @Container or guaranteed cleanup. The snippet focuses on the correct mapping API.
Use withEnv for nonsecret configuration. Supply secrets from an approved test secret mechanism only when necessary, and keep them out of logs. Host file mounts make tests path-sensitive, so prefer classpath resources copied through supported container APIs.
Explicitly configure locale, time zone, architecture expectations, and filesystem permissions when a scenario depends on them. A test should not pass only because the developer laptop happens to match production.
8. Wait for readiness and preserve diagnostic logs
A running container process may not be ready. Module classes include service-aware behavior, but GenericContainer often needs a semantic wait when an open port is insufficient.
This runnable fragment waits for an HTTP 200 response:
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.DockerImageName;
GenericContainer<?> nginx =
new GenericContainer<>(DockerImageName.parse("nginx:1.27.0-alpine3.19-slim"))
.withExposedPorts(80)
.waitingFor(Wait.forHttp("/").forStatusCode(200));
Other choices include Wait.forHealthcheck() and Wait.forLogMessage(pattern, count). Use a readiness log only when it is stable and emitted after the service can accept real work. Do not add an arbitrary sleep. It will be too short on a slow CI runner and waste time on a fast one.
Startup and readiness answer different questions. A one-shot migration container should start and exit successfully. A database should stay running and accept connections. An application endpoint may need to prove that its required dependencies are usable.
On failure, capture container logs, image name, runtime facts, and the root exception. Avoid printing all logs on every successful run, especially when they may contain secrets. A timeout is evidence to inspect, not an automatic reason to increase the timeout.
9. Connect multiple dependencies through a shared network
Host test code connects through mapped ports. Containers talking to each other should share a Testcontainers network and use network aliases. Do not pass a host-mapped port to another container as though it were an internal address.
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.utility.DockerImageName;
Network network = Network.newNetwork();
GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine"))
.withNetwork(network)
.withNetworkAliases("cache")
.withExposedPorts(6379);
A second container on that network addresses cache:6379. The Java process addresses redis.getHost() and redis.getMappedPort(6379). Those perspectives are intentionally different.
Start independent containers in parallel when the dependency graph allows it. Start an application after required infrastructure is ready, or give the application proper connection retries. Its readiness should include the ability to use critical dependencies, not merely opening an HTTP socket.
Keep the topology small. A universal base class that launches every service slows every test and makes failures vague. If browser infrastructure is your boundary, Docker for Playwright covers browser image concerns separately.
10. Run Testcontainers in CI
The runner needs a compatible container runtime, permission to use it, registry access, and sufficient disk, memory, and CPU. Hosted Linux runners commonly provide Docker. Hardened, rootless, nested, or remote runners need explicit supported configuration.
A basic GitHub Actions job uses the same Maven command as local development:
name: Java integration tests
on:
pull_request:
push:
branches: [main]
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Verify runtime
run: docker version
- name: Run integration tests
run: mvn --batch-mode --no-transfer-progress test
Do not disable cleanup mechanisms merely to silence an unexplained runner error. Understand runtime compatibility and security policy first. Orphaned resources consume capacity and can leak state.
Cache Maven dependencies. Image caching depends on runner lifecycle. Self-hosted images may pre-pull approved dependencies, but the image digest and update process must remain traceable.
Never make a required integration job silently skip when Docker is absent. A local profile may allow skipping, but CI should prove that the tests actually executed.
11. Keep the suite fast, parallel, and diagnosable
Measure image pull, container startup, migrations, fixture creation, assertions, and cleanup separately. Reusing a container cannot help if every method imports a giant dataset.
Group tests by required topology. Repository tests can share a class-scoped PostgreSQL process with safe reset. Broker-only tests should not start PostgreSQL. Avoid inheritance that hides expensive global fixtures.
Parallelize at a boundary with real isolation. Class-level parallelism works when classes own containers. Method-level parallelism against one mutable database needs schemas or unique keys. CI sharding can multiply image pulls, database processes, and memory, so tune from runner evidence.
Classify flaky integration failures:
- Test-data race or cleanup collision.
- Runtime startup or image pull.
- Application timing.
- Dependency behavior.
- CI resource exhaustion.
- Genuine product defect.
Retries blur these categories. Preserve first-attempt logs and fix the cause. A stable integration suite may be slower than unit tests, but it should be much more deterministic than a shared environment.
Assertions should describe product behavior: expected row, constraint, message, or response. Container diagnostics explain infrastructure failure, but container started successfully is not a meaningful product assertion.
12. Operationalize Testcontainers for integration tests
Create a platform contract covering supported Java and runtime versions, allowed registries, image pinning, cleanup, CI capability, secret handling, and ownership. This prevents each project from inventing incompatible setup.
Run a dependency version matrix only when it supports a real compatibility claim. Pull requests can test the production database major version, while a scheduled job tests the next upgrade candidate. More combinations without a support reason add maintenance, not confidence.
Review container updates like application dependencies. Read release notes, account for Testcontainers 2.x package changes, update one dimension at a time, and run clean local plus CI builds. Pinning creates a controlled update point, not permission to ignore updates.
Testcontainers for integration tests closes the gap between isolated unit tests and expensive shared-system tests. It does not replace staging, production-scale performance tests, managed-service policy validation, or migration rehearsal against production-sized data. Use it where real dependency behavior is the risk, and keep every test boundary explicit.
Interview Questions and Answers
Q: What problem does Testcontainers solve?
It starts disposable real service dependencies from test code and exposes their runtime connection details. This reduces reliance on shared manually managed services. It is most useful when real protocol or infrastructure behavior is part of the risk.
Q: What is the difference between static and instance @Container fields?
A static field follows the class lifecycle, while an instance field follows the method lifecycle. Static containers are faster because methods share one process. Instance containers provide a stronger reset at greater runtime cost.
Q: Why use getMappedPort or getJdbcUrl?
The runtime may publish an internal port to any available host port and may run remotely. Accessors return reachable values. Hard-coded ports create collisions and fail outside one laptop configuration.
Q: What changed in Testcontainers 2.x?
JUnit 4 support was removed, module artifacts gained the testcontainers prefix, and module-specific classes moved into module packages. PostgreSQL build coordinates and imports differ from many 1.x examples.
Q: How do you keep database tests isolated and fast?
Use a class-scoped container, migrate once, and reset owned state by rollback, truncation, schemas, or unique keys. Parallel methods must never delete each other's rows. Choose the lightest boundary that preserves determinism.
Q: What is a wait strategy?
It defines when a started service is usable. HTTP status, health checks, and readiness log patterns can be more accurate than a listening port. Arbitrary sleep delays are unreliable.
Q: Can Testcontainers replace all mocks?
No. Mocks are valuable for fast isolated logic and controlled failures. Containers validate real dependency behavior. A good suite uses both at appropriate boundaries.
Q: How do you troubleshoot a CI failure?
Verify runtime access and image pull as the job user. Inspect the root exception, container logs, disk, memory, permissions, networking, and architecture. Separate infrastructure launch failure from product assertion failure.
Common Mistakes
- Copying 1.x imports into a 2.x project: Use current prefixed artifacts and relocated module packages.
- Using floating image tags: Pin a compatible tag and update through review.
- Hard-coding localhost ports: Read URLs, host, and mapped ports from the container.
- Sleeping for startup: Use module readiness or a semantic wait.
- Starting one database per method by default: Prefer class scope with safe state reset when process isolation is unnecessary.
- Truncating shared tables during parallel tests: Allocate schemas or unique data, or disable unsafe concurrency.
- Replacing production migrations with test-only DDL: Run the real migration path so drift becomes visible.
- Starting every dependency in a base class: Use the smallest topology that proves the risk.
- Skipping when Docker is absent: Make required CI coverage fail clearly on missing capability.
- Treating logs as assertions: Test product behavior and attach infrastructure logs for diagnosis.
- Assuming teardown always runs: Use expiry or independent cleanup for durable external resources.
- Claiming a local image proves cloud behavior: Provider identity, policy, network, and scaling risks need other tests.
Conclusion
Testcontainers for integration tests works best as a precise boundary tool. Add current 2.x modules, let JUnit own lifecycle, connect through discovered values, migrate the real schema, isolate state, and preserve runtime evidence when failure occurs.
Run the PostgreSQL example locally and in CI before adding frameworks or more services. Then introduce only dependencies and scenarios that close a real coverage gap. The suite will be slower than unit tests, but much more trustworthy than tests based only on mocks or a drifting shared database.
Interview Questions and Answers
What is Testcontainers for Java?
It is a Java library that manages disposable containerized dependencies for tests. Tests declare real databases, brokers, caches, emulators, or generic images and receive reachable connection values. It reduces dependence on shared test infrastructure.
When would you choose Testcontainers over a mock?
I choose it when SQL dialect, transactions, protocol, serialization, acknowledgment, or another real dependency behavior is the risk. I keep mocks for fast logic tests and controlled collaboration failures.
What does the JUnit @Container annotation do?
The Testcontainers extension finds annotated fields and manages their start and stop around the appropriate lifecycle. Static fields are class-scoped, while instance fields are method-scoped. The class also uses @Testcontainers.
Why are mapped ports important?
The runtime can publish an internal port to any free host port, preventing collisions and supporting parallel runs. Host test code reads the actual mapping. Container-to-container traffic normally uses an alias and internal port.
What are major Testcontainers 2.x migration changes?
Module artifacts gained the testcontainers prefix, module classes moved to module packages, and JUnit 4 support was removed. Teams must update both build coordinates and imports.
How do you initialize a database container?
For real projects I run the same Flyway or Liquibase migrations used by the application, then create only scenario data. Migration failure stops the suite before behavior assertions.
How do you isolate parallel database tests?
Options include rollback, schema per worker, unique run keys, and separate containers. Shared truncation is unsafe when methods overlap. I choose the cheapest boundary that prevents cross-test reads and deletes.
What is the purpose of a wait strategy?
It tells Testcontainers when a started service is usable. HTTP status, health checks, or log-message waits can express readiness more accurately than an open port. Fixed sleeps are unreliable.
How would you debug a failure that occurs only in CI?
I verify runtime access and image pull under the job user. Then I inspect logs, the root exception, disk, memory, network, permissions, and architecture. I compare facts with local runtime behavior.
Should container reuse be enabled in CI?
CI correctness should not depend on reusable containers because state may persist. Class-scoped lifecycle within one run is different and useful. Development reuse should remain an optional speed aid.
How do you keep a Testcontainers suite fast?
I use the smallest topology, class-scoped containers, one migration pass, focused fixtures, and safe class-level parallelism. I measure pull, startup, migration, test, and cleanup independently.
What does Testcontainers not prove?
A local image does not prove production scale, managed-service identity rules, cloud policy, existing-data upgrades, or full deployment wiring. Those risks require additional environment and system tests.
Frequently Asked Questions
Does Testcontainers require Docker?
It requires access to a compatible container runtime that Testcontainers can detect and use. Docker is common, but supported setup depends on the runtime, operating system, and environment configuration.
What version should a new Java project use in 2026?
Use a current reviewed Testcontainers 2.x release and its BOM, then follow matching module documentation. This article uses 2.0.5, whose artifacts and packages differ from 1.x examples.
Should a database container start per test or per class?
Start once per class by default and isolate rows, transactions, or schemas between methods. Use one process per method only when a pristine database process is necessary and the extra runtime is acceptable.
Can Testcontainers tests run in GitHub Actions?
Yes, when the runner exposes a compatible container runtime with sufficient permissions and resources. Run the same Maven or Gradle command, and fail clearly if runtime capability is missing.
How do tests connect to a random container port?
Module containers provide URLs and credentials, while generic containers provide getHost and getMappedPort. Use those returned values instead of fixed host ports.
What is the Testcontainers JDBC URL?
The jdbc:tc scheme lets supported database modules launch a temporary container when a JDBC connection is created. It is concise, while an explicit container object gives more lifecycle and configuration control.
Are Testcontainers tests integration or end-to-end tests?
The tool does not define the level. A repository with PostgreSQL is an integration test, while a packaged application with multiple services may be a component or end-to-end test.