Skip to main content

Migrating to Testcontainers 2.0: Artifact Renames, Package Moves and JUnit 6

Testcontainers 2.0 bundles three independent changes under one version number: 61 artifacts renamed with a testcontainers- prefix, JUnit 4 support physically removed from the GenericContainer type hierarchy (not deprecated — verified by jar inspection, zero classes implement TestRule), and the default Docker API version bumped from 1.32 to 1.44, which is what actually fixes the real Docker Engine 29 compatibility break reported in testcontainers-java#11235. Includes a self-caught mistake: @EnabledIfDockerAvailable only works on the class, not the method, because TestcontainersExtension starts @Container fields in beforeAll before any method-level condition runs.

Testcontainers 2.0 shipped on October 14, 2025, and it is not a routine point release. Bump the version number in an existing project and several independent things can break at once — a dependency that no longer resolves, code that no longer compiles, and a container that no longer starts, each for a different reason. This article walks through what actually changed, verified against the real Maven Central artifact history, the real compiled jars, and a real GitHub issue thread, rather than against a changelog’s summary of itself.

Versions used in this article. Testcontainers 2.0.5 (the version Spring Boot 4.1.1’s own BOM pins — see the previous post), compared throughout against 1.21.4, the last release under the old artifact names. JUnit Jupiter 6.1.3. JDK 25 (Temurin). Companion module built and tested in a sandbox with no Docker daemon available — said plainly wherever it matters, rather than glossed over.

Why your Testcontainers upgrade just broke

You change one line — the version in your BOM, or the version pinned on a single dependency — and the build stops. Not with a warning. With a dependency your project has used for years suddenly reported as missing from Maven Central entirely:

[ERROR] Failed to execute goal on project legacy-check: Could not resolve dependencies for project com.ankurm:legacy-check:jar:1.0.0
[ERROR] dependency: org.testcontainers:postgresql:jar:2.0.5 (compile)
[ERROR] 	Could not find artifact org.testcontainers:postgresql:jar:2.0.5 in central (https://repo.maven.apache.org/maven2)

Full output in 05-legacy-pom-fails-to-resolve-under-2.0.5.txt. That dependency is not gone — it moved. org.testcontainers:postgresql was retired in favor of org.testcontainers:testcontainers-postgresql, and the retirement is total: there is no 2.0.5 release under the old name to fall back to, ever. The same project, with no code changes at all, compiled cleanly one version earlier:

[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ legacy-check ---
[INFO] Compiling 1 source file with javac [debug target 21] to target/test-classes
[INFO] BUILD SUCCESS

Full output in 04-legacy-pom-compiles-under-1.21.4.txt. The source file is a classic Testcontainers 1.x JUnit 4 test:

public class ClassicJUnit4RuleTest {

    @ClassRule
    public static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");

    @Test
    public void containerRuleFieldExists() {
    }
}

Nothing in that file is exotic — it is the idiom most Testcontainers-plus-JUnit-4 tutorials still teach. The one-line version bump breaks it before the compiler even gets a chance to care whether @ClassRule still makes sense on this field. And it would stop making sense anyway: that is the second, deeper change this release makes, and renaming the artifact back wouldn’t fix it. The rest of this article is three separable stories — an artifact rename, a dropped integration, and a quiet default change — that happen to share one version number.

Three unrelated changes wearing one version number

It helps to separate them before looking at any one in detail, because they fail differently and they are fixed differently. None of the three causes the others.

The rename. Every per-database, per-messaging-system module Testcontainers ships — postgresql, kafka, mongodb, junit-jupiter, sixty-one artifacts in total — got a testcontainers- prefix added to its artifact ID. Same group ID, same Maven Central repository, new coordinates. This is purely a dependency-management problem: your build file is wrong, your code is not.

The removal. Testcontainers’ container classes used to double as JUnit 4 TestRule implementations, for free, by inheritance. Testcontainers 2.0’s own release notes say plainly: Remove JUnit 4 support. This is a source-compatibility problem. Fixing the artifact name does not fix this one.

The default change. The Docker Engine API version this client falls back to when nothing else is configured moved from 1.32 to 1.44. Most of the time this is invisible — it only matters once your Docker Engine itself stops accepting 1.32, which is exactly what Docker Engine 29 does. This is a runtime problem, and it can go the opposite direction from the other two: it is the one change in this release that makes some older configurations work again, rather than breaking newer ones.

One version number, three independent changes The rename 61 artifacts get a testcontainers- prefix fails: dependency resolution The removal GenericContainer no longer implements TestRule fails: @ClassRule semantics The default change fallback Docker API version 1.32 → 1.44 fixes: Docker Engine 29+ fixed by: editing your pom/build.gradle fixed by: migrating off @Rule to JUnit 5 fixed by: nothing — it’s just the new default None of the three causes or fixes the others. A project can hit all three, two, one, or none, depending entirely on whether it still uses old artifact IDs, still uses JUnit 4 rules, and what Docker Engine version it runs against.

The next three sections take each one in turn, each with its own verification — against a real Maven Central response, a real jar, and a real GitHub issue, in that order.

Every module gets a testcontainers- prefix

Start with the smallest, clearest case: the JUnit 5 extension artifact. Its old coordinates, org.testcontainers:junit-jupiter, are not deprecated in the sense of still working with a warning. They are frozen. Querying Maven Central directly for that artifact’s version history:

$ curl -sS https://repo1.maven.org/maven2/org/testcontainers/junit-jupiter/maven-metadata.xml
<latest>1.21.4</latest>
<release>1.21.4</release>
... (1.10.0 through 1.21.4, 56 releases total) ...

Full transcript, including the matching query for the new artifact, in 06-maven-central-artifact-history.txt. There is no 2.x entry in that list, and there never will be — this artifact ID’s story on Maven Central ends at 1.21.4. The replacement starts a fresh history from scratch:

$ curl -sS https://repo1.maven.org/maven2/org/testcontainers/testcontainers-junit-jupiter/maven-metadata.xml
<latest>2.0.5</latest>
<release>2.0.5</release>
<versions>
  <version>2.0.0</version>
  <version>2.0.1</version>
  <version>2.0.2</version>
  <version>2.0.3</version>
  <version>2.0.4</version>
  <version>2.0.5</version>
</versions>

That gap — one artifact ID’s history stopping dead at 1.21.4 while a differently-named one starts at 2.0.0 — is the permanent fork point. There is no version number you can put on the old coordinates to get 2.0 behavior; the coordinates themselves had to change.

Two artifact IDs, two separate version histories org.testcontainers:junit-jupiter 1.10.0 ……………………………… 1.21.4 (last ever) org.testcontainers:testcontainers-junit-jupiter 2.0.0 …… 2.0.5 no path between them Bumping the version number on the old coordinates to “2.0.5” resolves to nothing — see docs/output/05 for exactly that failure. The dependency has to be re-pointed, not upgraded.

This one module stands in for the whole release: OpenRewrite’s own published migration recipe, org.openrewrite.java.testing.testcontainers.Testcontainers2Migration, documents the complete list — sixty-one org.testcontainers:<module> artifacts, each renamed to org.testcontainers:testcontainers-<module>, same group ID throughout. The one name on that list that did not change is the plain core artifact, org.testcontainers:testcontainers itself. The recipe is a composite of smaller sub-recipes — one specifically for the dependency renames, one for package-level type changes like DockerComposeContainer → ComposeContainer, one for API members Testcontainers removed from LocalStackContainer — and it is already wired into Spring Boot’s own 4.0 migration recipes, so a project running OpenRewrite for the Spring Boot 4 upgrade picks this up for free.

Mechanical find-and-replace is not enough for one of the sixty-one. The previous post on this blog found that PostgreSQLContainer did more than move package — org.testcontainers.postgresql.PostgreSQLContainer dropped the self-referential generic the old org.testcontainers.containers.PostgreSQLContainer<SELF extends PostgreSQLContainer<SELF>> carried for source compatibility. A text-only rename of the import still leaves PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(...) on the line below it, which no longer compiles. OpenRewrite’s recipe handles this with its own sub-recipe for container class changes; a manual sed script generally will not.

JUnit 4 isn’t deprecated here, it’s gone

Renaming the artifact back in your own build file would not save the @ClassRule test from the first section, and here is why. Testcontainers 1.x never shipped a separate JUnit 4 integration artifact — it didn’t need one. GenericContainer, the base class every container extends, itself extended FailureDetectingExternalResource, which directly implemented org.junit.rules.TestRule:

# unzipped from testcontainers-1.21.4.jar
$ javap -p org/testcontainers/containers/GenericContainer.class
public class org.testcontainers.containers.GenericContainer<SELF extends org.testcontainers.containers.GenericContainer<SELF>>
    extends org.testcontainers.containers.FailureDetectingExternalResource
    implements org.testcontainers.containers.Container<SELF>, java.lang.AutoCloseable,
               org.testcontainers.containers.wait.strategy.WaitStrategyTarget,
               org.testcontainers.lifecycle.Startable {
  ...
}

$ javap -p org/testcontainers/containers/FailureDetectingExternalResource.class
public class org.testcontainers.containers.FailureDetectingExternalResource
    implements org.junit.rules.TestRule {
  public org.testcontainers.containers.FailureDetectingExternalResource();
  ...
}

Full transcript in 02-genericcontainer-testrule-removed-javap.txt. That inheritance chain is the entire reason @ClassRule public static PostgreSQLContainer<?> postgres = ... ever worked with zero extra dependencies: every container class was, by construction, a valid JUnit 4 rule. Checking the exact same class in the 2.0.5 jar:

# unzipped from testcontainers-2.0.5.jar instead
$ javap -p org/testcontainers/containers/GenericContainer.class
public class org.testcontainers.containers.GenericContainer<SELF extends org.testcontainers.containers.GenericContainer<SELF>>
    implements org.testcontainers.containers.Container<SELF>, java.lang.AutoCloseable,
               org.testcontainers.containers.wait.strategy.WaitStrategyTarget,
               org.testcontainers.lifecycle.Startable {
  ...
}

No extends FailureDetectingExternalResource at all. That class, and the TestRule contract it carried, are both absent from the 2.0.5 jar — not relocated, not renamed, gone. To make sure this isn’t one class that happened to change while some sibling kept the old contract, the same module checked every class in each jar:

$ mkdir ext2 && cd ext2 && unzip -oq ../testcontainers-2.0.5.jar
$ grep -rl "org/junit/rules/TestRule" . --include="*.class" | wc -l
0

# Compare: the same search against the 1.21.4 jar
$ mkdir ext1 && cd ext1 && unzip -oq ../testcontainers-1.21.4.jar
$ grep -rl "org/junit/rules/TestRule" . --include="*.class" | wc -l
2

Zero classes anywhere in the 2.0.5 core jar reference org.junit.rules.TestRule. Testcontainers’ own release notes for 2.0.0 confirm this is deliberate, not an oversight: Remove JUnit 4 support, tracked as pull request #10805, with a follow-up cleanup, #10808, titled plainly Fix missing junit4 leftovers.

This module’s own test makes the same check, not against the jar directly but against the classes on its own test classpath, with JUnit 4’s junit:junit added purely as a test-scope dependency so the TestRule interface is available to compare against — Testcontainers 2.0 itself has no JUnit 4 dependency at all, which is the whole point:

class GenericContainerNoLongerImplementsTestRuleTest {

    @Test
    void genericContainerDoesNotImplementTestRule() {
        assertThat(TestRule.class.isAssignableFrom(GenericContainer.class)).isFalse();
    }

    @Test
    void noSuperclassOfGenericContainerImplementsTestRuleEither() {
        Class<?> walk = GenericContainer.class;
        while (walk != null) {
            assertThat(TestRule.class.isAssignableFrom(walk)).isFalse();
            walk = walk.getSuperclass();
        }
    }
}
[INFO] Running com.ankurm.tutorials.junit.testcontainers2.GenericContainerNoLongerImplementsTestRuleTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.166 s

Source: GenericContainerNoLongerImplementsTestRuleTest.java; output from 00-full-test-run.txt. No Docker daemon needed for either assertion — this is a pure type-hierarchy check.

The practical upshot for a project that hasn’t moved to JUnit 5 yet: testcontainers-junit-jupiter is a JUnit 5 extension, not a JUnit 4 rule shim, so it doesn’t paper over this. Testcontainers 2.0 has no JUnit 4 path at all — the JUnit 5 migration and the Testcontainers 2.0 migration, if both are pending, stop being independent projects you can schedule separately.

  • JUnit 4’s rule mechanism, for context on what was being relied on: @ClassRule Javadoc.
  • The two commits that made the removal, cited directly from the release: testcontainers-java#10805, testcontainers-java#10808.

The guard that only works if you put it in the right place

Testcontainers 2.0’s JUnit 5 extension adds one genuinely new piece, not just a renamed package: @EnabledIfDockerAvailable, which lets a test class skip cleanly instead of failing when no Docker daemon is present. It did not exist in the 1.x junit-jupiter artifact at all. The first version of this module’s own test put it in the obvious-looking place — on the test method:

@Testcontainers
class NewPostgresContainerSkipsCleanlyTest {

    @Container
    static GenericContainer<?> postgres = new PostgreSQLContainer("postgres:17");

    @Test
    @EnabledIfDockerAvailable
    void containerIsConfiguredWithTheRelocatedNonGenericClass() {
        ...
    }
}

That compiles. It is also wrong, and running it in this Docker-less sandbox is what caught it:

[ERROR] com.ankurm.tutorials.junit.testcontainers2.NewPostgresContainerSkipsCleanlyTest.initializationError -- Time elapsed: 1.676 s <<< ERROR!
java.lang.IllegalStateException: Could not find a valid Docker environment. Please see logs and check configuration
	at org.testcontainers.dockerclient.DockerClientProviderStrategy.lambda$getFirstValidStrategy$7(DockerClientProviderStrategy.java:274)
	at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:316)
	at org.testcontainers.junit.jupiter.TestcontainersExtension$StoreAdapter.start(TestcontainersExtension.java:276)
	at org.testcontainers.junit.jupiter.TestcontainersExtension.startContainers(TestcontainersExtension.java:83)
	at org.testcontainers.junit.jupiter.TestcontainersExtension.beforeAll(TestcontainersExtension.java:57)

Full stack trace in 01-guard-on-method-still-fails.txt. The stack trace explains exactly why the guard didn’t help: TestcontainersExtension.beforeAll starts every field annotated @Container before JUnit even gets to evaluating any method-level condition. By the time a method-level @EnabledIfDockerAvailable would run, the container has already tried and failed to start. The annotation has to sit on the class, where it gates the extension’s own beforeAll hook before container startup is attempted at all:

@Testcontainers
@EnabledIfDockerAvailable
class NewPostgresContainerSkipsCleanlyTest {

    @Container
    static GenericContainer<?> postgres = new PostgreSQLContainer("postgres:17");

    @Test
    void containerIsConfiguredWithTheRelocatedNonGenericClass() {
        assertThat(postgres.getDockerImageName()).isEqualTo("postgres:17");
        assertThat(postgres).isInstanceOf(PostgreSQLContainer.class);
    }
}
[INFO] Running com.ankurm.tutorials.junit.testcontainers2.NewPostgresContainerSkipsCleanlyTest
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.037 s

Source: NewPostgresContainerSkipsCleanlyTest.java; output from 00-full-test-run.txt. Same class, same field, one annotation moved up one level — Skipped: 1 instead of a stack trace. Note too that this test also demonstrates the relocated class directly: the field type is the unmoved GenericContainer<?>, but the value assigned to it is the new, non-generic org.testcontainers.postgresql.PostgreSQLContainer — the same class the previous post on this blog found living in the testcontainers-postgresql jar alongside its still-present, now-deprecated, self-referentially-generic predecessor.

The fingerprint of this specific mistake: an initializationError on a test class that has an @EnabledIfDockerAvailable annotation somewhere in it, rather than a skip. If the annotation is present anywhere and you’re still seeing a hard failure instead of a clean skip, check whether it landed on a method inside a class that also declares static @Container fields — those start in beforeAll, ahead of any method guard.

The default that quietly changed under you: Docker API 1.32 to 1.44

Not everything in this release is something upgrading breaks. One change in 2.0 fixes a problem that only shows up on current Docker installs, which is why projects that upgraded for the artifact rename sometimes report Testcontainers working better afterward, for a reason that has nothing to do with the rename.

Testcontainers talks to the Docker daemon over its HTTP API, and like any HTTP client it has to pick an API version to request. Decompiling the strategy class that does this, in both jars:

# unzipped from testcontainers-1.21.4.jar
$ javap -v -p org/testcontainers/dockerclient/DockerClientProviderStrategy.class
   ...
   #773 = Utf8               Fallback to Docker API version 1.32.
   #775 = Utf8               VERSION_1_32

# unzipped from testcontainers-2.0.5.jar instead
$ javap -v -p org/testcontainers/dockerclient/DockerClientProviderStrategy.class
   ...
   #742 = Utf8               VERSION_1_44
   #764 = Utf8               Pinging Docker API version 1.44.

Full transcript in 03-docker-api-version-bump-javap.txt. The hardcoded fallback moved from 1.32 to 1.44 between these two releases — confirmed in the bytecode, not inferred from a changelog line. Testcontainers’ own 2.0.2 release notes say exactly this happened, as a one-line fix: Set default docker API version to 1.44.

Here is why that one line matters. Docker Engine 29, shipped inside Docker Desktop 4.52, raised the minimum API version it will accept from any client, and rejects anything older outright: client version 1.32 is too old. Minimum supported API version is 1.44, please upgrade your client to a newer version.

That exact error is reported against Testcontainers 1.21.3 in testcontainers-java#11235 — a project on the 1.x line, with no code changes of its own, started failing the moment the Docker Engine underneath it was upgraded to 29. Multiple commenters on that thread report that upgrading to Testcontainers 2.0.2 made the exact same project work again against the exact same Docker Desktop version. The issue itself was closed as a duplicate of an earlier report rather than with an explicit fix confirmation, but the pattern across both threads is consistent: 1.x plus Docker Engine 29 fails with the 1.32-is-too-old message; 2.0.2 and later do not, because 1.44 is what they ask for by default.

Testcontainers 1.21.4 defaults to API 1.32 Testcontainers 2.0.2+ defaults to API 1.44 Docker Engine 29 (Docker Desktop 4.52+) rejects anything below API 1.44 rejected accepted
If upgrading isn’t an option right now: the thread on #11235 documents the workaround — set api.version=1.44 by hand in a ~/.docker-java.properties file to override the 1.x default without touching the Testcontainers version at all. The other reported workaround, staying on an older Docker Engine build, is not something most projects control for long.

Should you upgrade now

For most projects, yes, and sooner rather than later — but go in knowing which of the three changes from this article actually apply before touching the version number.

When to move now: if your Docker Engine is anywhere near version 29, the default API version change alone is worth the upgrade regardless of anything else in this release — it is a fix, not a migration cost. If you’re already on Spring Boot 4.1, you’re pinned to 2.0.5 by its own BOM whether you’ve noticed the rename or not; @ServiceConnection itself needed zero changes for this release (its JdbcContainerConnectionDetailsFactory is typed against the base class the move never touched), so that half of the migration is already done for you.

When to slow down: if your test suite still leans on JUnit 4 @Rule/@ClassRule for containers, this is not a version bump — it is a migration to JUnit 5 that happens to be forced by a dependency upgrade rather than scheduled on its own terms. Run OpenRewrite’s Testcontainers2Migration recipe first, on a branch, and read its diff before merging; it handles the artifact renames and several of the container-class API changes mechanically, but a JUnit 4-to-5 test runner migration is not something any OpenRewrite recipe in this family claims to do for you.

What this module did not attempt: an actual OpenRewrite dry run against a realistic multi-module project, and an actual container start against a live Docker daemon under both API versions. Both are honest gaps in this sandbox rather than claims made from documentation — the OpenRewrite recipe’s behavior here is quoted directly from its own published documentation pages, which is a primary source for what the recipe does, but it is not the same as having run it.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.