Correct post #45: Flyway and Liquibase DO run under @DataJpaTest by default

The previous commit's FlywayDoesNotRunUnderPlainDataJpaTestTest and
FlywayRunsWhenExplicitlyImportedTest rested on a false premise: that none of
@DataJpaTest's four documented meta-annotations import FlywayAutoConfiguration,
so migrations never run under the slice. That reading of the four .imports
manifests is accurate but incomplete. @AutoConfigureJdbc is itself
meta-annotated with @AutoConfigureDataSourceInitialization (new in Boot 4.0),
and Spring's ImportAutoConfigurationImportSelector walks the full
meta-annotation tree transitively. That fifth annotation's own .imports
resource is loaded via ClassLoader.getResources(), which merges same-named
files from every jar on the classpath -- spring-boot-jdbc-test,
spring-boot-flyway and spring-boot-liquibase each ship one at the identical
path, contributing DataSourceInitializationAutoConfiguration,
FlywayAutoConfiguration and LiquibaseAutoConfiguration respectively.

Replaced the two wrong-premise test classes with FlywayRunsByDefaultTest
(proves the bean is present and the migration actually ran) and
ExplicitFlywayImportIsRedundantTest (proves the old "add the import yourself"
advice is harmless but unnecessary on Boot 4.1). Added javap/unzip evidence
for the new mechanism to docs/output, re-captured the full test run (9 tests,
1 clean skip), corrected README's test table, and closed a second,
unrelated gap: the unzip -l listing quoted in the @ServiceConnection section
had no matching captured transcript -- it does now.
This commit is contained in:
Claude
2026-10-08 12:35:04 +00:00
parent 97f946136b
commit 4370ac7fdf
9 changed files with 298 additions and 188 deletions
@@ -0,0 +1,59 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.context.ApplicationContext;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Statement;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Older advice for this exact situation (including an earlier draft of this module, and more
* than one blog post this article will not link to) says to add
* {@code @ImportAutoConfiguration(FlywayAutoConfiguration.class)} yourself to get Flyway running
* inside a {@code @DataJpaTest}. As {@link FlywayRunsByDefaultTest} now shows, that is no longer
* necessary on Spring Boot 4.1 -- the import already happens on its own, by way of
* {@code @AutoConfigureJdbc}'s own {@code @AutoConfigureDataSourceInitialization} meta-annotation.
*
* <p>Adding the explicit import anyway is not harmful. {@code @ImportAutoConfiguration} is
* backed by ordinary Spring auto-configuration machinery: the same fully-qualified class name,
* however many times it is reached (once through the transitive meta-annotation walk, once
* through this class's own explicit annotation), is only ever registered once. This test proves
* that directly rather than asserting it from the mechanism alone: exactly one {@code flyway}
* bean, and exactly one applied migration -- not two -- with the redundant import sitting right
* there on the class.
*/
@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();
int realMigrationRows = rs.getInt(1);
System.out.println("flyway_schema_history real migration rows (rank >= 0): " + realMigrationRows);
assertThat(realMigrationRows).isEqualTo(1);
}
}
}
@@ -1,49 +0,0 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import static org.assertj.core.api.Assertions.assertThat;
/**
* This module has a real Flyway migration, {@code V1__create_product.sql}, and flyway-core is a
* main-scope dependency. A plain @DataJpaTest never runs it anyway -- not because of the
* embedded-database substitution, but because @DataJpaTest's own curated set of auto-configuration
* imports simply does not include FlywayAutoConfiguration or LiquibaseAutoConfiguration at all.
* Confirmed by reading the actual import manifests Boot ships inside spring-boot-jdbc-test.jar and
* spring-boot-data-jpa-test.jar -- neither file mentions Flyway or Liquibase, in any form, under
* any replace setting.
*
* The {@code product} table exists in this test for a completely different reason: Hibernate's own
* {@code ddl-auto}, defaulted on because Boot never detects a schema-management tool in this
* slice's context. See {@link FlywayRunsWhenExplicitlyImportedTest} for the one-line fix.
*/
@DataJpaTest
class FlywayDoesNotRunUnderPlainDataJpaTestTest {
@Autowired
private DataSource dataSource;
@Test
void productTableExistsButFlywayNeverRan() throws Exception {
try (Connection connection = dataSource.getConnection()) {
try (ResultSet flywayTable = connection.getMetaData()
.getTables(null, null, "FLYWAY_SCHEMA_HISTORY", null)) {
assertThat(flywayTable.next())
.as("flyway_schema_history should not exist -- Flyway never ran")
.isFalse();
}
try (ResultSet productTable = connection.getMetaData()
.getTables(null, null, "PRODUCT", null)) {
assertThat(productTable.next())
.as("product table should exist anyway -- Hibernate's ddl-auto made it")
.isTrue();
}
}
}
}
@@ -0,0 +1,92 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.ApplicationContext;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.assertj.core.api.Assertions.assertThat;
/**
* This module has a real Flyway migration, {@code V1__create_product.sql}, and flyway-core
* (plus the test-scoped {@code spring-boot-starter-flyway} -- see the module README for why that
* second one matters) is on the classpath. A plain {@code @DataJpaTest}, with no extra
* annotation anywhere on this class, runs that migration by default.
*
* <p>An earlier draft of this module asserted the opposite, on the strength of reading
* {@code @DataJpaTest}'s four documented meta-annotations ({@code @AutoConfigureDataJpa},
* {@code @AutoConfigureJdbc}, {@code @AutoConfigureTestDatabase},
* {@code @AutoConfigureTestEntityManager}) and finding that none of their four
* {@code .imports} manifests mentions Flyway or Liquibase. That reading is correct and
* incomplete: {@code AutoConfigureJdbc.class} is itself meta-annotated with a fifth annotation,
* {@code @AutoConfigureDataSourceInitialization} (new in Boot 4.0; confirmed with
* {@code javap -v org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureJdbc} against
* {@code spring-boot-jdbc-test-4.1.1.jar}), and Spring's own
* {@code ImportAutoConfigurationImportSelector} walks the entire meta-annotation tree
* transitively -- every annotation, and every meta-annotation on those -- collecting every class
* in that tree that is itself annotated with {@code @ImportAutoConfiguration}. That fifth
* annotation is one of them.
*
* <p>{@code @AutoConfigureDataSourceInitialization}'s own {@code .imports} resource is loaded via
* {@code ClassLoader.getResources()}, which returns every file at that path across every jar on
* the classpath and merges them -- not a single-file lookup. Three jars ship a file at the exact
* same resource path
* ({@code META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports}):
* {@code spring-boot-jdbc-test} contributes {@code DataSourceInitializationAutoConfiguration},
* {@code spring-boot-flyway} contributes {@code FlywayAutoConfiguration}, and
* {@code spring-boot-liquibase} contributes {@code LiquibaseAutoConfiguration}. Confirmed by
* unzipping both jars actually on this classpath and reading the file; the Liquibase side was
* not independently re-run here (no Liquibase module in this repo) but the same mechanism applies
* to it by construction. {@code FlywayAutoConfiguration} is still gated by the ordinary
* {@code @ConditionalOnClass(Flyway.class)} -- it only activates once flyway-core is genuinely on
* the classpath, which is the one part of the old draft's observation that was never wrong, just
* misattributed to the wrong mechanism.
*
* <p>See {@link ExplicitFlywayImportIsRedundantTest} for what this means for the (now
* unnecessary, but harmless) advice to {@code @ImportAutoConfiguration(FlywayAutoConfiguration.class)}
* yourself.
*/
@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 {
// Flyway's own history table always carries a bookkeeping row at installed_rank = -1
// ("<< Flyway Schema History table created >>") ahead of the real migration rows, and
// both the table name and every column name are stored quoted and lowercase -- H2's
// default unquoted-identifier folding (to uppercase) does not apply to any of them.
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();
String version = rs.getString(2);
String description = rs.getString(3);
boolean success = rs.getBoolean(4);
System.out.println("flyway_schema_history row: version=" + version
+ " description=" + description + " success=" + success);
assertThat(version).isEqualTo("1");
assertThat(description).isEqualTo("create product");
assertThat(success).isTrue();
assertThat(rs.next()).isFalse();
}
}
}
@@ -1,52 +0,0 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The one-line fix for {@link FlywayDoesNotRunUnderPlainDataJpaTestTest}: pull
* FlywayAutoConfiguration into the slice yourself. Once it is in the context, Boot's own
* schema-management detection sees it, flips Hibernate's ddl-auto back to {@code none}, and the
* real migration runs against the same substituted (here, H2) DataSource.
*/
@DataJpaTest
@ImportAutoConfiguration(FlywayAutoConfiguration.class)
class FlywayRunsWhenExplicitlyImportedTest {
@Autowired
private DataSource dataSource;
@Test
void flywayHistoryTableProvesTheMigrationRanThisTime() throws Exception {
// Flyway's own history table always carries a bookkeeping row at installed_rank = -1
// ("<< Flyway Schema History table created >>") ahead of the real migration rows --
// easy to miss if a query only reads the first row back, which is what an earlier draft
// of this test did. The real V1 migration is installed_rank = 1.
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();
String version = rs.getString(2);
String description = rs.getString(3);
boolean success = rs.getBoolean(4);
System.out.println("flyway_schema_history row: version=" + version
+ " description=" + description + " success=" + success);
assertThat(version).isEqualTo("1");
assertThat(description).isEqualTo("create product");
assertThat(success).isTrue();
assertThat(rs.next()).isFalse();
}
}
}