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.
- Testcontainers’ own release: 2.0.0 release notes.
- The companion repo for this article, including both legacy-check transcripts: testcontainers2 module.
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.
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.
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 thatPostgreSQLContainerdid more than move package —org.testcontainers.postgresql.PostgreSQLContainerdropped the self-referential generic the oldorg.testcontainers.containers.PostgreSQLContainer<SELF extends PostgreSQLContainer<SELF>>carried for source compatibility. A text-only rename of the import still leavesPostgreSQLContainer<?> 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.
- The full artifact rename list and sub-recipe breakdown: Migrate to testcontainers-java 2.x (OpenRewrite docs).
- The dependency-only sub-recipe with the complete old-to-new artifact table: Rename Testcontainers dependencies.
- The PostgreSQLContainer generics change, verified against the real jar: @DataJpaTest in Spring Boot 4.1 with Testcontainers @ServiceConnection.
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:
@ClassRuleJavadoc. - 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: aninitializationErroron a test class that has an@EnabledIfDockerAvailableannotation 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@Containerfields — those start inbeforeAll, ahead of any method guard.
- How this plays out identically under Spring Boot’s
@DataJpaTestslice, with the guard correctly on the class from the start: @DataJpaTest in Spring Boot 4.1 with Testcontainers @ServiceConnection. - The extension’s source, for the exact lifecycle order: TestcontainersExtension.java.
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.
If upgrading isn’t an option right now: the thread on #11235 documents the workaround — setapi.version=1.44by hand in a~/.docker-java.propertiesfile 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.
- The issue thread with the exact error and the workaround: testcontainers-java#11235.
- The release that made the change: 2.0.2 release notes.
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;@ServiceConnectionitself needed zero changes for this release (itsJdbcContainerConnectionDetailsFactoryis 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/@ClassRulefor 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’sTestcontainers2Migrationrecipe 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
- Companion repository for this article: testcontainers2, in the JUnit_Tutorials repository.
- The previous post on this blog, which this one cross-references throughout for the parts of the 2.0 migration already covered under Spring Boot 4.1: @DataJpaTest in Spring Boot 4.1 with Testcontainers @ServiceConnection.
- Testcontainers’ full release notes: testcontainers-java releases.
- OpenRewrite’s migration recipe: Migrate to testcontainers-java 2.x.
- JUnit 4’s rule mechanism, for anyone still assessing how much of it a codebase depends on:
@ClassRuleJavadoc. - The Docker Engine 29 compatibility thread: testcontainers-java#11235.
No Comments yet!