View all services
Talk to QA Advisor
Browse the Knowledge Hub211 resources
/Tools Library/Testcontainers
Tools Library · Review and guide

Testcontainers review: tutorial, pros and cons

Testcontainers is a family of open-source libraries that start real dependencies, such as databases, message brokers, browsers or any other image, in throwaway Docker containers from inside test code and remove them afterwards. Implementations exist for Java, Go, .NET, Node.js, Python and other languages; Docker sponsors the Java and Go libraries.

QAble has evaluated this tool and works alongside it. The factual sections are complete; our view is bounded to what we have actually seen rather than a long-term verdict.

last reviewed
13 October 2026
version covered
Testcontainers for Java 2.0
licence
Open source

Reviewed by Viral Patel

Co-Founder, QAbleLast reviewed 13 October 2026

At a glance

Testcontainers at a glance

Official site
Vendor
Docker (sponsor) and community
Category
Containers, Mocking & virtualisation
Licence
Open source (MIT (Java, Go, .NET, Node.js); Apache 2.0 (Python))
Platforms
JVM, Go, .NET, Node.js, Python
Languages
Java, Go, C#, JavaScript, Python
Integrates with
JUnit, Docker, Selenium, Kafka, PostgreSQL
First released
2015
Version reviewed
Testcontainers for Java 2.0

Verdict

Our verdict on Testcontainers

Strengths

  • Tests hit the real database engine and version, so SQL dialect and driver problems surface before production
  • Each container gets random host ports, so parallel builds on one machine never fight over 5432
  • The Ryuk reaper removes containers when the test process ends, even after a crash
  • Modules wrap PostgreSQL, Kafka, LocalStack, Selenium browsers and more with ready-made readiness checks
  • Free and open source, with implementations for Java, Go, .NET, Node.js, Python and others

Weaknesses

  • Every developer machine and CI runner needs a Docker-API compatible container runtime
  • Image pulls and container start-up add seconds per container, and minutes on a cold machine
  • Java 2.0 renamed artifacts and packages, so older examples fail to compile
  • The JUnit 5 extension is documented as tested only with sequential execution
  • Hosted macOS and Windows GitHub runners lack Linux container support, so these tests need Linux runners

Our take

Testcontainers is the most direct fix for two problems integration tests usually have: mocks that pass while the real database disagrees, and shared test environments that other runs leave dirty. It fits best where tests exercise code that talks to a database, a broker or an HTTP dependency, and where every developer machine and CI runner can reach a Docker-compatible engine. QAble has evaluated it rather than maintained a long-running suite on it, so treat this as a bounded take. Watch three things. Start-up time, which you control by sharing containers per class or per suite. The container runtime on CI, which the job must be able to reach. And Testcontainers for Java 2.0, which renamed every module artifact, moved container classes into new packages and dropped JUnit 4, so most examples online need their imports and dependencies changing.

We have evaluated this tool rather than maintained a long-running suite on it, so this is a bounded view rather than a long-term verdict. The rest of this page is factual.

How it works

What is Testcontainers and how does it work?

A Docker API client inside your test

Testcontainers talks to a container engine through the Docker API, the same API the docker command uses. On first use it looks for an engine through DOCKER_HOST, the default socket, Docker Desktop or its own configuration file, runs a few startup checks, pulls the image if needed and creates the container. Ports you ask it to expose are published on random free ports on the host, and the test asks for the real address with getHost and getMappedPort. That indirection is deliberate: it is what lets two builds on one machine run the same tests at once, and it is why any test that hardcodes localhost:5432 breaks.

Wait strategies decide when a test may start

A running container is not necessarily a ready service. Every container has a wait strategy that start() blocks on: by default it waits for the first exposed port to accept connections, and modules ship strategies suited to their service. For your own images, the Wait class offers strategies based on an HTTP endpoint, a log message or the image’s own Docker health check. Getting this right is most of the work of writing a reliable custom container, because a strategy that fires too early produces exactly the intermittent connection failures the library is meant to remove.

Ryuk cleans up after you

Alongside your containers, Testcontainers starts a small container called Ryuk, the resource reaper. Ryuk keeps a connection to the test process and, when that connection ends, removes the containers, networks and volumes created in that session. That is why a run interrupted by a crash or a killed IDE does not leave databases running for days. Ryuk needs access to the Docker socket and, in some environments, privileged mode. Where that is not allowed, as with rootless Podman, TESTCONTAINERS_RYUK_DISABLED=true turns it off, and cleanup then relies on the process exiting normally.

You choose the lifecycle

How long a container lives is the main lever on speed and isolation. With the JUnit 5 extension, an instance field annotated with @Container starts and stops for every test method; a static field starts once per test class. The singleton pattern goes further: a container started in a static block of a shared base class lives for the whole test run, and Ryuk removes it at the end. Reusable containers, which survive between runs, are documented as experimental and unsuitable for CI. Most suites settle on singletons for expensive services, with test data isolated per test.

Strengths

What it does well

Modules that know their service

Modules wrap common services with sensible defaults, the right wait strategy and convenience methods: a database module returns a JDBC URL, user and password, and a Kafka module returns bootstrap servers. The catalogue covers relational and NoSQL databases, brokers, cloud emulators such as LocalStack and search engines. Using a module rather than a generic container removes most of the configuration and readiness guesswork.

Containers from a JDBC URL

For Java code that only accepts a connection string, a special URL such as jdbc:tc:postgresql:17:///shop starts a matching database container when the first connection opens. It is useful for frameworks configured by properties files, and for legacy suites where changing test setup code is harder than changing a URL.

Spring Boot service connections

Spring Boot’s @ServiceConnection annotation reads connection details straight from a container field and overrides the matching configuration properties, so a PostgreSQLContainer configures the application’s data source with no properties file at all. It needs the spring-boot-testcontainers dependency and removes the usual hand-written property plumbing.

Whole environments from a Compose file

ComposeContainer starts the services in an existing Compose file from a test, using Compose v2, and exposes their ports through the same random-port mechanism. Teams that already describe their dependencies in Compose for local development can reuse that file in tests instead of maintaining a second definition.

Clean browsers per test

The Selenium module starts browsers from the docker-selenium images and returns a RemoteWebDriver, choosing an image compatible with the Selenium version on the classpath. Each test can get a fresh browser with no leftover cookies or cache, and the module can record a video of each session, or only of failing ones, which is useful evidence when a UI test fails in CI.

Runs on more than Docker Desktop

Any Docker-API compatible runtime works. The docs give the settings for Colima, Podman and Rancher Desktop, and Testcontainers Cloud runs the containers on Docker’s infrastructure instead of the local machine. The free Testcontainers Desktop app switches runtimes, can freeze a container to stop it shutting down while you debug, and can proxy a service to a fixed port for inspection.

Pricing

Testcontainers pricing and licensing

The libraries are free and open source, with no paid edition and no feature held back: modules, framework integrations, Ryuk and Compose support are all included. Docker, which sponsors the Java and Go implementations, sells Testcontainers Cloud as minutes bundled with its paid plans, and offers Testcontainers Desktop as a free download that requires signing in. The real costs are the container runtime on every machine that runs the tests, CI time, and image storage.

  • Licence: MIT for the Java, Go, .NET and Node.js implementations and Apache 2.0 for Python, per each repository’s LICENSE file.
  • No paid tier for the libraries themselves: every module, the JUnit integration, the Compose module and the resource reaper are free.
  • Testcontainers Cloud: Docker Pro, Team and Business include 100, 500 and 1,500 runtime minutes a month, with more available to buy; Docker Personal gets a trial.
  • Runtime licensing: on developer laptops the engine is often Docker Desktop, which needs a paid Docker subscription in organisations of 250 or more employees or $10 million or more in revenue. Podman, Colima and Rancher Desktop are documented alternatives.
  • CI cost: image pulls and container start-up on every job. Mirror images in your own registry, which also avoids Docker Hub rate limits, and share containers across tests to keep start-ups down.

Checked against the vendor’s own pages on 13 October 2026. Pricing changes; confirm on the official site before buying.

Getting started

Testcontainers tutorial: getting started

Setup, step by step

  1. Install a Docker-compatible runtime and confirm docker info works from the same user that runs your tests.
  2. Add testcontainers-junit-jupiter, testcontainers-postgresql and the PostgreSQL JDBC driver as test dependencies; the module does not include a driver.
  3. Annotate the test class with @Testcontainers and declare the container as a static @Container field.
  4. Read the JDBC URL, user and password from the container instead of hardcoding a host and port.
  5. Run the test. The first run pulls the image and is slow; later runs start in seconds.

Install

// build.gradle (Maven uses the same coordinates with test scope)
testImplementation "org.testcontainers:testcontainers-junit-jupiter:2.0.5"
testImplementation "org.testcontainers:testcontainers-postgresql:2.0.5"
testImplementation "org.postgresql:postgresql:42.7.14"
// plus junit-jupiter from your existing JUnit setup

A first example

// src/test/java/com/example/OrderStoreIT.java
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer; // 2.0 package

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Testcontainers
class OrderStoreIT {

    // static: one container shared by every test in this class
    @Container
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");

    @Test
    void storesAnOrder() throws Exception {
        // The host port is random, so always ask the container for the URL
        try (Connection c = DriverManager.getConnection(
                postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
             Statement s = c.createStatement()) {
            s.execute("create table orders (id serial primary key, total numeric)");
            s.execute("insert into orders (total) values (42.50)");
            ResultSet rs = s.executeQuery("select count(*) from orders");
            rs.next();
            assertEquals(1, rs.getInt(1));
        }
    }
}

What trips people up first

Could not find a valid Docker environment. Testcontainers found no engine it could reach, usually because Docker is not running, the user cannot access the socket, or the runtime is not Docker Desktop. Colima, Podman and Rancher Desktop each need DOCKER_HOST and sometimes TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE or TESTCONTAINERS_HOST_OVERRIDE set as the docs describe; rootless Podman also needs Ryuk disabled. The second hurdle is old examples: on Testcontainers for Java 2.0 the artifact is testcontainers-postgresql, not postgresql, and the class lives in org.testcontainers.postgresql, so copied code fails to compile until both are updated.

In practice

Using Testcontainers in practice

Sharing expensive containers across the suite

Starting a database per test class is fine for ten classes and painful for two hundred. The singleton pattern from the Testcontainers docs starts the container once, in a static block of an abstract base class, and every test class that extends it reuses the same instance; Ryuk removes it when the run ends. The trade is isolation, which you get back at the data level: run each test in a transaction that rolls back, truncate the tables a test touches in a before-each hook, or give each test class its own schema. Because start() is called explicitly, do not also annotate the field with @Container, or the extension will stop it after the first class.

// src/test/java/com/example/PostgresTestBase.java
import org.testcontainers.postgresql.PostgreSQLContainer;

public abstract class PostgresTestBase {

    // Started once for the whole run; Ryuk removes it at the end
    protected static final PostgreSQLContainer POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer("postgres:17")
            .withDatabaseName("shop")
            .withInitScript("schema.sql"); // from src/test/resources
        POSTGRES.start();
    }
}

// Each test class extends the base and isolates its own data
class InvoiceStoreIT extends PostgresTestBase { /* tests */ }

Wiring containers into Spring Boot tests

In a Spring Boot application, @ServiceConnection removes the property plumbing that older examples did with @DynamicPropertySource. Spring reads the container’s connection details and uses them for the data source, overriding any spring.datasource settings, so the test configuration contains nothing environment-specific. Add spring-boot-testcontainers alongside the Testcontainers modules. The static field gives one container per test class; combine it with the singleton base class above when several classes share the database. Spring caches application contexts between test classes with identical configuration, so keeping container definitions consistent across classes also avoids rebuilding the context.

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@Testcontainers
@SpringBootTest
class CheckoutServiceIT {

    // Spring derives the data source from this container
    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");

    @Test
    void placesAnOrder() {
        // inject the service and exercise it against the real database
    }
}

Running Testcontainers suites in GitHub Actions

GitHub-hosted Ubuntu runners have Docker, so a Testcontainers suite needs no special setup: check out, set up the JDK and run the build. Hosted macOS and Windows runners do not provide Docker for Linux containers, so keep these jobs on Linux. Sign in to Docker Hub or set TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX to pull from a mirror, since shared runner IP addresses hit the anonymous pull limit. If the build itself runs inside a container, for example on a self-hosted Jenkins agent, the documented pattern is to mount the Docker socket and the project directory at the same path inside the build container, so Testcontainers starts sibling containers on the host engine.

# .github/workflows/integration.yml
name: integration
on: [push, pull_request]
jobs:
  integration:
    runs-on: ubuntu-latest   # hosted macOS and Windows lack Linux containers
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '21'
          cache: maven
      # Authenticated pulls avoid the anonymous per-IP limit
      - uses: docker/login-action@v4
        with:
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - run: ./mvnw -B verify
      - uses: actions/upload-artifact@v7
        if: failure()
        with:
          name: test-reports
          path: target/failsafe-reports

Keeping a growing suite fast

Speed problems usually come from the number of container starts, not from the containers themselves, so count them before optimising anything else. Pin image tags so CI caches stay valid, and prefer small official images. Group tests that need the same set of services, and use singletons for anything slow to boot, such as Kafka or a search engine. Reusable containers, enabled with withReuse(true) plus testcontainers.reuse.enable=true in the user’s ~/.testcontainers.properties, keep a container alive between local runs and can cut the edit-run loop to almost nothing, but the docs mark them experimental and unsuited to CI, so keep them a local convenience. Once the environment is stable, checks.disable=true skips the startup checks.

The same model in Node.js and other languages

The other implementations follow the same ideas with each language’s conventions: a generic container class, modules for common services, random host ports, a wait strategy and Ryuk. In Node.js, containers are started in beforeAll and stopped in afterAll, and modules are separate packages such as @testcontainers/postgresql or @testcontainers/redis. In Python, containers are context managers, so a with block guarantees the stop. Feature coverage differs between languages, and only the Java and Go libraries are sponsored by Docker, so check that the modules you need exist in your language before standardising on it.

// redis.test.ts: npm install testcontainers redis --save-dev
import { createClient, RedisClientType } from 'redis'
import { GenericContainer, StartedTestContainer } from 'testcontainers'

describe('cache', () => {
  let container: StartedTestContainer
  let client: RedisClientType

  beforeAll(async () => {
    container = await new GenericContainer('redis:8').withExposedPorts(6379).start()
    // Host and port come from the container, never hardcoded
    client = createClient({ url: `redis://${container.getHost()}:${container.getMappedPort(6379)}` })
    await client.connect()
  })

  afterAll(async () => {
    await client.disconnect()
    await container.stop()
  })

  it('stores a value', async () => {
    await client.set('key', 'val')
    expect(await client.get('key')).toBe('val')
  })
})

Pitfalls

Common pitfalls

Hardcoding localhost and the default port

Why it happens: Testcontainers publishes exposed ports on random host ports so parallel runs cannot collide, and in some CI setups the container is not even on localhost.

The fix: Build every address from getHost() and getMappedPort(), or from module helpers such as getJdbcUrl(), and pass them into the code under test.

Copying 1.x examples into a 2.0 project

Why it happens: Testcontainers for Java 2.0, released on 14 October 2025, prefixed every module artifact with testcontainers-, moved container classes into per-module packages such as org.testcontainers.postgresql, removed JUnit 4 support and dropped modules’ default constructors.

The fix: Update coordinates (org.testcontainers:postgresql becomes testcontainers-postgresql), fix imports, pass an explicit image name to every module constructor, and move JUnit 4 @Rule or @ClassRule tests to the JUnit 5 extension or manual start() calls.

A new container for every test method by accident

Why it happens: An @Container field without static is restarted for each test method, which is correct for strict isolation but turns a two-minute suite into a twenty-minute one.

The fix: Make shared containers static, or use the singleton base class, and isolate data with transactions, truncation or schemas instead of fresh containers.

Running the JUnit 5 extension with parallel test execution

Why it happens: The documentation states that the extension has only been tested with sequential execution and that parallel use may have unintended side effects.

The fix: For parallel suites, start shared containers yourself with the singleton pattern, so lifecycle does not depend on the extension, and design test data so concurrent tests cannot see each other’s rows.

Ryuk failing to start on locked-down CI or rootless Podman

Why it happens: Ryuk needs the Docker socket and sometimes privileged mode, which hardened runners and rootless runtimes refuse.

The fix: Set TESTCONTAINERS_RYUK_DISABLED=true only where the environment already removes containers after each job, such as ephemeral CI runners, and keep it enabled on shared machines.

Client version too old errors after upgrading Docker

Why it happens: Docker Engine 29.0 raised the minimum API version it accepts to 1.44, and older Testcontainers releases requested an earlier API version. Engine 29.3 later lowered the minimum to 1.40.

The fix: Upgrade Testcontainers rather than downgrading Docker: Java 2.0.2 made API 1.44 the default and 2.0.3 added a fallback for older engines. Keep the library current alongside runner upgrades.

Compared

How Testcontainers compares

ToolWhat the test talks toFidelity to productionNeeds a container runtimeLifecycle owned byLicence
TestcontainersReal service in a throwaway containerHigh: same engine and versionYes, Docker-API compatibleThe test codeMIT; Python port Apache 2.0
DockerReal services started by ComposeHigh, when image tags are pinnedYesA CI script or the developerApache 2.0 engine and Compose
WireMockSimulated HTTP APIOnly as good as the stubsNo; in process or standaloneTest code or a standalone serverApache 2.0
MockitoMock objects in the same JVMLow: no real I/O happensNoThe test codeMIT

FAQ

Frequently asked questions

What is Testcontainers?

Testcontainers is a family of open-source libraries that start real dependencies, such as databases, message brokers, browsers or any other image, in throwaway Docker containers from inside test code and remove them afterwards. Implementations exist for Java, Go, .NET, Node.js, Python and other languages; Docker sponsors the Java and Go libraries.

Is Testcontainers free?

Yes. The libraries are open source, MIT-licensed for Java, Go, .NET and Node.js and Apache 2.0 for Python, with no paid edition. What can cost money is around it: Docker Desktop needs a paid subscription in larger organisations, and Testcontainers Cloud, which runs containers on Docker’s infrastructure, is bundled as monthly minutes with Docker’s Pro, Team and Business plans.

Testcontainers or Docker Compose for integration tests?

Compose starts an environment before the test run, owned by a script; Testcontainers starts dependencies from inside the tests, owned by test code, with random ports and automatic cleanup. Testcontainers suits integration tests that a developer runs from the IDE with no setup step. Compose suits end-to-end environments shared by several suites. They combine well: ComposeContainer can start an existing Compose file from a test.

Should I use Testcontainers or mocks?

Use both, for different questions. Mocks such as Mockito, and HTTP stubs such as WireMock, are fast and good at testing your logic and error handling. They cannot tell you whether your SQL works on the real database version, whether a migration runs, or how a driver behaves. Testcontainers answers those. Many teams keep unit tests on mocks and run a smaller integration layer against real dependencies.

Does Testcontainers work without Docker Desktop?

Yes. It needs a Docker-API compatible runtime, not Docker Desktop specifically. Docker Engine on Linux works with no configuration, and the documentation gives the settings for Colima, Podman and Rancher Desktop. Rootless Podman also needs the Ryuk reaper disabled. Testcontainers Cloud is another option, running containers remotely so the laptop needs no local engine.

How do I migrate to Testcontainers for Java 2.0?

Change each module dependency to its testcontainers- prefixed artifact, for example testcontainers-postgresql and testcontainers-kafka, and update imports to the per-module packages, such as org.testcontainers.postgresql.PostgreSQLContainer. Pass an explicit image name to module constructors, since default constructors were removed, and move any JUnit 4 rules to the JUnit 5 extension. Using the testcontainers-bom keeps every module on the same version.

Can Testcontainers run in CI?

Yes, wherever the job can reach a container engine. GitHub-hosted Ubuntu runners work out of the box; hosted macOS and Windows runners do not provide Linux containers. On self-hosted agents that run builds inside containers, mount the Docker socket and the project directory at the same path, as the documentation describes. Authenticate to Docker Hub or mirror images to avoid pull rate limits.

What are Testcontainers Cloud and Testcontainers Desktop?

Testcontainers Cloud is a Docker service that runs the containers your tests start on remote infrastructure instead of the local engine, paid through minutes included in Docker’s Pro, Team and Business plans. Testcontainers Desktop is a free app, which requires sign-in, for switching between local runtimes or the cloud, freezing containers so they stay up while you debug, and exposing services on fixed ports.

Alternatives

Testcontainers alternatives worth weighing

Resources

Where to go next

Ready

Picking the wrong tool costs more than picking late.

QAble advises on toolchain decisions and then implements them, with engineers who have run these tools on real delivery. Start with a free QA audit.

Talk to QA Advisor