Add data-jpa-testcontainers module: @DataJpaTest's default substitution, the Flyway-never-runs trap, and @ServiceConnection on Testcontainers 2.0.5 (post #45)
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
package com.ankurm.datajpa;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class DataJpaTestcontainersApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DataJpaTestcontainersApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ankurm.datajpa;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
/**
|
||||
* A deliberately boring entity. The point of this module is not the domain model -- it is what
|
||||
* backs the DataSource underneath it, which is why every field here is the simplest type that
|
||||
* will do the job.
|
||||
*
|
||||
* See the "@DataJpaTest with Testcontainers @ServiceConnection" article on ankurm.com for the
|
||||
* chapter this entity supports.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "product")
|
||||
public class Product {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String sku;
|
||||
|
||||
private String name;
|
||||
|
||||
protected Product() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public Product(String sku, String name) {
|
||||
this.sku = sku;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getSku() {
|
||||
return sku;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.datajpa;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ProductRepository extends JpaRepository<Product, Long> {
|
||||
|
||||
Optional<Product> findBySku(String sku);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
create table product (
|
||||
id bigint generated by default as identity primary key,
|
||||
sku varchar(64) not null unique,
|
||||
name varchar(255) not null
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
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.boot.jpa.test.autoconfigure.TestEntityManager;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* No @ServiceConnection, no Testcontainers import anywhere in this file. Plain @DataJpaTest,
|
||||
* exactly as most tutorials show it.
|
||||
*
|
||||
* What it actually runs against, and why the two tests below pass or fail in isolation from each
|
||||
* other, is the subject of the article's second and third sections.
|
||||
*/
|
||||
@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();
|
||||
}
|
||||
System.out.println("JDBC reports database product name: " + productName);
|
||||
assertThat(productName).isNotEqualTo("PostgreSQL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void insertedRowIsVisibleWithinThisTest() {
|
||||
entityManager.persistAndFlush(new Product("SKU-1", "Widget"));
|
||||
|
||||
assertThat(productRepository.findBySku("SKU-1")).isPresent();
|
||||
assertThat(productRepository.count()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void previousTestsRowIsGoneInThisOne() {
|
||||
// If @DataJpaTest did not roll each test back, this would see the row the previous test
|
||||
// method inserted. It does not -- each test method gets its own transaction, rolled back
|
||||
// when the method returns, regardless of test execution order.
|
||||
assertThat(productRepository.count()).isZero();
|
||||
assertThat(productRepository.findBySku("SKU-1")).isEmpty();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
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.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
|
||||
|
||||
/**
|
||||
* The actually-surprising half of this trap. The intuition is that
|
||||
* {@code @AutoConfigureTestDatabase(replace = Replace.NONE)} turns off the embedded substitution,
|
||||
* so without a real connection configured anywhere this test should fail to start -- that was this
|
||||
* class's original name and premise, until running it against this sandbox's lack of Docker proved
|
||||
* that assumption wrong.
|
||||
*
|
||||
* What {@code replace = Replace.NONE} actually turns off is narrower: only
|
||||
* {@code TestDatabaseAutoConfiguration}'s own bean-post-processing step, the one that replaces an
|
||||
* already-configured production DataSource bean. It does nothing to the ordinary
|
||||
* {@code DataSourceAutoConfiguration}, which auto-provisions an embedded database whenever no
|
||||
* {@code spring.datasource.url} is set and an embedded driver is on the classpath -- completely
|
||||
* independent machinery, with no awareness of the test slice at all. There is no
|
||||
* {@code @ServiceConnection} in this class, and no connection properties anywhere. Replace.NONE is
|
||||
* set. It still silently gets H2, with no warning that anything was substituted, because nothing
|
||||
* Replace.NONE knows about was. The real log line is in
|
||||
* {@code docs/output/00-full-test-run.txt}: a HikariDataSource backed by {@code jdbc:h2:mem:...},
|
||||
* never the "Replacing 'dataSource' DataSource bean with embedded version" line that
|
||||
* {@link DefaultReplacementTest} produces.
|
||||
*/
|
||||
@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();
|
||||
}
|
||||
System.out.println("JDBC reports database product name: " + productName
|
||||
+ " (even with replace = Replace.NONE and no @ServiceConnection anywhere)");
|
||||
assertThat(productName).isEqualTo("H2");
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
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.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
|
||||
|
||||
/**
|
||||
* This is the article's intended, finished shape: a real Postgres container wired in as the
|
||||
* DataSource for a @DataJpaTest slice, with the default embedded substitution turned off so the
|
||||
* real connection is actually used.
|
||||
*
|
||||
* {@code @EnabledIfDockerAvailable} is new in Testcontainers 2.0's JUnit 5 extension (confirmed by
|
||||
* `unzip -l` against the 2.0.5 artifact; it does not exist in 1.x). Without it, this class would
|
||||
* try to start the container and fail loudly the moment Docker is not reachable -- see
|
||||
* {@link ServiceConnectionWithoutGuardFailsToStartTest} for exactly what that looks like. With it,
|
||||
* JUnit reports the whole class as cleanly SKIPPED instead, which is what actually happened the one
|
||||
* time this class ran for this article: no Docker daemon in the build sandbox, captured in
|
||||
* {@code docs/output/05-service-connection-skips-cleanly.txt}.
|
||||
*/
|
||||
@DataJpaTest
|
||||
@Testcontainers
|
||||
@AutoConfigureTestDatabase(replace = Replace.NONE)
|
||||
@EnabledIfDockerAvailable
|
||||
class ServiceConnectionSkipsCleanlyTest {
|
||||
|
||||
// Unlike the deprecated org.testcontainers.containers.PostgreSQLContainer<SELF extends ...>,
|
||||
// this new org.testcontainers.postgresql.PostgreSQLContainer is not itself generic (confirmed
|
||||
// with javap) -- it extends JdbcDatabaseContainer<PostgreSQLContainer> directly. Code ported
|
||||
// from the old import that wrote `PostgreSQLContainer<?>` needs that type parameter dropped.
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Test
|
||||
void realDatabaseProductNameIsPostgres() throws Exception {
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
String productName = connection.getMetaData().getDatabaseProductName();
|
||||
System.out.println("JDBC reports database product name: " + productName);
|
||||
assertThat(productName).isEqualTo("PostgreSQL");
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.ankurm.datajpa;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
|
||||
import static org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
|
||||
|
||||
/**
|
||||
* Identical to {@link ServiceConnectionSkipsCleanlyTest} except for one missing annotation:
|
||||
* no {@code @EnabledIfDockerAvailable}. NOT part of this module's default `mvn test` run -- see
|
||||
* the surefire exclude in pom.xml. Run it on its own with:
|
||||
*
|
||||
* <pre>mvn test -Dtest=ServiceConnectionWithoutGuardFailsToStartTest</pre>
|
||||
*
|
||||
* Without the guard, the Testcontainers JUnit extension tries to start the container regardless of
|
||||
* what @AutoConfigureTestDatabase says, and that start call is what actually fails. The captured
|
||||
* failure -- including whichever of Spring Boot's own
|
||||
* {@code DockerEnvironmentNotFoundFailureAnalyzer} or the raw Testcontainers exception came through
|
||||
* -- is in {@code docs/output/04-service-connection-without-guard-fails.txt}.
|
||||
*/
|
||||
@DataJpaTest
|
||||
@Testcontainers
|
||||
@AutoConfigureTestDatabase(replace = Replace.NONE)
|
||||
class ServiceConnectionWithoutGuardFailsToStartTest {
|
||||
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
|
||||
|
||||
@Test
|
||||
void neverReached() {
|
||||
// The test body is irrelevant -- container startup fails before this runs.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user