Resource library

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.

Related Guides