Skip to main content

@DataJpaTest in Spring Boot 4.1 with Testcontainers @ServiceConnection

@DataJpaTest substitutes an embedded database by default, and Replace.NONE alone doesn’t guarantee a real connection behind it — but the opposite trap is the real story here: @DataJpaTest DOES run your Flyway/Liquibase migrations by default, through an undocumented fifth meta-annotation that merges autoconfiguration imports across three separate jars. Built with no Docker daemon available, with an honest accounting of what ran for real versus what’s verified from source.

A @DataJpaTest class with a PostgreSQLContainer in it reads like proof that your repository works against Postgres. It compiles against the real driver, it imports the real container class, and if you squint at the test output it even runs. Whether it actually touched Postgres at all is a separate question, and this article exists because the honest answer, for a surprising number of real test classes, is no.

@DataJpaTest substitutes an embedded database for whatever DataSource your application would otherwise use, by default, every time. That part is documented and well known. What is less well known — and not written down anywhere I could find before building this module and reading the actual class files — is that @AutoConfigureTestDatabase(replace = Replace.NONE), the annotation that is supposed to turn that substitution off, does not by itself guarantee anything real is on the other end of the connection. The other surprise in this article runs the opposite direction from what an early draft of it claimed: @DataJpaTest does run your Flyway or Liquibase migrations by default, through a mechanism that is not documented for this specific test slice and is easy to miss even when reading the annotation’s own source.

This article builds a small repository test six different ways, reads the real log line each one produces, and settles what each annotation actually does from that — then wires in a real Testcontainers Postgres instance with @ServiceConnection, which is where the honesty gets harder: the sandbox this module was built in has no Docker daemon, and that turned out to be the most useful accident in the whole exercise. Every section below says plainly which half is a real, captured run and which half is read from source and docs because nothing else was available to run it against.

The same repository test, six annotation combinations, six outcomes plain @DataJpaTest embedded H2, rolled back per test method + replace = Replace.NONE still embedded H2 — nothing else was configured + @ServiceConnection + real PostgreSQLContainer real Postgres (needs Docker) flyway-core on the classpath migration runs automatically — no extra annotation needed + @ImportAutoConfiguration (FlywayAutoConfiguration.class) redundant — same migration, once same, no Docker daemon real ContainerFetchException, captured below

Six boxes, four colors this time. The blue and amber boxes both end up on H2 — one on purpose, one by accident. The green boxes are both things that already work without you doing anything extra: a real Postgres container once @ServiceConnection is wired in, and a real Flyway migration the moment flyway-core is on the classpath. The gray box shows that trying to “fix” the second green box with the old advice changes nothing. The red box, dashed because it never executes, is this article’s own build log.

Versions used in this article. Spring Boot 4.1.1 (4.1.0 GA 2026‑06‑10), Testcontainers 2.0.5 (2.0.0 released 2025‑10‑14), Flyway 12.4.0, Hibernate ORM 7.4.5.Final, H2 2.4.240, JDK 25 (Temurin, LTS). Every class-shape and import-manifest claim below comes from javap or unzip -l against the real jars on this classpath; every behavioral claim comes from a real test run, captured to docs/output/ — except the ones this article says plainly were not runnable here, for lack of a Docker daemon.

What @DataJpaTest actually assembles

@DataJpaTest is not one autoconfiguration — it is a bundle of four narrower annotations, each of which pulls in its own short, explicit list of autoconfiguration classes. Reading that bundle directly off the compiled annotation (javap -v against DataJpaTest.class, not the reference docs) is what this whole article is built on, so it is worth seeing once:

$ javap -v org/springframework/boot/data/jpa/test/autoconfigure/DataJpaTest.class
  4: #45(#15=c#46)
    org.springframework.test.context.BootstrapWith(value=class ...DataJpaTestContextBootstrapper)
  8: #54()
    org.springframework.transaction.annotation.Transactional
  9: #55()
    org.springframework.boot.data.jpa.test.autoconfigure.AutoConfigureDataJpa
  10: #56()
    org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureJdbc
  11: #57()
    org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase
  12: #58()
    org.springframework.boot.jpa.test.autoconfigure.AutoConfigureTestEntityManager

— the full disassembly is not a separate transcript for this one; it was read directly from the jar during research and is reproduced here verbatim. Each of those four meta-annotations ships its own META-INF/spring/<FQN>.imports file — the actual, load-bearing list Spring Boot’s test-slice machinery reads at startup. Unzipped straight out of the jars:

$ cat META-INF/spring/org.springframework.boot.data.jpa.test.autoconfigure.AutoConfigureDataJpa.imports
org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration
org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration

$ cat META-INF/spring/org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureJdbc.imports
org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration
org.springframework.boot.jdbc.autoconfigure.JdbcClientAutoConfiguration
org.springframework.boot.jdbc.autoconfigure.JdbcTemplateAutoConfiguration
org.springframework.boot.transaction.autoconfigure.TransactionAutoConfiguration
org.springframework.boot.transaction.autoconfigure.TransactionManagerCustomizationAutoConfiguration
optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration

$ cat META-INF/spring/org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.imports
org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
org.springframework.boot.jdbc.test.autoconfigure.TestDatabaseAutoConfiguration
optional:org.springframework.boot.testcontainers.service.connection.ServiceConnectionAutoConfiguration

Four files, eleven distinct autoconfiguration classes between them, and not one mention of Flyway or Liquibase anywhere. It is tempting — it is exactly what an earlier draft of this article did — to read that as the complete, final answer: four manifests, no Flyway, so Flyway never runs. That conclusion turns out to be wrong, and section four explains why, because @AutoConfigureJdbc on this list is not quite what it looks like once you check whether it is meta-annotated with anything else of its own. What is true from this list alone: ServiceConnectionAutoConfiguration is in there twice, marked optional:, which is the reason @ServiceConnection works inside a @DataJpaTest slice at all without you importing anything extra for it — optional: means Spring Boot loads it only if the class is actually on the classpath, so a module with no Testcontainers dependency pays nothing for the check.

@DataJpaTest unpacked @DataJpaTest @AutoConfigureDataJpa → DataJpaRepositoriesAutoConfiguration, HibernateJpaAutoConfiguration @AutoConfigureJdbc → DataSourceAutoConfiguration, JdbcTemplateAutoConfiguration, Transaction… (+ one more, see §4) @AutoConfigureTestDatabase → DataSourceAutoConfiguration, TestDatabaseAutoConfiguration FlywayAutoConfiguration: one level deeper

The two autoconfiguration classes that matter most for the rest of this article — TestDatabaseAutoConfiguration and the plain DataSourceAutoConfiguration it sits next to — are two genuinely different mechanisms that happen to produce the same embedded database, which is exactly what makes the trap in section five possible. The amber box above, @AutoConfigureJdbc, is the one worth watching closely; it is not finished contributing imports by the time this section is done reading it.

  • The full DataJpaTest.class disassembly, including the excludeAutoConfiguration alias wiring, is in docs/output/ comments inside this module’s README.
  • Spring Boot’s own list of every test-slice annotation and what each one imports: Test Modules reference.

The smallest thing that works

Before touching Testcontainers at all, here is what a completely ordinary @DataJpaTest class actually does, demonstrated rather than described. One entity, one Spring Data repository, three test methods:

@DataJpaTest
class DefaultReplacementTest {

    @Autowired private TestEntityManager entityManager;
    @Autowired private ProductRepository productRepository;
    @Autowired private DataSource dataSource;

    @Test
    void whateverIsBehindThisDataSourceIsNotPostgres() throws Exception {
        String productName;
        try (Connection connection = dataSource.getConnection()) {
            productName = connection.getMetaData().getDatabaseProductName();
        }
        assertThat(productName).isNotEqualTo("PostgreSQL");
    }

    @Test
    void insertedRowIsVisibleWithinThisTest() {
        entityManager.persistAndFlush(new Product("SKU-1", "Widget"));
        assertThat(productRepository.findBySku("SKU-1")).isPresent();
    }

    @Test
    void previousTestsRowIsGoneInThisOne() {
        assertThat(productRepository.count()).isZero();
    }
}

No import of anything Testcontainers-shaped, no connection properties anywhere in the module. Full source is DefaultReplacementTest.java. The real run:

Replacing 'dataSource' DataSource bean with embedded version
Starting embedded database: url='jdbc:h2:mem:4440be02-551f-486e-a4b1-2182bb84c7a5;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'
JDBC reports database product name: H2
Hibernate: insert into product (name,sku,id) values (?,?,default)
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0

— trimmed from 00-full-test-run.txt. Two things are worth reading closely in that output, because they are the two halves of the article from here on. First, the explicit log line: Replacing 'dataSource' DataSource bean with embedded version — that is TestDatabaseAutoConfiguration doing exactly what its name says, and it only fires because nothing told it not to. Second, the product-name probe genuinely asserts H2, not PostgreSQL, on every run, every time, with zero variance — which is the whole point: this is not a flaky or environment-dependent fact, it is the default, unconditionally.

The third test is the part most tutorials lead with and this article is deliberately leading past: each test method runs inside its own transaction, rolled back when the method returns, regardless of execution order — the second test’s insert is invisible to the third. That part works exactly as advertised and needed no investigation. What needed investigation is everything about which database that transaction is rolling back against, which is where the rest of this article goes.

The fifth meta-annotation: why your migrations run anyway

The previous section’s four manifests are real, and reading only them is exactly how an earlier draft of this article concluded that @DataJpaTest never runs Flyway. The conclusion is wrong, and the reason is specific: @AutoConfigureJdbc — one of the four meta-annotations on @DataJpaTest, the one whose manifest was quoted in full above — is itself meta-annotated with a fifth annotation that manifest never mentions, because it is not one more entry in that file. It is a separate annotation, sitting on @AutoConfigureJdbc itself:

$ javap -v org/springframework/boot/jdbc/test/autoconfigure/AutoConfigureJdbc.class
RuntimeVisibleAnnotations:
  0: #10(#11=[e#12.#13])
    java.lang.annotation.Target(
  ...
  4: #19()
    org.springframework.boot.autoconfigure.ImportAutoConfiguration
  5: #20()
    org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization

— trimmed from 06-autoconfigurejdbc-meta-annotation-javap.txt, unzipped directly from spring-boot-jdbc-test-4.1.1.jar. @AutoConfigureDataSourceInitialization is new in Boot 4.0, and Spring’s own ImportAutoConfigurationImportSelector does not stop at the four annotations @DataJpaTest is directly marked with — it walks the entire meta-annotation tree, transitively, collecting every class it finds along the way that is itself annotated with @ImportAutoConfiguration. @AutoConfigureDataSourceInitialization carries that annotation too, which is confirmed in the same transcript. It is, in other words, a fifth source of imports, nested one level under a box this article’s own diagram in section two already drew — just not expanded.

That fifth annotation’s own .imports resource is loaded the same way the first four are — except this time the loader is ClassLoader.getResources(), which returns every file at that resource path across every jar on the classpath, and merges all of them into one list. It is not a single-file lookup. Three jars happen to ship a file at the identical path, META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports:

# unzipped from spring-boot-jdbc-test-4.1.1.jar
$ cat META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports
org.springframework.boot.jdbc.autoconfigure.DataSourceInitializationAutoConfiguration

# the SAME resource path, inside spring-boot-flyway-4.1.1.jar instead:
$ cat META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports
org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration

Source for both: 06-autoconfigurejdbc-meta-annotation-javap.txt. A spring-boot-liquibase jar ships a third file at the same path, contributing LiquibaseAutoConfiguration — not independently re-verified against a jar in this module (there is no Liquibase dependency here to unzip), but the mechanism applies to it identically by construction, since it is the exact same resource-merging loader reading the exact same resource path. spring-boot-starter-flyway is what actually puts the middle jar on this module’s test classpath; remove it and FlywayAutoConfiguration itself would not exist to be imported, regardless of what flyway-core is doing.

FlywayAutoConfiguration is still gated the ordinary way once it is imported — @ConditionalOnClass(Flyway.class), confirmed in the same transcript — so it only activates once flyway-core is genuinely on the classpath. That condition was always satisfied in this module, which is exactly why the earlier, wrong draft’s test still passed: it happened to be checking the right outcome (a product table existing) for the wrong reason (it assumed Hibernate’s ddl-auto made the table, when the real build log shows no create table statement from Hibernate anywhere — only Flyway’s own migration, confirmed against 00-full-test-run.txt).

@DataJpaTest
class FlywayRunsByDefaultTest {

    @Autowired private DataSource dataSource;
    @Autowired private ApplicationContext ctx;

    @Test
    void flywayAutoConfigurationBeansArePresentWithNoExplicitImportAnywhere() {
        assertThat(ctx.getBeanNamesForType(org.flywaydb.core.Flyway.class)).isNotEmpty();
        assertThat(ctx.containsBean("flywayInitializer")).isTrue();
    }

    @Test
    void theMigrationActuallyRanAgainstTheSubstitutedDatabase() throws Exception {
        try (Connection connection = dataSource.getConnection();
             Statement statement = connection.createStatement();
             ResultSet rs = statement.executeQuery(
                     "select \"installed_rank\", \"version\", \"description\", \"success\" "
                   + "from \"flyway_schema_history\" where \"installed_rank\" = 1")) {
            assertThat(rs.next()).isTrue();
            assertThat(rs.getString(2)).isEqualTo("1");
            assertThat(rs.getBoolean(4)).isTrue();
        }
    }
}
Migrating schema "PUBLIC" to version "1 - create product"
Successfully applied 1 migration to schema "PUBLIC", now at version v1
flyway_schema_history row: version=1 description=create product success=true
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

Source: FlywayRunsByDefaultTest.java; output trimmed from the same 00-full-test-run.txt. No extra annotation anywhere on this class — the migration ran because the fifth meta-annotation’s merge put FlywayAutoConfiguration in the context before the test method ever started.

A real quoting trap along the way, worth flagging on its own: Flyway creates its history table — and every column in it — with quoted, lowercase names. H2’s default identifier folding (unquoted names become uppercase) therefore does not apply to any of them. An unquoted select version from flyway_schema_history fails with “candidates are: \"flyway_schema_history\"”, and even once the table name is quoted, the columns still need the same treatment, and the first row back is always a bookkeeping row at installed_rank = -1 (“<< Flyway Schema History table created >>”), not the real migration. This is the same family of H2 case-folding surprise documented from the opposite direction — an application query silently finding nothing — in Flyway vs Liquibase for Spring Boot 4.

So what does the old advice — @ImportAutoConfiguration(FlywayAutoConfiguration.class), added by hand to make Flyway run — actually do on Boot 4.1, now that the import already happens on its own? Nothing harmful, and nothing useful either:

@DataJpaTest
@ImportAutoConfiguration(FlywayAutoConfiguration.class)
class ExplicitFlywayImportIsRedundantTest {

    @Autowired private DataSource dataSource;
    @Autowired private ApplicationContext ctx;

    @Test
    void redundantImportStillProducesExactlyOneFlywayBean() {
        assertThat(ctx.getBeanNamesForType(org.flywaydb.core.Flyway.class)).hasSize(1);
    }

    @Test
    void redundantImportDoesNotReapplyTheMigration() throws Exception {
        try (Connection connection = dataSource.getConnection();
             Statement statement = connection.createStatement()) {
            var rs = statement.executeQuery(
                    "select count(*) from \"flyway_schema_history\" where \"installed_rank\" >= 0");
            assertThat(rs.next()).isTrue();
            assertThat(rs.getInt(1)).isEqualTo(1);
        }
    }
}
flyway_schema_history real migration rows (rank >= 0): 1
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

Source: ExplicitFlywayImportIsRedundantTest.java; output from the same 00-full-test-run.txt. One bean, one applied migration, whether the import is there or not — Spring’s auto-configuration machinery de-duplicates the same fully-qualified class name however many times it is reached, so the old workaround is not wrong to leave in an existing test class, it is simply no longer doing anything. If a reader’s own project still needs the explicit import to make a migration run under @DataJpaTest, the next thing to check is whether spring-boot-starter-flyway (not just flyway-core) is actually on that project’s test classpath.

  • The full Flyway-plus-Liquibase case-folding story, with its own captured javap evidence: Flyway vs Liquibase for Spring Boot 4.
  • Liquibase ships the identical mechanism for the identical reason — a third AutoConfigureDataSourceInitialization.imports file inside spring-boot-liquibase, contributing LiquibaseAutoConfiguration — not separately demonstrated with a repo module here.
  • @ImportAutoConfiguration Javadoc — the general mechanism, stated plainly in Spring’s own words: “the classes are specified using a file in META-INF/spring where the file name is the fully-qualified name of the annotated class”. That one sentence is the entire reason three unrelated jars can each contribute to the same merged list without knowing about each other.

Replace.NONE is not the same as a real connection

The natural next move, for anyone who has just read section three, is to reach for @AutoConfigureTestDatabase(replace = Replace.NONE) and assume the job is done — substitution turned off, so whatever is configured must be real. That was this article’s own working assumption too, right up until running the test that checks it:

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class NoSubstitutionStillSilentlyUsesH2Test {

    @Autowired private DataSource dataSource;

    @Test
    void replaceNoneIsNotTheSameAsARealConnection() throws Exception {
        String productName;
        try (Connection connection = dataSource.getConnection()) {
            productName = connection.getMetaData().getDatabaseProductName();
        }
        assertThat(productName).isEqualTo("H2");   // still, even with Replace.NONE
    }
}

No @ServiceConnection, no container, no spring.datasource.url anywhere in this module. Source: NoSubstitutionStillSilentlyUsesH2Test.java. It passes — which is itself the finding, since the test asserts the surprising outcome, not the hoped-for one. The real log line from this run, next to the one section three already showed, is the whole explanation:

# DefaultReplacementTest (plain @DataJpaTest, replace defaults to ANY)
Replacing 'dataSource' DataSource bean with embedded version
Starting embedded database: url='jdbc:h2:mem:4440be02-551f-486e-a4b1-2182bb84c7a5;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'

# NoSubstitutionStillSilentlyUsesH2Test (replace = Replace.NONE)
HikariPool-1 - Starting...
HikariPool-1 - Added connection conn9: url=jdbc:h2:mem:59a60d3a-9329-4255-85ca-9683b850ef31 user=SA
JDBC reports database product name: H2 (even with replace = Replace.NONE and no @ServiceConnection anywhere)

— both trimmed from 00-full-test-run.txt. No “Replacing … bean with embedded version” line the second time — because TestDatabaseAutoConfiguration genuinely did nothing, exactly as Replace.NONE promises. But a Hikari pool backed by jdbc:h2:mem still shows up anyway, started by the perfectly ordinary DataSourceAutoConfiguration that every Spring Boot application ships with. That autoconfiguration auto-provisions an embedded database whenever no spring.datasource.url is set and an embedded driver is sitting on the classpath — logic with no idea it is running inside a test slice, no awareness of Replace.NONE, and no reason to care about either.

Two different roads, same destination TestDatabaseAutoConfiguration replaces an EXISTING ‘dataSource’ bean — only fires if Replace is ANY (the default) DataSourceAutoConfiguration provisions one from scratch if NOTHING was configured — doesn’t know Replace exists embedded H2, either way

The only way to actually land on a real connection under Replace.NONE is to make sure something real is configured in the first place — a real spring.datasource.url, or, for a test, @ServiceConnection wiring a real container’s details into the context before either autoconfiguration gets a chance to run. That is the subject of the rest of this article.

What to actually check for, in a real codebase: Replace.NONE with no @ServiceConnection and no datasource properties is not a compile error, not a startup failure, and not a log warning — it is a test that passes against H2 while looking, to a reviewer who does not run it with an eye on the startup log, like it is pinned to something real. The only reliable tell is the one this section’s test turns into an assertion: ask the connection what product it actually is.

Wiring in a real Postgres: @ServiceConnection and Testcontainers 2.0

Testcontainers 2.0 (released 2025‑10‑14, 2.0.5 is what Spring Boot 4.1.1’s BOM manages) relocated PostgreSQLContainer from org.testcontainers.containers to org.testcontainers.postgresql. The headline fact is simple; the two details that actually matter for a migration are not, and both come straight from unzip -l against the real 2.0.5 jar rather than a changelog:

$ unzip -l /root/.m2/repository/org/testcontainers/testcontainers-postgresql/2.0.5/testcontainers-postgresql-2.0.5.jar | grep PostgreSQLContainer
     5533  2026-04-06 22:36   org/testcontainers/postgresql/PostgreSQLContainer.class
     1745  2026-04-06 22:36   org/testcontainers/containers/PostgreSQLContainerProvider.class
     6150  2026-04-06 22:36   org/testcontainers/containers/PostgreSQLContainer.class

— full listing in 01-postgresqlcontainer-relocation-javap.txt. First surprise: both classes ship inside the same artifact, testcontainers-postgresql — the deprecated shim at the old package did not move to a separate compatibility jar, it just stayed where it was, inside the one module that now also holds the new class. There is no second dependency to add or remove for backward compatibility; it was never separate.

Second surprise, and the one that actually breaks a mechanical find-and-replace migration: the two classes have different type signatures.

$ javap -p org/testcontainers/postgresql/PostgreSQLContainer.class
public class org.testcontainers.postgresql.PostgreSQLContainer
    extends org.testcontainers.containers.JdbcDatabaseContainer<org.testcontainers.postgresql.PostgreSQLContainer>

$ javap -p org/testcontainers/containers/PostgreSQLContainer.class
public class org.testcontainers.containers.PostgreSQLContainer<SELF extends org.testcontainers.containers.PostgreSQLContainer<SELF>>
    extends org.testcontainers.containers.JdbcDatabaseContainer<SELF>

The deprecated class kept its familiar self-referential generic (<SELF extends PostgreSQLContainer<SELF>>) for source compatibility with every 1.x test that ever wrote PostgreSQLContainer<?>. The new class dropped the type parameter entirely — it is not generic at all. Code ported straight across by changing only the import line does not compile; PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(...) needs to become plain PostgreSQLContainer postgres = new PostgreSQLContainer(...), nothing else, but that one bracket pair is a real compiler error, not a style nit.

What did not need to change at all: @ServiceConnection itself. The class that wires a container’s connection details into a DataSource bean, JdbcContainerConnectionDetailsFactory, is typed against the base class the relocation never touched:

$ javap -p org/springframework/boot/jdbc/testcontainers/JdbcContainerConnectionDetailsFactory.class
class ...JdbcContainerConnectionDetailsFactory
    extends ContainerConnectionDetailsFactory<org.testcontainers.containers.JdbcDatabaseContainer<?>, JdbcConnectionDetails>

— full output in 02-serviceconnection-unaffected-javap.txt. JdbcDatabaseContainer is the common parent both the old and the new PostgreSQLContainer extend, and it never moved. @ServiceConnection does not know or care which package your container class comes from — it was never coupled to PostgreSQLContainer specifically, only to the stable base class above it, which is exactly why this particular relocation needed a one-line import-and-generics fix in application code and zero changes in Spring Boot’s own wiring.

What moved, what didn’t org.testcontainers.containers.PostgreSQLContainer @Deprecated, same jar org.testcontainers.postgresql.PostgreSQLContainer new, not generic itself JdbcDatabaseContainer<?> unmoved base class — both PostgreSQLContainers extend it JdbcContainerConnectionDetailsFactory typed against the base class — @ServiceConnection needed zero changes

Here is the wiring in this module, built to run against the two things Docker availability actually changes: whether the class guarding it reports a clean skip, or whether the absence of a daemon surfaces as a real failure.

@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
@EnabledIfDockerAvailable
class ServiceConnectionSkipsCleanlyTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");

    @Autowired private DataSource dataSource;

    @Test
    void realDatabaseProductNameIsPostgres() throws Exception {
        try (Connection connection = dataSource.getConnection()) {
            assertThat(connection.getMetaData().getDatabaseProductName())
                    .isEqualTo("PostgreSQL");
        }
    }
}

Source: ServiceConnectionSkipsCleanlyTest.java. @EnabledIfDockerAvailable is new in Testcontainers 2.0’s JUnit 5 extension — it does not exist in the 1.x junit-jupiter artifact, confirmed the same way as everything else in this section, with unzip -l. Run for real, in this sandbox, with no Docker daemon present:

ERROR o.t.d.DockerClientProviderStrategy : Could not find a valid Docker environment. ...
Tests run: 1, Failures: 0, Errors: 0, Skipped: 1
BUILD SUCCESS

— full output in 05-service-connection-skips-cleanly.txt. Note that the same Docker-detection error line fires either way — the condition still has to probe for Docker to decide what to do — but with the guard present, the outcome is a clean Skipped: 1 and a green build, not a broken one.

Remove just that one annotation and the exact same class, minus the guard, actually tries to pull the container’s image and fails loudly instead of skipping:

ERROR: Can't get Docker image: RemoteDockerImage(imageName=postgres:17, ...)
Caused by: java.lang.IllegalStateException: Could not find a valid Docker environment.
    at org.testcontainers.dockerclient.DockerClientProviderStrategy.getFirstValidStrategy(...)
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
BUILD FAILURE

— full output and stack trace in 04-service-connection-without-guard-fails.txt, from ServiceConnectionWithoutGuardFailsToStartTest.java. One honest correction worth making here: spring-boot-testcontainers ships a class called DockerEnvironmentNotFoundFailureAnalyzer, which looked, from reading its name and javap output alone during research, like it should turn this into one of Spring Boot’s friendly, formatted startup-failure banners. It did not fire in this run — the raw Testcontainers IllegalStateException is what actually surfaces, unformatted, through JUnit’s own failure reporting for this test-slice context rather than through SpringApplication‘s own exception handling. The class exists; this particular path through a @DataJpaTest slice is not the one that reaches it. Said plainly here rather than silently dropped, because the first draft of this section claimed the friendly banner would appear, and the real run disagreed.

What this means for CI: if any part of your pipeline runs tests without Docker-in-Docker — a lint-only stage, a fast unit-test gate before the real integration stage — a @ServiceConnection test class with no @EnabledIfDockerAvailable guard fails that stage outright, with the stack trace above, rather than skipping. Testcontainers 2.0 added the guard specifically so you can choose which behavior you want per class; omitting it is itself a choice, not a neutral default.
  • Testcontainers’ own migration notes for the 2.0 package moves: testcontainers.org.
  • The multi-module relocation this section’s technique also applies to — worth its own close look if you maintain more than one Testcontainers module: see Testcontainers’ 2.0.0 release notes.

What this sandbox could not run — and what the wiring says would happen

This article has been explicit, section by section, about which claims come from a real run and which come from reading class files and documentation. This section is entirely the second kind, gathered in one place rather than scattered, because the whole point of the house standard this blog holds itself to is that a reader should never have to guess which is which.

Everything below is real in the sense that it was verified against Spring Boot’s and Testcontainers’ actual source and compiled classes — not against blog posts or release-note prose — but none of it was exercised against an actually-running Postgres container in this module, because the sandbox it was built in has no Docker daemon and no way to start one.

  • What realDatabaseProductNameIsPostgres would assert, with Docker present. The wiring verified in the previous section — @ServiceConnection typed against the stable JdbcDatabaseContainer base class, feeding a real JdbcConnectionDetails bean into the same DataSource slot TestDatabaseAutoConfiguration otherwise substitutes — gives no reason to expect anything other than PostgreSQL back from that same probe. This is the one claim in this list closest to certain; it rests on mechanism, not guesswork, and the only thing actually missing is the terminal output of one more test run.
  • Rollback semantics against real sequence and identity behavior. Section three’s rollback demonstration ran against H2’s own auto-increment emulation. Real Postgres bigint generated by default as identity columns are driven by an actual sequence object, and Postgres sequences are famously not transactional — a rolled-back insert does not give its sequence value back. A test suite that asserts on specific generated ID values, rather than on row presence or count, would see different numbers under real Postgres than under H2’s embedded emulation, even though the rollback-per-test behavior itself is unaffected. This module’s own tests were written to avoid asserting on raw ID values for exactly this reason, once it became clear a real run was not available to confirm the specifics.
  • Reusable containers across test runs. Testcontainers supports .withReuse(true) on a container plus a testcontainers.reuse.enable=true line in ~/.testcontainers.properties, which keeps one container alive across repeated local test runs instead of paying Postgres’s full startup cost every time. This is documented, stable Testcontainers behavior, unrelated to the 2.0 package moves this article otherwise focuses on — it was out of scope for a module built without a daemon to reuse anything against, not because of any uncertainty about whether it works.
  • Testing Postgres-specific SQL against the real dialect. This module’s migration, V1__create_product.sql, is deliberately portable ANSI SQL so section four’s Flyway demonstration could run for real against H2. A migration using Postgres-only syntax — a jsonb column, a partial index, generated always as identity with Postgres’s specific sequence options — would need the real container to test at all; H2’s compatibility modes get close on simple cases but are not a substitute once a migration leans on anything Postgres-specific, which is the entire reason @ServiceConnection exists in the first place.
Why say this so plainly instead of writing around it: the whole thesis of this article is that a @DataJpaTest class can look like it tests something real while quietly not doing so. Publishing a section that silently asserted real-Postgres output it never actually produced would be the exact failure mode this article is about, aimed at its own author instead of a reader’s code.

Should you bother with a real container at all?

Should every @DataJpaTest suite switch to a real Testcontainers Postgres? Not automatically. If your repository layer has no native queries, no database-specific functions, and no migrations more exotic than creating tables and columns Hibernate could have derived itself, H2’s default substitution is cheap, fast, and genuinely fine — the trap this article describes is not “H2 is bad,” it is “knowing which one you are actually running against, on purpose, instead of by accident.” Reach for @ServiceConnection when a native query, a Postgres-specific type, or a migration’s real SQL dialect is itself part of what you need to verify — and once you do, add @EnabledIfDockerAvailable from the first commit, not after the first CI run that fails for a reason that has nothing to do with your code.

Further reading

No Comments yet!

Leave a Reply

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