From 8b887596229b18a8040e6da60cb7ae2be743f79e Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 8 May 2026 14:50:46 +0700 Subject: [PATCH 1/9] add Embedded annotations --- README.md | 32 +++++ pom.xml | 10 ++ .../annotations/kafka/EmbeddedKafkaTest.java | 69 ++++++++++ ...ddedKafkaTestContextCustomizerFactory.java | 35 +++++ .../kafka/EmbeddedKafkaTestExtension.java | 97 ++++++++++++++ .../postgresql/EmbeddedPostgresqlTest.java | 50 +++++++ .../EmbeddedPostgresqlTestExtension.java | 121 +++++++++++++++++ .../PostgresqlContainerExtension.java | 70 +--------- .../postgresql/PostgresqlDatabaseCleaner.java | 91 +++++++++++++ src/main/resources/META-INF/spring.factories | 4 +- .../EmbeddedKafkaTestIntegrationTest.java | 125 ++++++++++++++++++ ...EmbeddedPostgresqlTestIntegrationTest.java | 82 ++++++++++++ 12 files changed, 718 insertions(+), 68 deletions(-) create mode 100644 src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTest.java create mode 100644 src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestContextCustomizerFactory.java create mode 100644 src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java create mode 100644 src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTest.java create mode 100644 src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestExtension.java create mode 100644 src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlDatabaseCleaner.java create mode 100644 src/test/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestIntegrationTest.java create mode 100644 src/test/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestIntegrationTest.java diff --git a/README.md b/README.md index 57142b5e..d067a68d 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,38 @@ opensearchproject/opensearch | @MinioTestcontainer | @MinioTestcontainerSingleton | | @OpensearchTestcontainer | @OpensearchTestcontainerSingleton | +## Embedded режим без Docker + +Для легковесных интеграционных тестов можно использовать embedded-аннотации. Библиотека сама запускает локальный +backend, +инициализирует runtime-настройки в Spring context и очищает данные между test methods. + +| embedded | backend | +|-------------------------|---------------------------------| +| @EmbeddedPostgresqlTest | io.zonky.test embedded-postgres | +| @EmbeddedKafkaTest | spring-kafka-test EmbeddedKafka | + +Пример замены Docker Kafka/PostgreSQL на embedded: + +```java +@EmbeddedPostgresqlTest +@EmbeddedKafkaTest( + topics = { + "magista-invoicing-test", + "magista-invoice-template-test" + }, + properties = { + "kafka.topics.invoicing.id=magista-invoicing-test", + "kafka.topics.invoicing.consume.enabled=true", + "kafka.topics.invoice-template.id=magista-invoice-template-test", + "kafka.topics.invoice-template.consume.enabled=true" + } +) +@SpringBootTest +class KafkaListenerTest { +} +``` + Для изменения `docker image tag`, который используется тестконтейнерами нужно переопределить параметры в `application.yml`: ```yml diff --git a/pom.xml b/pom.xml index 5cf014cd..829f5ec0 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,7 @@ 0.0.3 1.0.2 1.18.38 + 2.0.3 ./src/main/resources/checkstyle/checkstyle-suppressions.xml @@ -181,6 +182,15 @@ spring-kafka provided + + org.springframework.kafka + spring-kafka-test + + + io.zonky.test + embedded-postgres + ${embedded-postgres.version} + org.apache.kafka kafka-clients diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTest.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTest.java new file mode 100644 index 00000000..9a1961f8 --- /dev/null +++ b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTest.java @@ -0,0 +1,69 @@ +package dev.vality.testcontainers.annotations.kafka; + +import dev.vality.testcontainers.annotations.KafkaTestConfig; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.core.annotation.AliasFor; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.test.annotation.DirtiesContext; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Starts Spring embedded Kafka and initializes common Spring/Kafka bootstrap properties. + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@EmbeddedKafka +@ExtendWith(EmbeddedKafkaTestExtension.class) +@KafkaTestConfig +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +public @interface EmbeddedKafkaTest { + + /** + * Alias for {@link EmbeddedKafka#topics()}. + */ + @AliasFor(annotation = EmbeddedKafka.class, attribute = "topics") + String[] topics() default {}; + + /** + * Alias for {@link EmbeddedKafka#partitions()}. + */ + @AliasFor(annotation = EmbeddedKafka.class, attribute = "partitions") + int partitions() default 1; + + /** + * Alias for {@link EmbeddedKafka#count()}. + */ + @AliasFor(annotation = EmbeddedKafka.class, attribute = "count") + int count() default 1; + + /** + * Alias for {@link EmbeddedKafka#brokerProperties()}. + */ + @AliasFor(annotation = EmbeddedKafka.class, attribute = "brokerProperties") + String[] brokerProperties() default {}; + + /** + * Alias for {@link EmbeddedKafka#kraft()}. + */ + @AliasFor(annotation = EmbeddedKafka.class, attribute = "kraft") + boolean kraft() default false; + + /** + * Аналогичный параметр как у аннотации {@code SpringBootTest#properties()}. + */ + String[] properties() default {}; + + /** + * Удалять и пересоздавать объявленные топики перед каждым тестом. + */ + boolean cleanupTopics() default true; + + /** + * Топики, которые не нужно очищать между тестами. + */ + String[] excludeCleanupTopics() default {}; +} diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestContextCustomizerFactory.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestContextCustomizerFactory.java new file mode 100644 index 00000000..9cac00f3 --- /dev/null +++ b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestContextCustomizerFactory.java @@ -0,0 +1,35 @@ +package dev.vality.testcontainers.annotations.kafka; + +import org.junit.platform.commons.support.AnnotationSupport; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfigurationAttributes; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.ContextCustomizerFactory; + +import java.util.List; +import java.util.Optional; + +public class EmbeddedKafkaTestContextCustomizerFactory implements ContextCustomizerFactory { + + @Override + public ContextCustomizer createContextCustomizer( + Class testClass, + List configAttributes) { + return (context, mergedConfig) -> + findAnnotation(testClass).ifPresent(annotation -> init(context, annotation)); + } + + private Optional findAnnotation(Class testClass) { + return AnnotationSupport.findAnnotation(testClass, EmbeddedKafkaTest.class); + } + + private void init(ConfigurableApplicationContext context, EmbeddedKafkaTest annotation) { + TestPropertyValues.of( + "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}", + "kafka.bootstrap-servers=${spring.embedded.kafka.brokers}", + "kafka.ssl.enabled=false") + .and(annotation.properties()) + .applyTo(context); + } +} diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java new file mode 100644 index 00000000..d7270582 --- /dev/null +++ b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java @@ -0,0 +1,97 @@ +package dev.vality.testcontainers.annotations.kafka; + +import lombok.SneakyThrows; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.RecordsToDelete; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.serialization.ByteArrayDeserializer; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.platform.commons.support.AnnotationSupport; +import org.springframework.kafka.test.EmbeddedKafkaBroker; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +public class EmbeddedKafkaTestExtension implements BeforeEachCallback { + + private static final int WAIT_TIMEOUT_SECONDS = 10; + + @Override + public void beforeEach(ExtensionContext context) { + AnnotationSupport.findAnnotation(context.getTestClass(), EmbeddedKafkaTest.class) + .filter(EmbeddedKafkaTest::cleanupTopics) + .ifPresent(annotation -> cleanupTopics(context, annotation)); + } + + @SneakyThrows + private void cleanupTopics(ExtensionContext context, EmbeddedKafkaTest annotation) { + var topics = Arrays.stream(annotation.topics()) + .filter(topic -> !List.of(annotation.excludeCleanupTopics()).contains(topic)) + .toList(); + if (topics.isEmpty()) { + return; + } + + var applicationContext = SpringExtension.getApplicationContext(context); + var embeddedKafkaBroker = applicationContext.getBean(EmbeddedKafkaBroker.class); + try (var adminClient = AdminClient.create(adminProperties(embeddedKafkaBroker))) { + var existingTopics = adminClient.listTopics().names().get(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + var missingTopics = topics.stream() + .filter(topic -> !existingTopics.contains(topic)) + .toList(); + if (!missingTopics.isEmpty()) { + embeddedKafkaBroker.addTopics(missingTopics.toArray(String[]::new)); + } + var recordsToDelete = recordsToDelete(embeddedKafkaBroker, topics, annotation.partitions()); + if (!recordsToDelete.isEmpty()) { + adminClient.deleteRecords(recordsToDelete).all().get(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } + } + + private Map recordsToDelete( + EmbeddedKafkaBroker embeddedKafkaBroker, + List topics, + int partitions) { + try (var consumer = new KafkaConsumer(consumerProperties(embeddedKafkaBroker))) { + var topicPartitions = topics.stream() + .flatMap(topic -> topicPartitions(topic, partitions).stream()) + .toList(); + consumer.assign(topicPartitions); + var endOffsets = consumer.endOffsets(topicPartitions); + var recordsToDelete = new HashMap(); + endOffsets.forEach((topicPartition, offset) -> { + if (offset > 0) { + recordsToDelete.put(topicPartition, RecordsToDelete.beforeOffset(offset)); + } + }); + return recordsToDelete; + } + } + + private List topicPartitions(String topic, int partitions) { + return java.util.stream.IntStream.range(0, partitions) + .mapToObj(partition -> new TopicPartition(topic, partition)) + .toList(); + } + + private Properties adminProperties(EmbeddedKafkaBroker embeddedKafkaBroker) { + var properties = new Properties(); + properties.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, embeddedKafkaBroker.getBrokersAsString()); + return properties; + } + + private Properties consumerProperties(EmbeddedKafkaBroker embeddedKafkaBroker) { + var properties = new Properties(); + properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, embeddedKafkaBroker.getBrokersAsString()); + properties.put(ConsumerConfig.GROUP_ID_CONFIG, "embedded-kafka-cleanup-" + UUID.randomUUID()); + properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class); + properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class); + return properties; + } +} diff --git a/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTest.java b/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTest.java new file mode 100644 index 00000000..d8b888e8 --- /dev/null +++ b/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTest.java @@ -0,0 +1,50 @@ +package dev.vality.testcontainers.annotations.postgresql; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Starts an embedded PostgreSQL process and initializes Spring datasource properties. + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(EmbeddedPostgresqlTestExtension.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +public @interface EmbeddedPostgresqlTest { + + /** + * Аналогичный параметр как у аннотации {@link SpringBootTest#properties()}. + */ + String[] properties() default {}; + + /** + * Embedded PostgreSQL database name. + */ + String database() default "postgres"; + + /** + * Embedded PostgreSQL user. + */ + String username() default "postgres"; + + /** + * Embedded PostgreSQL password. Zonky embedded PostgreSQL uses an empty password by default. + */ + String password() default ""; + + /** + * Очищать таблицы между тестами. + */ + boolean truncateTables() default true; + + /** + * Таблицы, которые не нужно очищать между тестами. + */ + String[] excludeTruncateTables() default {}; +} diff --git a/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestExtension.java b/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestExtension.java new file mode 100644 index 00000000..bc9b1483 --- /dev/null +++ b/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestExtension.java @@ -0,0 +1,121 @@ +package dev.vality.testcontainers.annotations.postgresql; + +import io.zonky.test.db.postgres.embedded.EmbeddedPostgres; +import lombok.SneakyThrows; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.platform.commons.support.AnnotationSupport; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfigurationAttributes; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.ContextCustomizerFactory; + +import java.util.List; +import java.util.Optional; + +public class EmbeddedPostgresqlTestExtension implements BeforeAllCallback, BeforeEachCallback, AfterAllCallback { + + private static final ThreadLocal THREAD_POSTGRESQL = new ThreadLocal<>(); + + @Override + public void beforeAll(ExtensionContext context) { + findAnnotation(context).ifPresent(annotation -> THREAD_POSTGRESQL.set(getOrStart(annotation))); + } + + @Override + public void beforeEach(ExtensionContext context) { + findAnnotation(context).ifPresent(annotation -> { + if (annotation.truncateTables()) { + var postgresql = getOrStart(annotation); + PostgresqlDatabaseCleaner.cleanupDatabaseTables( + postgresql.jdbcUrl(), + annotation.username(), + annotation.password(), + List.of(annotation.excludeTruncateTables())); + } + }); + } + + @Override + public void afterAll(ExtensionContext context) { + findAnnotation(context).ifPresent(annotation -> { + var postgresql = THREAD_POSTGRESQL.get(); + THREAD_POSTGRESQL.remove(); + if (postgresql != null) { + postgresql.close(); + } + }); + } + + private static Optional findAnnotation(ExtensionContext context) { + return AnnotationSupport.findAnnotation(context.getTestClass(), EmbeddedPostgresqlTest.class); + } + + private static Optional findAnnotation(Class testClass) { + return AnnotationSupport.findAnnotation(testClass, EmbeddedPostgresqlTest.class); + } + + private static EmbeddedPostgresql getOrStart(EmbeddedPostgresqlTest annotation) { + var postgresql = THREAD_POSTGRESQL.get(); + if (postgresql == null) { + postgresql = EmbeddedPostgresql.start(annotation); + THREAD_POSTGRESQL.set(postgresql); + } + return postgresql; + } + + public static class EmbeddedPostgresqlTestContextCustomizerFactory implements ContextCustomizerFactory { + + @Override + public ContextCustomizer createContextCustomizer( + Class testClass, + List configAttributes) { + return (context, mergedConfig) -> + findAnnotation(testClass).ifPresent(annotation -> init(context, annotation)); + } + + private void init(ConfigurableApplicationContext context, EmbeddedPostgresqlTest annotation) { + var postgresql = getOrStart(annotation); + THREAD_POSTGRESQL.set(postgresql); + var jdbcUrl = postgresql.jdbcUrl(); + var username = annotation.username(); + var password = annotation.password(); + TestPropertyValues.of( + "spring.datasource.url=" + jdbcUrl, + "spring.datasource.username=" + username, + "spring.datasource.password=" + password, + "spring.flyway.url=" + jdbcUrl, + "spring.flyway.user=" + username, + "spring.flyway.password=" + password, + "postgres.db.url=" + jdbcUrl, + "postgres.db.user=" + username, + "postgres.db.username=" + username, + "postgres.db.password=" + password, + "flyway.url=" + jdbcUrl, + "flyway.user=" + username, + "flyway.password=" + password, + "flyway.postgresql.transactional.lock=false") + .and(annotation.properties()) + .applyTo(context); + } + } + + private record EmbeddedPostgresql(EmbeddedPostgres delegate, String jdbcUrl) { + + @SneakyThrows + private static EmbeddedPostgresql start(EmbeddedPostgresqlTest annotation) { + var postgres = EmbeddedPostgres.start(); + return new EmbeddedPostgresql( + postgres, + postgres.getJdbcUrl(annotation.database(), annotation.username())); + } + + @SneakyThrows + private void close() { + delegate.close(); + } + } +} diff --git a/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlContainerExtension.java b/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlContainerExtension.java index aeacc350..2ab2d5a9 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlContainerExtension.java +++ b/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlContainerExtension.java @@ -6,9 +6,8 @@ import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; -import java.sql.Connection; -import java.sql.DriverManager; -import java.util.*; +import java.util.List; +import java.util.UUID; import static dev.vality.testcontainers.annotations.util.SpringApplicationPropertiesLoader.loadDefaultLibraryProperty; @@ -17,13 +16,6 @@ public class PostgresqlContainerExtension extends PostgreSQLContainer EXCLUDE_SCHEMAS = Set.of("information_schema"); - private static final String PG_ = "pg_"; - private static final String SQL_ = "sql_"; public PostgresqlContainerExtension() { super(DockerImageName @@ -35,62 +27,6 @@ public PostgresqlContainerExtension() { @SneakyThrows public void cleanupDatabaseTables(List excludedTables) { - try (var connection = DriverManager.getConnection(getJdbcUrl(), getUsername(), getPassword())) { - for (var schema : getSchemas(connection)) { - var tables = getUserTables(connection, schema, excludedTables); - if (!tables.isEmpty()) { - truncateTables(connection, schema, tables); - } - } - } - } - - @SneakyThrows - private Set getSchemas(Connection connection) { - var schemas = new HashSet(); - try ( - var statement = connection.createStatement(); - var resultSet = statement.executeQuery(CURRENT_SCHEMA_QUERY)) { - while (resultSet.next()) { - var schema = resultSet.getString("schema_name"); - if (!EXCLUDE_SCHEMAS.contains(schema) - && !schema.startsWith(PG_) && !schema.startsWith(SQL_)) { - schemas.add(schema); - } - } - } - return schemas; - } - - @SneakyThrows - private List getUserTables(Connection connection, String schema, List excludedTables) { - var tables = new ArrayList(); - try (var statement = connection.prepareStatement(TABLES_QUERY)) { - statement.setString(1, schema); - try (var resultSet = statement.executeQuery()) { - while (resultSet.next()) { - var tableName = resultSet.getString("tablename"); - boolean isTruncatable = !tableName.startsWith(PG_) - && !tableName.startsWith(SQL_) - && !excludedTables.contains(tableName); - if (isTruncatable) { - tables.add(tableName); - } - } - } - } - return tables; - } - - @SneakyThrows - private void truncateTables(Connection connection, String schema, List tables) { - try (var statement = connection.createStatement()) { - statement.execute("SET session_replication_role = 'replica'"); - for (var table : tables) { - log.debug("Truncating table: {}", table); - statement.execute(String.format(TRUNCATE_TABLE_QUERY, schema, table)); - } - statement.execute("SET session_replication_role = 'origin'"); - } + PostgresqlDatabaseCleaner.cleanupDatabaseTables(getJdbcUrl(), getUsername(), getPassword(), excludedTables); } } diff --git a/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlDatabaseCleaner.java b/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlDatabaseCleaner.java new file mode 100644 index 00000000..530db85d --- /dev/null +++ b/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlDatabaseCleaner.java @@ -0,0 +1,91 @@ +package dev.vality.testcontainers.annotations.postgresql; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PostgresqlDatabaseCleaner { + + private static final String CURRENT_SCHEMA_QUERY = "SELECT schema_name FROM information_schema.schemata"; + private static final String TABLES_QUERY = "SELECT tablename FROM pg_tables " + + "WHERE schemaname = ? AND tablename NOT LIKE 'flyway%'AND tablename NOT LIKE 'schema_version'"; + private static final String TRUNCATE_TABLE_QUERY = "TRUNCATE TABLE %s.%s CASCADE"; + private static final Set EXCLUDE_SCHEMAS = Set.of("information_schema"); + private static final String PG_ = "pg_"; + private static final String SQL_ = "sql_"; + + @SneakyThrows + public static void cleanupDatabaseTables( + String jdbcUrl, + String username, + String password, + List excludedTables) { + try (var connection = DriverManager.getConnection(jdbcUrl, username, password)) { + for (var schema : getSchemas(connection)) { + var tables = getUserTables(connection, schema, excludedTables); + if (!tables.isEmpty()) { + truncateTables(connection, schema, tables); + } + } + } + } + + @SneakyThrows + private static Set getSchemas(Connection connection) { + var schemas = new HashSet(); + try ( + var statement = connection.createStatement(); + var resultSet = statement.executeQuery(CURRENT_SCHEMA_QUERY)) { + while (resultSet.next()) { + var schema = resultSet.getString("schema_name"); + if (!EXCLUDE_SCHEMAS.contains(schema) + && !schema.startsWith(PG_) && !schema.startsWith(SQL_)) { + schemas.add(schema); + } + } + } + return schemas; + } + + @SneakyThrows + private static List getUserTables(Connection connection, String schema, List excludedTables) { + var tables = new ArrayList(); + try (var statement = connection.prepareStatement(TABLES_QUERY)) { + statement.setString(1, schema); + try (var resultSet = statement.executeQuery()) { + while (resultSet.next()) { + var tableName = resultSet.getString("tablename"); + boolean isTruncatable = !tableName.startsWith(PG_) + && !tableName.startsWith(SQL_) + && !excludedTables.contains(tableName); + if (isTruncatable) { + tables.add(tableName); + } + } + } + } + return tables; + } + + @SneakyThrows + private static void truncateTables(Connection connection, String schema, List tables) { + try (var statement = connection.createStatement()) { + statement.execute("SET session_replication_role = 'replica'"); + for (var table : tables) { + log.debug("Truncating table: {}", table); + statement.execute(String.format(TRUNCATE_TABLE_QUERY, schema, table)); + } + statement.execute("SET session_replication_role = 'origin'"); + } + } +} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index 06273234..67b1c455 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -2,6 +2,8 @@ org.springframework.test.context.ContextCustomizerFactory=\ dev.vality.testcontainers.annotations.postgresql.PostgresqlTestcontainerExtension.PostgresqlTestcontainerContextCustomizerFactory,\ dev.vality.testcontainers.annotations.kafka.KafkaTestcontainerExtension.KafkaTestcontainerContextCustomizerFactory,\ +dev.vality.testcontainers.annotations.kafka.EmbeddedKafkaTestContextCustomizerFactory,\ dev.vality.testcontainers.annotations.clickhouse.ClickhouseTestcontainerExtension.ClickhouseTestcontainerContextCustomizerFactory,\ dev.vality.testcontainers.annotations.minio.MinioTestcontainerExtension.MinioTestcontainerContextCustomizerFactory,\ -dev.vality.testcontainers.annotations.opensearch.OpensearchTestcontainerExtension.OpensearchTestcontainerContextCustomizerFactory +dev.vality.testcontainers.annotations.opensearch.OpensearchTestcontainerExtension.OpensearchTestcontainerContextCustomizerFactory,\ +dev.vality.testcontainers.annotations.postgresql.EmbeddedPostgresqlTestExtension.EmbeddedPostgresqlTestContextCustomizerFactory diff --git a/src/test/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestIntegrationTest.java b/src/test/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestIntegrationTest.java new file mode 100644 index 00000000..547b3378 --- /dev/null +++ b/src/test/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestIntegrationTest.java @@ -0,0 +1,125 @@ +package dev.vality.testcontainers.annotations.kafka; + +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.kafka.test.EmbeddedKafkaBroker; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@EmbeddedKafkaTest( + topics = EmbeddedKafkaTestIntegrationTest.TOPIC, + properties = { + "app.kafka.topic=" + EmbeddedKafkaTestIntegrationTest.TOPIC, + "spring.kafka.consumer.group-id=embedded-kafka-test" + } +) +@SpringBootTest(classes = EmbeddedKafkaTestIntegrationTest.Config.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class EmbeddedKafkaTestIntegrationTest { + + static final String TOPIC = "embedded-kafka-test-topic"; + + @Autowired + private Environment environment; + + @Autowired + private EmbeddedKafkaBroker embeddedKafkaBroker; + + @Test + @Order(1) + void shouldStartEmbeddedKafkaAndExposeBootstrapProperties() throws Exception { + var bootstrapServers = environment.getRequiredProperty("spring.kafka.bootstrap-servers"); + + assertThat(bootstrapServers) + .isEqualTo(embeddedKafkaBroker.getBrokersAsString()); + assertThat(environment.getRequiredProperty("kafka.bootstrap-servers")) + .isEqualTo(embeddedKafkaBroker.getBrokersAsString()); + assertThat(environment.getRequiredProperty("kafka.ssl.enabled")) + .isEqualTo("false"); + assertThat(environment.getRequiredProperty("app.kafka.topic")) + .isEqualTo(TOPIC); + + try (var adminClient = AdminClient.create(adminProperties(bootstrapServers))) { + assertThat(adminClient.listTopics().names().get(10, TimeUnit.SECONDS)) + .contains(TOPIC); + } + + try (var producer = new KafkaProducer(producerProperties(bootstrapServers))) { + producer.send(new ProducerRecord<>(TOPIC, "key", "value")).get(10, TimeUnit.SECONDS); + producer.flush(); + } + + assertThat(readValues("first-reader", bootstrapServers)) + .contains("value"); + } + + @Test + @Order(2) + void shouldCleanupTopicsBeforeEachTest() { + var bootstrapServers = environment.getRequiredProperty("spring.kafka.bootstrap-servers"); + + assertThat(readValues("second-reader", bootstrapServers)) + .isEmpty(); + } + + private Properties adminProperties(String bootstrapServers) { + var properties = new Properties(); + properties.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + return properties; + } + + private Properties producerProperties(String bootstrapServers) { + var properties = new Properties(); + properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + properties.put(ProducerConfig.ACKS_CONFIG, "all"); + return properties; + } + + private List readValues(String groupId, String bootstrapServers) { + try (var consumer = new KafkaConsumer(consumerProperties(groupId, bootstrapServers))) { + consumer.subscribe(List.of(TOPIC)); + var values = new ArrayList(); + for (var record : consumer.poll(Duration.ofMillis(500)).records(TOPIC)) { + values.add(record.value()); + } + return values; + } + } + + private Properties consumerProperties(String groupId, String bootstrapServers) { + var properties = new Properties(); + properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + properties.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + return properties; + } + + @Configuration + static class Config { + } +} diff --git a/src/test/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestIntegrationTest.java b/src/test/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestIntegrationTest.java new file mode 100644 index 00000000..5e7bc4f7 --- /dev/null +++ b/src/test/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestIntegrationTest.java @@ -0,0 +1,82 @@ +package dev.vality.testcontainers.annotations.postgresql; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +import java.sql.Connection; +import java.sql.DriverManager; + +import static org.assertj.core.api.Assertions.assertThat; + +@EmbeddedPostgresqlTest +@SpringBootTest(classes = EmbeddedPostgresqlTestIntegrationTest.Config.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class EmbeddedPostgresqlTestIntegrationTest { + + private static final String TABLE_NAME = "embedded_postgresql_test"; + + @Autowired + private Environment environment; + + @Test + @Order(1) + void shouldStartEmbeddedPostgresqlAndExposeDatasourceProperties() throws Exception { + assertThat(environment.getRequiredProperty("spring.datasource.url")) + .startsWith("jdbc:postgresql://localhost:"); + assertThat(environment.getRequiredProperty("spring.datasource.username")) + .isEqualTo("postgres"); + assertThat(environment.getRequiredProperty("spring.datasource.password")) + .isEmpty(); + assertThat(environment.getRequiredProperty("postgres.db.url")) + .isEqualTo(environment.getRequiredProperty("spring.datasource.url")); + + try (var connection = connection()) { + execute(connection, "CREATE TABLE IF NOT EXISTS " + TABLE_NAME + " (id INT PRIMARY KEY)"); + execute(connection, "INSERT INTO " + TABLE_NAME + " (id) VALUES (1)"); + + assertThat(countRows(connection)) + .isEqualTo(1); + } + } + + @Test + @Order(2) + void shouldCleanupTablesBeforeEachTest() throws Exception { + try (var connection = connection()) { + assertThat(countRows(connection)) + .isZero(); + } + } + + private Connection connection() throws Exception { + return DriverManager.getConnection( + environment.getRequiredProperty("spring.datasource.url"), + environment.getRequiredProperty("spring.datasource.username"), + environment.getRequiredProperty("spring.datasource.password")); + } + + private void execute(Connection connection, String sql) throws Exception { + try (var statement = connection.createStatement()) { + statement.execute(sql); + } + } + + private int countRows(Connection connection) throws Exception { + try ( + var statement = connection.createStatement(); + var resultSet = statement.executeQuery("SELECT COUNT(*) FROM " + TABLE_NAME)) { + resultSet.next(); + return resultSet.getInt(1); + } + } + + @Configuration + static class Config { + } +} From 81751e3ccc927b579ac45feb4e0a510ddc312014 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 8 May 2026 15:13:16 +0700 Subject: [PATCH 2/9] suitable default annotations --- .../ClickhouseContainerExtension.java | 15 +++++--- .../ClickhouseTestcontainerFactory.java | 23 ++++++++++-- .../kafka/KafkaContainerExtension.java | 36 +++++++++++++------ .../kafka/KafkaTestcontainerExtension.java | 10 ++++-- .../kafka/KafkaTestcontainerFactory.java | 25 +++++++++---- .../minio/MinioTestcontainerFactory.java | 11 +++--- .../OpensearchTestcontainerExtension.java | 8 ++--- .../OpensearchTestcontainerFactory.java | 25 +++++++------ .../PostgresqlTestcontainerExtension.java | 7 ++-- .../PostgresqlTestcontainerFactory.java | 4 +-- .../SpringApplicationPropertiesLoader.java | 7 +++- 11 files changed, 117 insertions(+), 54 deletions(-) diff --git a/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseContainerExtension.java b/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseContainerExtension.java index ce116bb2..2dcb4b75 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseContainerExtension.java +++ b/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseContainerExtension.java @@ -7,11 +7,13 @@ import org.testcontainers.containers.Network; import org.testcontainers.utility.DockerImageName; +import java.io.FileNotFoundException; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; +import java.util.Optional; import java.util.Properties; import java.util.UUID; @@ -76,7 +78,9 @@ private void executeMigration(Connection connection, String path) { var split = sql.split(";"); for (var exec : split) { if (exec != null && !exec.trim().isEmpty()) { - connection.createStatement().execute(exec); + try (var statement = connection.createStatement()) { + statement.execute(exec); + } } } } catch (SQLException e) { @@ -87,11 +91,14 @@ private void executeMigration(Connection connection, String path) { private String getFile(String fileName) { var classLoader = ClickhouseContainerExtension.class.getClassLoader(); - try { - return IOUtils.toString(classLoader.getResourceAsStream(fileName), StandardCharsets.UTF_8); + try (var inputStream = Optional.ofNullable(classLoader.getResourceAsStream(fileName)) + .orElseThrow(() -> new ClickhouseStartingException( + "Migration file not found: " + fileName, + new FileNotFoundException(fileName)))) { + return IOUtils.toString(inputStream, StandardCharsets.UTF_8); } catch (IOException e) { log.error("Error when getFile e: ", e); - return ""; + throw new ClickhouseStartingException("Error when reading migration file: " + fileName, e); } } } diff --git a/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java b/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java index ae4da713..6f1dac94 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java +++ b/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java @@ -5,6 +5,8 @@ import lombok.Synchronized; import lombok.extern.slf4j.Slf4j; +import java.util.Arrays; + /** * Фабрика по созданию контейнеров *

{@link #create(String, String[])} создает экземпляр тестконтейнера @@ -17,6 +19,8 @@ public class ClickhouseTestcontainerFactory { private ClickhouseContainerExtension clickHouseContainer; + private String singletonDatabaseName; + private String[] singletonMigrations; public static ClickhouseContainerExtension container(String databaseName, String[] migrations) { return instance().create(databaseName, migrations); @@ -33,15 +37,30 @@ private static ClickhouseTestcontainerFactory instance() { @Synchronized private ClickhouseContainerExtension getOrCreateSingletonContainer(String databaseName, String[] migrations) { if (clickHouseContainer != null) { + validateSingletonConfig(databaseName, migrations); return clickHouseContainer; } clickHouseContainer = create(databaseName, migrations); + singletonDatabaseName = databaseName; + singletonMigrations = Arrays.copyOf(migrations, migrations.length); return clickHouseContainer; } private ClickhouseContainerExtension create(String databaseName, String[] migrations) { - try (var container = new ClickhouseContainerExtension(databaseName, migrations)) { - return container; + return new ClickhouseContainerExtension(databaseName, migrations); + } + + private void validateSingletonConfig(String databaseName, String[] migrations) { + if (!singletonDatabaseName.equals(databaseName) || !Arrays.equals(singletonMigrations, migrations)) { + throw new IllegalStateException( + ("ClickHouse singleton testcontainer was already created with databaseName=%s migrations=%s, " + + "but requested databaseName=%s migrations=%s. Use the same singleton ClickHouse " + + "configuration across test classes or switch to non-singleton @ClickhouseTestcontainer.") + .formatted( + singletonDatabaseName, + Arrays.toString(singletonMigrations), + databaseName, + Arrays.toString(migrations))); } } diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaContainerExtension.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaContainerExtension.java index bd15dae6..d8e3e04b 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaContainerExtension.java +++ b/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaContainerExtension.java @@ -38,7 +38,17 @@ default void createTopics(List excludedTopics) { var topics = topics().stream() .filter(topic -> !excludedTopics.contains(topic)) .toList(); - var newTopics = topics.stream() + if (topics.isEmpty()) { + return; + } + var adminClientTopics = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); + var topicsToCreate = topics.stream() + .filter(topic -> !adminClientTopics.contains(topic)) + .toList(); + if (topicsToCreate.isEmpty()) { + return; + } + var newTopics = topicsToCreate.stream() .map(topic -> new NewTopic(topic, 1, (short) 1)) .peek(newTopic -> log.info(newTopic.toString())) .collect(Collectors.toList()); @@ -47,12 +57,12 @@ default void createTopics(List excludedTopics) { .atMost(Duration.ofSeconds(WAIT_TIMEOUT)) .pollInterval(Duration.ofSeconds(2)) .untilAsserted(() -> topicsResult.all().get(1, TimeUnit.SECONDS)); - var adminClientTopics = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); + adminClientTopics = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); log.info("Topics list from 'AdminClient' after [TOPICS CREATED]: {}", adminClientTopics); - assertThat(adminClientTopics.size()) - .isEqualTo(topics.size()); + assertThat(adminClientTopics) + .containsAll(topicsToCreate); var actual = execInContainerKafkaTopicsListCommand(); - assertThat(topics.stream().allMatch(actual::contains)) + assertThat(topicsToCreate.stream().allMatch(actual::contains)) .isTrue(); } catch (ExecutionException | TimeoutException ex) { throw new KafkaStartingException("Error when topic creating, ", ex); @@ -71,17 +81,23 @@ default void deleteTopics(List excludedTopics) { var topics = topics().stream() .filter(topic -> !excludedTopics.contains(topic)) .toList(); - var topicsResult = admin.deleteTopics(topics); + var topicsToDelete = topics.stream() + .filter(adminClientTopics::contains) + .toList(); + if (topicsToDelete.isEmpty()) { + return; + } + var topicsResult = admin.deleteTopics(topicsToDelete); Awaitility.await() .atMost(Duration.ofSeconds(WAIT_TIMEOUT)) .pollInterval(Duration.ofSeconds(2)) .untilAsserted(() -> topicsResult.all().get(1, TimeUnit.SECONDS)); adminClientTopics = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); - log.info("Topics list from 'AdminClient' after [TOPICS DELETED]: {} (should be empty)", adminClientTopics); - assertThat(adminClientTopics) - .isEmpty(); + log.info("Topics list from 'AdminClient' after [TOPICS DELETED]: {}", adminClientTopics); + assertThat(adminClientTopics.stream().noneMatch(topicsToDelete::contains)) + .isTrue(); var actual = execInContainerKafkaTopicsListCommand(); - assertThat(topics.stream().noneMatch(actual::contains)) + assertThat(topicsToDelete.stream().noneMatch(actual::contains)) .isTrue(); } catch (ExecutionException | TimeoutException ex) { throw new KafkaStartingException("Error when topic deleting, ", ex); diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerExtension.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerExtension.java index 99bcb134..d1eb2d37 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerExtension.java +++ b/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerExtension.java @@ -69,10 +69,14 @@ public void beforeAll(ExtensionContext context) { var container = KafkaTestcontainerFactory.singletonContainer(annotation.provider(), topics); if (!container.isRunning()) { GenericContainerUtil.startContainer(container); - } else { - container.deleteTopics(List.of()); + container.createTopics(List.of()); + } else if (annotation.truncateTopics()) { + var excludedTopics = Optional.ofNullable(annotation.excludeTruncateTopics()) + .map(List::of) + .orElse(List.of()); + container.deleteTopics(excludedTopics); + container.createTopics(excludedTopics); } - container.createTopics(List.of()); THREAD_CONTAINER.set(container); } } diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerFactory.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerFactory.java index 528e1df3..0b0eda7d 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerFactory.java +++ b/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerFactory.java @@ -6,7 +6,9 @@ import lombok.Synchronized; import lombok.extern.slf4j.Slf4j; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; /** * Фабрика по созданию контейнеров @@ -20,6 +22,8 @@ public class KafkaTestcontainerFactory { private KafkaContainerExtension kafkaContainer; + private Provider singletonProvider; + private List singletonTopics; public static KafkaContainerExtension container(Provider provider, List topics) { return instance().create(provider, topics); @@ -36,27 +40,36 @@ private static KafkaTestcontainerFactory instance() { @Synchronized private KafkaContainerExtension getOrCreateSingletonContainer(Provider provider, List topics) { if (kafkaContainer != null) { + validateSingletonConfig(provider, topics); return kafkaContainer; } kafkaContainer = create(provider, topics); + singletonProvider = provider; + singletonTopics = new ArrayList<>(topics); return kafkaContainer; } private KafkaContainerExtension create(Provider provider, List topics) { return switch (provider) { case APACHE -> { - try (var container = new ApacheKafkaContainer(topics)) { - yield container; - } + yield new ApacheKafkaContainer(topics); } case CONFLUENT -> { - try (var container = new ConfluentKafkaContainer(topics)) { - yield container; - } + yield new ConfluentKafkaContainer(topics); } }; } + private void validateSingletonConfig(Provider provider, List topics) { + if (singletonProvider != provider || !Objects.equals(singletonTopics, topics)) { + throw new IllegalStateException( + ("Kafka singleton testcontainer was already created with provider=%s topics=%s, " + + "but requested provider=%s topics=%s. Use the same singleton Kafka configuration " + + "across test classes or switch to non-singleton @KafkaTestcontainer.") + .formatted(singletonProvider, singletonTopics, provider, topics)); + } + } + private static class SingletonHolder { private static final KafkaTestcontainerFactory INSTANCE = new KafkaTestcontainerFactory(); diff --git a/src/main/java/dev/vality/testcontainers/annotations/minio/MinioTestcontainerFactory.java b/src/main/java/dev/vality/testcontainers/annotations/minio/MinioTestcontainerFactory.java index 30997cb9..9945356b 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/minio/MinioTestcontainerFactory.java +++ b/src/main/java/dev/vality/testcontainers/annotations/minio/MinioTestcontainerFactory.java @@ -54,7 +54,7 @@ private GenericContainer getOrCreateSingletonContainer() { } private GenericContainer create() { - try (GenericContainer container = new GenericContainer<>( + GenericContainer container = new GenericContainer<>( DockerImageName .parse(MINIO_IMAGE_NAME) .withTag(loadDefaultLibraryProperty(TAG_PROPERTY))) @@ -62,11 +62,10 @@ private GenericContainer create() { .withEnv("MINIO_ROOT_USER", loadDefaultLibraryProperty(MINIO_USER)) .withEnv("MINIO_ROOT_PASSWORD", loadDefaultLibraryProperty(MINIO_PASSWORD)) .withCommand("server /data{1...12}") - .waitingFor(getWaitStrategy("/minio/health/live", 200, 9000, Duration.ofMinutes(1)))) { - container.withNetworkAliases("minio-" + UUID.randomUUID()); - container.withNetwork(Network.SHARED); - return container; - } + .waitingFor(getWaitStrategy("/minio/health/live", 200, 9000, Duration.ofMinutes(1))); + container.withNetworkAliases("minio-" + UUID.randomUUID()); + container.withNetwork(Network.SHARED); + return container; } private static class SingletonHolder { diff --git a/src/main/java/dev/vality/testcontainers/annotations/opensearch/OpensearchTestcontainerExtension.java b/src/main/java/dev/vality/testcontainers/annotations/opensearch/OpensearchTestcontainerExtension.java index 54ed723f..471a470c 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/opensearch/OpensearchTestcontainerExtension.java +++ b/src/main/java/dev/vality/testcontainers/annotations/opensearch/OpensearchTestcontainerExtension.java @@ -47,10 +47,10 @@ public void beforeEach(ExtensionContext context) { var container = THREAD_CONTAINER.get(); if (container != null && container.isRunning()) { var builder = RestClient.builder(new HttpHost(container.getHost(), container.getFirstMappedPort())); - var client = builder.build(); - var deleteRequest = new Request("DELETE", "/*"); - client.performRequest(deleteRequest); - client.close(); + try (var client = builder.build()) { + var deleteRequest = new Request("DELETE", "/*"); + client.performRequest(deleteRequest); + } } } diff --git a/src/main/java/dev/vality/testcontainers/annotations/opensearch/OpensearchTestcontainerFactory.java b/src/main/java/dev/vality/testcontainers/annotations/opensearch/OpensearchTestcontainerFactory.java index 140aec87..37a9859b 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/opensearch/OpensearchTestcontainerFactory.java +++ b/src/main/java/dev/vality/testcontainers/annotations/opensearch/OpensearchTestcontainerFactory.java @@ -42,21 +42,20 @@ private GenericContainer getOrCreateSingletonContainer() { } private GenericContainer create() { - try (var container = new GenericContainer<>( + var container = new GenericContainer<>( DockerImageName .parse(OPENSEARCH_IMAGE_NAME) - .withTag(loadDefaultLibraryProperty(TAG_PROPERTY)))) { - container.withNetworkAliases("opensearch-" + UUID.randomUUID()); - container.withNetwork(Network.SHARED); - container.withExposedPorts(9200, 9600); - container.setWaitStrategy((new HttpWaitStrategy()) - .forPort(9200) - .forStatusCodeMatching(response -> response == 200 || response == 401)); - container.withEnv("discovery.type", "single-node"); - container.withEnv("DISABLE_INSTALL_DEMO_CONFIG", "true"); - container.withEnv("DISABLE_SECURITY_PLUGIN", "true"); - return container; - } + .withTag(loadDefaultLibraryProperty(TAG_PROPERTY))); + container.withNetworkAliases("opensearch-" + UUID.randomUUID()); + container.withNetwork(Network.SHARED); + container.withExposedPorts(9200, 9600); + container.setWaitStrategy((new HttpWaitStrategy()) + .forPort(9200) + .forStatusCodeMatching(response -> response == 200 || response == 401)); + container.withEnv("discovery.type", "single-node"); + container.withEnv("DISABLE_INSTALL_DEMO_CONFIG", "true"); + container.withEnv("DISABLE_SECURITY_PLUGIN", "true"); + return container; } private static class SingletonHolder { diff --git a/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlTestcontainerExtension.java b/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlTestcontainerExtension.java index 4d5b9a66..e52e5a31 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlTestcontainerExtension.java +++ b/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlTestcontainerExtension.java @@ -50,8 +50,11 @@ public void beforeAll(ExtensionContext context) { var container = PostgresqlTestcontainerFactory.singletonContainer(); if (!container.isRunning()) { GenericContainerUtil.startContainer(container); - } else { - container.cleanupDatabaseTables(List.of()); + } else if (findSingletonAnnotation(context).get().truncateTables()) { + var excludedTables = Optional.ofNullable(findSingletonAnnotation(context).get().excludeTruncateTables()) + .map(List::of) + .orElse(List.of()); + container.cleanupDatabaseTables(excludedTables); } THREAD_CONTAINER.set(container); diff --git a/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlTestcontainerFactory.java b/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlTestcontainerFactory.java index 418804ca..c3df4630 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlTestcontainerFactory.java +++ b/src/main/java/dev/vality/testcontainers/annotations/postgresql/PostgresqlTestcontainerFactory.java @@ -38,9 +38,7 @@ private PostgresqlContainerExtension getOrCreateSingletonContainer() { } private PostgresqlContainerExtension create() { - try (var container = new PostgresqlContainerExtension()) { - return container; - } + return new PostgresqlContainerExtension(); } private static class SingletonHolder { diff --git a/src/main/java/dev/vality/testcontainers/annotations/util/SpringApplicationPropertiesLoader.java b/src/main/java/dev/vality/testcontainers/annotations/util/SpringApplicationPropertiesLoader.java index e7c22018..9f29d00a 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/util/SpringApplicationPropertiesLoader.java +++ b/src/main/java/dev/vality/testcontainers/annotations/util/SpringApplicationPropertiesLoader.java @@ -30,7 +30,12 @@ public class SpringApplicationPropertiesLoader { ); public static String loadDefaultLibraryProperty(String key) { - var tag = loadPropertiesByFile().get(key); + Object tag; + try { + tag = loadPropertiesByFile().get(key); + } catch (NoSuchFileException ex) { + tag = null; + } if (tag == null) { tag = getSource(findPropertiesFileParametersByName("testcontainers-annotations")).get(key); } From 1b92f3b9496dadbe04facb64b7a6885d0a9c5bc6 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 15 May 2026 12:10:14 +0700 Subject: [PATCH 3/9] suitable default annotations --- .../ClickhouseTestcontainerFactory.java | 23 ++--------------- .../kafka/KafkaTestcontainerFactory.java | 25 ++----------------- 2 files changed, 4 insertions(+), 44 deletions(-) diff --git a/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java b/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java index 6f1dac94..ae4da713 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java +++ b/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java @@ -5,8 +5,6 @@ import lombok.Synchronized; import lombok.extern.slf4j.Slf4j; -import java.util.Arrays; - /** * Фабрика по созданию контейнеров *

{@link #create(String, String[])} создает экземпляр тестконтейнера @@ -19,8 +17,6 @@ public class ClickhouseTestcontainerFactory { private ClickhouseContainerExtension clickHouseContainer; - private String singletonDatabaseName; - private String[] singletonMigrations; public static ClickhouseContainerExtension container(String databaseName, String[] migrations) { return instance().create(databaseName, migrations); @@ -37,30 +33,15 @@ private static ClickhouseTestcontainerFactory instance() { @Synchronized private ClickhouseContainerExtension getOrCreateSingletonContainer(String databaseName, String[] migrations) { if (clickHouseContainer != null) { - validateSingletonConfig(databaseName, migrations); return clickHouseContainer; } clickHouseContainer = create(databaseName, migrations); - singletonDatabaseName = databaseName; - singletonMigrations = Arrays.copyOf(migrations, migrations.length); return clickHouseContainer; } private ClickhouseContainerExtension create(String databaseName, String[] migrations) { - return new ClickhouseContainerExtension(databaseName, migrations); - } - - private void validateSingletonConfig(String databaseName, String[] migrations) { - if (!singletonDatabaseName.equals(databaseName) || !Arrays.equals(singletonMigrations, migrations)) { - throw new IllegalStateException( - ("ClickHouse singleton testcontainer was already created with databaseName=%s migrations=%s, " - + "but requested databaseName=%s migrations=%s. Use the same singleton ClickHouse " - + "configuration across test classes or switch to non-singleton @ClickhouseTestcontainer.") - .formatted( - singletonDatabaseName, - Arrays.toString(singletonMigrations), - databaseName, - Arrays.toString(migrations))); + try (var container = new ClickhouseContainerExtension(databaseName, migrations)) { + return container; } } diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerFactory.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerFactory.java index 0b0eda7d..5c3aa57b 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerFactory.java +++ b/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaTestcontainerFactory.java @@ -6,9 +6,7 @@ import lombok.Synchronized; import lombok.extern.slf4j.Slf4j; -import java.util.ArrayList; import java.util.List; -import java.util.Objects; /** * Фабрика по созданию контейнеров @@ -22,8 +20,6 @@ public class KafkaTestcontainerFactory { private KafkaContainerExtension kafkaContainer; - private Provider singletonProvider; - private List singletonTopics; public static KafkaContainerExtension container(Provider provider, List topics) { return instance().create(provider, topics); @@ -40,36 +36,19 @@ private static KafkaTestcontainerFactory instance() { @Synchronized private KafkaContainerExtension getOrCreateSingletonContainer(Provider provider, List topics) { if (kafkaContainer != null) { - validateSingletonConfig(provider, topics); return kafkaContainer; } kafkaContainer = create(provider, topics); - singletonProvider = provider; - singletonTopics = new ArrayList<>(topics); return kafkaContainer; } private KafkaContainerExtension create(Provider provider, List topics) { return switch (provider) { - case APACHE -> { - yield new ApacheKafkaContainer(topics); - } - case CONFLUENT -> { - yield new ConfluentKafkaContainer(topics); - } + case APACHE -> new ApacheKafkaContainer(topics); + case CONFLUENT -> new ConfluentKafkaContainer(topics); }; } - private void validateSingletonConfig(Provider provider, List topics) { - if (singletonProvider != provider || !Objects.equals(singletonTopics, topics)) { - throw new IllegalStateException( - ("Kafka singleton testcontainer was already created with provider=%s topics=%s, " - + "but requested provider=%s topics=%s. Use the same singleton Kafka configuration " - + "across test classes or switch to non-singleton @KafkaTestcontainer.") - .formatted(singletonProvider, singletonTopics, provider, topics)); - } - } - private static class SingletonHolder { private static final KafkaTestcontainerFactory INSTANCE = new KafkaTestcontainerFactory(); From 32fb6be6baf37d2e114f657195dea287a5bea188 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 15 May 2026 12:13:43 +0700 Subject: [PATCH 4/9] suitable default annotations --- .../kafka/KafkaContainerExtension.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaContainerExtension.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaContainerExtension.java index d8e3e04b..66307727 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaContainerExtension.java +++ b/src/main/java/dev/vality/testcontainers/annotations/kafka/KafkaContainerExtension.java @@ -41,9 +41,9 @@ default void createTopics(List excludedTopics) { if (topics.isEmpty()) { return; } - var adminClientTopics = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); + var existingTopics = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); var topicsToCreate = topics.stream() - .filter(topic -> !adminClientTopics.contains(topic)) + .filter(topic -> !existingTopics.contains(topic)) .toList(); if (topicsToCreate.isEmpty()) { return; @@ -57,9 +57,9 @@ default void createTopics(List excludedTopics) { .atMost(Duration.ofSeconds(WAIT_TIMEOUT)) .pollInterval(Duration.ofSeconds(2)) .untilAsserted(() -> topicsResult.all().get(1, TimeUnit.SECONDS)); - adminClientTopics = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); - log.info("Topics list from 'AdminClient' after [TOPICS CREATED]: {}", adminClientTopics); - assertThat(adminClientTopics) + var topicsAfterCreate = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); + log.info("Topics list from 'AdminClient' after [TOPICS CREATED]: {}", topicsAfterCreate); + assertThat(topicsAfterCreate) .containsAll(topicsToCreate); var actual = execInContainerKafkaTopicsListCommand(); assertThat(topicsToCreate.stream().allMatch(actual::contains)) @@ -74,15 +74,15 @@ default void createTopics(List excludedTopics) { default void deleteTopics(List excludedTopics) { try (var admin = createAdminClient()) { - var adminClientTopics = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); - if (adminClientTopics.isEmpty()) { + var existingTopics = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); + if (existingTopics.isEmpty()) { return; } var topics = topics().stream() .filter(topic -> !excludedTopics.contains(topic)) .toList(); var topicsToDelete = topics.stream() - .filter(adminClientTopics::contains) + .filter(existingTopics::contains) .toList(); if (topicsToDelete.isEmpty()) { return; @@ -92,9 +92,9 @@ default void deleteTopics(List excludedTopics) { .atMost(Duration.ofSeconds(WAIT_TIMEOUT)) .pollInterval(Duration.ofSeconds(2)) .untilAsserted(() -> topicsResult.all().get(1, TimeUnit.SECONDS)); - adminClientTopics = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); - log.info("Topics list from 'AdminClient' after [TOPICS DELETED]: {}", adminClientTopics); - assertThat(adminClientTopics.stream().noneMatch(topicsToDelete::contains)) + var topicsAfterDelete = admin.listTopics().names().get(WAIT_TIMEOUT, TimeUnit.SECONDS); + log.info("Topics list from 'AdminClient' after [TOPICS DELETED]: {}", topicsAfterDelete); + assertThat(topicsAfterDelete.stream().noneMatch(topicsToDelete::contains)) .isTrue(); var actual = execInContainerKafkaTopicsListCommand(); assertThat(topicsToDelete.stream().noneMatch(actual::contains)) From 3f65b2870055229ff8c8c096d1aacebd99fce175 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 15 May 2026 12:32:27 +0700 Subject: [PATCH 5/9] suitable default annotations --- README.md | 5 +- .../demo/DemoClickhouseTestcontainer.java | 20 ---- .../annotations/kafka/EmbeddedKafkaTest.java | 93 ++++++++++++------- .../kafka/EmbeddedKafkaTestExtension.java | 7 +- .../kafka/demo/DemoKafkaTestcontainer.java | 12 --- 5 files changed, 67 insertions(+), 70 deletions(-) delete mode 100644 src/main/java/dev/vality/testcontainers/annotations/clickhouse/demo/DemoClickhouseTestcontainer.java delete mode 100644 src/main/java/dev/vality/testcontainers/annotations/kafka/demo/DemoKafkaTestcontainer.java diff --git a/README.md b/README.md index d067a68d..5ab248a2 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,14 @@ opensearchproject/opensearch ## Embedded режим без Docker Для легковесных интеграционных тестов можно использовать embedded-аннотации. Библиотека сама запускает локальный -backend, -инициализирует runtime-настройки в Spring context и очищает данные между test methods. +backend, инициализирует runtime-настройки в Spring context и очищает данные между test methods. | embedded | backend | |-------------------------|---------------------------------| | @EmbeddedPostgresqlTest | io.zonky.test embedded-postgres | | @EmbeddedKafkaTest | spring-kafka-test EmbeddedKafka | -Пример замены Docker Kafka/PostgreSQL на embedded: +Пример: ```java @EmbeddedPostgresqlTest diff --git a/src/main/java/dev/vality/testcontainers/annotations/clickhouse/demo/DemoClickhouseTestcontainer.java b/src/main/java/dev/vality/testcontainers/annotations/clickhouse/demo/DemoClickhouseTestcontainer.java deleted file mode 100644 index 36e28d7d..00000000 --- a/src/main/java/dev/vality/testcontainers/annotations/clickhouse/demo/DemoClickhouseTestcontainer.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.vality.testcontainers.annotations.clickhouse.demo; - -import dev.vality.testcontainers.annotations.clickhouse.ClickhouseTestcontainer; - -/** - * this is a demo example of filling in annotation, do not use - */ -@ClickhouseTestcontainer( - dbNameShouldBeDropped = "databaseName", - properties = "clickhouse.db.connection.timeout=60000", - migrations = { - "sql/db_init.sql", - "sql/V2__create_events_p2p.sql", - "sql/V3__create_fraud_payments.sql", - "sql/V4__create_payment.sql", - "sql/V5__add_fields.sql", - "sql/V6__add_result_fields_payment.sql", - "sql/V7__add_fields.sql"}) -public @interface DemoClickhouseTestcontainer { -} diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTest.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTest.java index 9a1961f8..8b250d11 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTest.java +++ b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTest.java @@ -1,7 +1,10 @@ package dev.vality.testcontainers.annotations.kafka; import dev.vality.testcontainers.annotations.KafkaTestConfig; +import dev.vality.testcontainers.annotations.kafka.config.KafkaConsumer; +import dev.vality.testcontainers.annotations.kafka.config.KafkaProducer; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.core.annotation.AliasFor; import org.springframework.kafka.test.context.EmbeddedKafka; import org.springframework.test.annotation.DirtiesContext; @@ -12,7 +15,50 @@ import java.lang.annotation.Target; /** - * Starts Spring embedded Kafka and initializes common Spring/Kafka bootstrap properties. + * Аннотация {@code @EmbeddedKafkaTest} подключает и запускает embedded Kafka + * {@link org.springframework.kafka.test.EmbeddedKafkaBroker}, также + * настройки embedded брокера будут проинициализированы в контекст тестового приложения + *

Аннотация требует дополнительной конфигурации {@link EmbeddedKafkaTest#topics()} + *

Примеры

+ *

В примере ниже создается обертка над аннотацией для конкретного приложения с инициализацией + * конкретных топиков приложения. Эту обертку можно позже переиспользовать для любых тестов, + * требующих embedded Kafka без запуска Docker контейнера + *

 {@code
+ * @Target({ElementType.TYPE})
+ * @Retention(RetentionPolicy.RUNTIME)
+ * @EmbeddedKafkaTest(
+ *         properties = {
+ *                 "kafka.topics.invoicing.consume.enabled=true",
+ *                 "kafka.topics.invoice-template.consume.enabled=true",
+ *                 "kafka.state.cache.size=0"},
+ *         topics = {
+ *                 "magista-invoicing-test",
+ *                 "magista-invoice-template-test"})
+ * public @interface CustomEmbeddedKafkaTest {
+ * }}
+ *

В примере ниже {@link EmbeddedKafkaTest} подключается напрямую + * к {@link SpringBootTest} для проведения теста консьюмера, который читает данные из топика + *

 {@code
+ * @EmbeddedKafkaTest(
+ *         properties = {
+ *                 "kafka.topics.invoicing.id=reporter-invoicing-test",
+ *                 "kafka.topics.invoicing.enabled=true"},
+ *         topics = "reporter-invoicing-test")
+ * @SpringBootTest
+ * public class KafkaListenerTest {
+ *
+ *     @Autowired
+ *     private KafkaProducer> testThriftKafkaProducer;
+ *
+ *   ...
+ * }}
+ * + * @see KafkaTestcontainer @KafkaTestcontainer + * @see KafkaTestcontainerSingleton @KafkaTestcontainerSingleton + * @see EmbeddedKafka @EmbeddedKafka + * @see KafkaProducer KafkaProducer + * @see KafkaConsumer KafkaConsumer + * @see KafkaTestConfig KafkaTestConfig */ @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @@ -23,47 +69,32 @@ public @interface EmbeddedKafkaTest { /** - * Alias for {@link EmbeddedKafka#topics()}. + * Аналогичный параметр как у аннотации {@link SpringBootTest#properties()} + *

+ * пример — properties = {"kafka.topics.invoicing.consume.enabled=true",...} */ - @AliasFor(annotation = EmbeddedKafka.class, attribute = "topics") - String[] topics() default {}; - - /** - * Alias for {@link EmbeddedKafka#partitions()}. - */ - @AliasFor(annotation = EmbeddedKafka.class, attribute = "partitions") - int partitions() default 1; - - /** - * Alias for {@link EmbeddedKafka#count()}. - */ - @AliasFor(annotation = EmbeddedKafka.class, attribute = "count") - int count() default 1; - - /** - * Alias for {@link EmbeddedKafka#brokerProperties()}. - */ - @AliasFor(annotation = EmbeddedKafka.class, attribute = "brokerProperties") - String[] brokerProperties() default {}; - - /** - * Alias for {@link EmbeddedKafka#kraft()}. - */ - @AliasFor(annotation = EmbeddedKafka.class, attribute = "kraft") - boolean kraft() default false; + String[] properties() default {}; /** - * Аналогичный параметр как у аннотации {@code SpringBootTest#properties()}. + * Обязательный параметр — здесь перечисляются имена топиков, которые требуется создать при старте embedded Kafka + *

+ * пример — topics = {"magista-invoicing-test",...} */ - String[] properties() default {}; + @AliasFor(annotation = EmbeddedKafka.class, attribute = "topics") + String[] topics() default {}; /** - * Удалять и пересоздавать объявленные топики перед каждым тестом. + * Очищать топики между тестами + * + * @return true - данные между тестами удаляются из embedded Kafka */ boolean cleanupTopics() default true; /** * Топики, которые не нужно очищать между тестами. + * Используется только если {@link #cleanupTopics()} = true + *

+ * пример — excludeCleanupTopics = {"magista-invoicing-test"} */ String[] excludeCleanupTopics() default {}; } diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java index d7270582..aba150ad 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java +++ b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java @@ -47,7 +47,7 @@ private void cleanupTopics(ExtensionContext context, EmbeddedKafkaTest annotatio if (!missingTopics.isEmpty()) { embeddedKafkaBroker.addTopics(missingTopics.toArray(String[]::new)); } - var recordsToDelete = recordsToDelete(embeddedKafkaBroker, topics, annotation.partitions()); + var recordsToDelete = recordsToDelete(embeddedKafkaBroker, topics); if (!recordsToDelete.isEmpty()) { adminClient.deleteRecords(recordsToDelete).all().get(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); } @@ -56,11 +56,10 @@ private void cleanupTopics(ExtensionContext context, EmbeddedKafkaTest annotatio private Map recordsToDelete( EmbeddedKafkaBroker embeddedKafkaBroker, - List topics, - int partitions) { + List topics) { try (var consumer = new KafkaConsumer(consumerProperties(embeddedKafkaBroker))) { var topicPartitions = topics.stream() - .flatMap(topic -> topicPartitions(topic, partitions).stream()) + .flatMap(topic -> topicPartitions(topic, embeddedKafkaBroker.getPartitionsPerTopic()).stream()) .toList(); consumer.assign(topicPartitions); var endOffsets = consumer.endOffsets(topicPartitions); diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/demo/DemoKafkaTestcontainer.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/demo/DemoKafkaTestcontainer.java deleted file mode 100644 index 2a94c094..00000000 --- a/src/main/java/dev/vality/testcontainers/annotations/kafka/demo/DemoKafkaTestcontainer.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.vality.testcontainers.annotations.kafka.demo; - -import dev.vality.testcontainers.annotations.kafka.KafkaTestcontainer; - -/** - * this is a demo example of filling in annotation, do not use - */ -@KafkaTestcontainer( - properties = "kafka.topics.invoicing.consume.enabled=true", - topicsKeys = "kafka.topics.invoicing.id") -public @interface DemoKafkaTestcontainer { -} From cd91c88b8180c117f5b72e89bd6480e9e43be811 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 15 May 2026 12:42:22 +0700 Subject: [PATCH 6/9] suitable default annotations --- .../ClickhouseTestcontainerFactory.java | 4 +- .../postgresql/EmbeddedPostgresqlTest.java | 59 +++++++++++++++++-- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java b/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java index ae4da713..4a30dea4 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java +++ b/src/main/java/dev/vality/testcontainers/annotations/clickhouse/ClickhouseTestcontainerFactory.java @@ -40,9 +40,7 @@ private ClickhouseContainerExtension getOrCreateSingletonContainer(String databa } private ClickhouseContainerExtension create(String databaseName, String[] migrations) { - try (var container = new ClickhouseContainerExtension(databaseName, migrations)) { - return container; - } + return new ClickhouseContainerExtension(databaseName, migrations); } private static class SingletonHolder { diff --git a/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTest.java b/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTest.java index d8b888e8..61e3c77a 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTest.java +++ b/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTest.java @@ -10,7 +10,40 @@ import java.lang.annotation.Target; /** - * Starts an embedded PostgreSQL process and initializes Spring datasource properties. + * Аннотация {@code @EmbeddedPostgresqlTest} подключает и запускает embedded PostgreSQL + * {@link io.zonky.test.db.postgres.embedded.EmbeddedPostgres}, также + * настройки embedded базы данных будут проинициализированы в контекст тестового приложения + *

Аннотация не требует дополнительной конфигурации + *

Примеры

+ *

В примере ниже создается обертка над аннотацией для конкретного приложения с инициализацией + * дополнительных Spring properties. Эту обертку можно позже переиспользовать для любых тестов, + * требующих embedded PostgreSQL без запуска Docker контейнера + *

 {@code
+ * @Target({ElementType.TYPE})
+ * @Retention(RetentionPolicy.RUNTIME)
+ * @EmbeddedPostgresqlTest(
+ *         properties = {
+ *                 "spring.flyway.schemas=public",
+ *                 "spring.jpa.hibernate.ddl-auto=none"})
+ * public @interface CustomEmbeddedPostgresqlTest {
+ * }}
+ *

В примере ниже {@link EmbeddedPostgresqlTest} подключается напрямую + * к {@link SpringBootTest} для проведения теста DAO слоя, при котором идет запись и чтение данных из базы данных + *

 {@code
+ * @EmbeddedPostgresqlTest
+ * @SpringBootTest
+ * public class AdjustmentDaoTest {
+ *
+ *     @Autowired
+ *     private AdjustmentDao adjustmentDao;
+ *
+ *   ...
+ * }}
+ * + * @see PostgresqlTestcontainer @PostgresqlTestcontainer + * @see PostgresqlTestcontainerSingleton @PostgresqlTestcontainerSingleton + * @see EmbeddedPostgresqlTestExtension EmbeddedPostgresqlTestExtension + * @see io.zonky.test.db.postgres.embedded.EmbeddedPostgres EmbeddedPostgres */ @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @@ -19,32 +52,46 @@ public @interface EmbeddedPostgresqlTest { /** - * Аналогичный параметр как у аннотации {@link SpringBootTest#properties()}. + * Аналогичный параметр как у аннотации {@link SpringBootTest#properties()} + *

+ * пример — properties = {"spring.flyway.schemas=public",...} */ String[] properties() default {}; /** - * Embedded PostgreSQL database name. + * Имя embedded PostgreSQL базы данных + *

+ * пример — database = "postgres" */ String database() default "postgres"; /** - * Embedded PostgreSQL user. + * Имя embedded PostgreSQL пользователя + *

+ * пример — username = "postgres" */ String username() default "postgres"; /** - * Embedded PostgreSQL password. Zonky embedded PostgreSQL uses an empty password by default. + * Пароль embedded PostgreSQL пользователя. + * Zonky embedded PostgreSQL по умолчанию использует пустой пароль + *

+ * пример — password = "" */ String password() default ""; /** - * Очищать таблицы между тестами. + * Очищать таблицы между тестами + * + * @return true - данные между тестами удаляются из embedded PostgreSQL */ boolean truncateTables() default true; /** * Таблицы, которые не нужно очищать между тестами. + * Используется только если {@link #truncateTables()} = true + *

+ * пример — excludeTruncateTables = {"schema_history", "reference_data"} */ String[] excludeTruncateTables() default {}; } From 3af1e66c89afea1d0dd3b57155a740b30d848da9 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 15 May 2026 12:45:11 +0700 Subject: [PATCH 7/9] codestyle --- README.md | 7 +++++-- .../annotations/TestcontainerAnnotationsTest.java | 11 ----------- 2 files changed, 5 insertions(+), 13 deletions(-) delete mode 100644 src/test/java/dev/vality/testcontainers/annotations/TestcontainerAnnotationsTest.java diff --git a/README.md b/README.md index 5ab248a2..b3888c5b 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,11 @@ opensearchproject/opensearch ## Embedded режим без Docker -Для легковесных интеграционных тестов можно использовать embedded-аннотации. Библиотека сама запускает локальный -backend, инициализирует runtime-настройки в Spring context и очищает данные между test methods. +Для легковесных интеграционных тестов можно использовать +embedded-аннотации. +Библиотека сама запускает локальный backend. +Она инициализирует runtime-настройки в Spring context +и очищает данные между test methods. | embedded | backend | |-------------------------|---------------------------------| diff --git a/src/test/java/dev/vality/testcontainers/annotations/TestcontainerAnnotationsTest.java b/src/test/java/dev/vality/testcontainers/annotations/TestcontainerAnnotationsTest.java deleted file mode 100644 index 442ee411..00000000 --- a/src/test/java/dev/vality/testcontainers/annotations/TestcontainerAnnotationsTest.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.vality.testcontainers.annotations; - -import org.junit.jupiter.api.Test; - -public class TestcontainerAnnotationsTest { - - @Test - public void setUp() { - - } -} From 83d23014908a91b426bb05700b1d76e4ed8af5a5 Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 15 May 2026 15:12:34 +0700 Subject: [PATCH 8/9] codestyle --- .../kafka/EmbeddedKafkaTestExtension.java | 16 +++---- ...ostgresqlTestContextCustomizerFactory.java | 45 ++++++++++++++++++ .../EmbeddedPostgresqlTestExtension.java | 47 ++----------------- src/main/resources/META-INF/spring.factories | 2 +- 4 files changed, 57 insertions(+), 53 deletions(-) create mode 100644 src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestContextCustomizerFactory.java diff --git a/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java index aba150ad..1b4fa670 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java +++ b/src/main/java/dev/vality/testcontainers/annotations/kafka/EmbeddedKafkaTestExtension.java @@ -39,7 +39,7 @@ private void cleanupTopics(ExtensionContext context, EmbeddedKafkaTest annotatio var applicationContext = SpringExtension.getApplicationContext(context); var embeddedKafkaBroker = applicationContext.getBean(EmbeddedKafkaBroker.class); - try (var adminClient = AdminClient.create(adminProperties(embeddedKafkaBroker))) { + try (var adminClient = AdminClient.create(buildAdminProperties(embeddedKafkaBroker))) { var existingTopics = adminClient.listTopics().names().get(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); var missingTopics = topics.stream() .filter(topic -> !existingTopics.contains(topic)) @@ -47,19 +47,19 @@ private void cleanupTopics(ExtensionContext context, EmbeddedKafkaTest annotatio if (!missingTopics.isEmpty()) { embeddedKafkaBroker.addTopics(missingTopics.toArray(String[]::new)); } - var recordsToDelete = recordsToDelete(embeddedKafkaBroker, topics); + var recordsToDelete = buildRecordsToDelete(embeddedKafkaBroker, topics); if (!recordsToDelete.isEmpty()) { adminClient.deleteRecords(recordsToDelete).all().get(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); } } } - private Map recordsToDelete( + private Map buildRecordsToDelete( EmbeddedKafkaBroker embeddedKafkaBroker, List topics) { - try (var consumer = new KafkaConsumer(consumerProperties(embeddedKafkaBroker))) { + try (var consumer = new KafkaConsumer(buildConsumerProperties(embeddedKafkaBroker))) { var topicPartitions = topics.stream() - .flatMap(topic -> topicPartitions(topic, embeddedKafkaBroker.getPartitionsPerTopic()).stream()) + .flatMap(topic -> buildTopicPartitions(topic, embeddedKafkaBroker.getPartitionsPerTopic()).stream()) .toList(); consumer.assign(topicPartitions); var endOffsets = consumer.endOffsets(topicPartitions); @@ -73,19 +73,19 @@ private Map recordsToDelete( } } - private List topicPartitions(String topic, int partitions) { + private List buildTopicPartitions(String topic, int partitions) { return java.util.stream.IntStream.range(0, partitions) .mapToObj(partition -> new TopicPartition(topic, partition)) .toList(); } - private Properties adminProperties(EmbeddedKafkaBroker embeddedKafkaBroker) { + private Properties buildAdminProperties(EmbeddedKafkaBroker embeddedKafkaBroker) { var properties = new Properties(); properties.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, embeddedKafkaBroker.getBrokersAsString()); return properties; } - private Properties consumerProperties(EmbeddedKafkaBroker embeddedKafkaBroker) { + private Properties buildConsumerProperties(EmbeddedKafkaBroker embeddedKafkaBroker) { var properties = new Properties(); properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, embeddedKafkaBroker.getBrokersAsString()); properties.put(ConsumerConfig.GROUP_ID_CONFIG, "embedded-kafka-cleanup-" + UUID.randomUUID()); diff --git a/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestContextCustomizerFactory.java b/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestContextCustomizerFactory.java new file mode 100644 index 00000000..1b766aff --- /dev/null +++ b/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestContextCustomizerFactory.java @@ -0,0 +1,45 @@ +package dev.vality.testcontainers.annotations.postgresql; + +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfigurationAttributes; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.ContextCustomizerFactory; + +import java.util.List; + +public class EmbeddedPostgresqlTestContextCustomizerFactory implements ContextCustomizerFactory { + + @Override + public ContextCustomizer createContextCustomizer( + Class testClass, + List configAttributes) { + return (context, mergedConfig) -> + EmbeddedPostgresqlTestExtension.findAnnotation(testClass) + .ifPresent(annotation -> init(context, annotation)); + } + + private void init(ConfigurableApplicationContext context, EmbeddedPostgresqlTest annotation) { + var postgresql = EmbeddedPostgresqlTestExtension.getOrStart(annotation); + var jdbcUrl = postgresql.jdbcUrl(); + var username = annotation.username(); + var password = annotation.password(); + TestPropertyValues.of( + "spring.datasource.url=" + jdbcUrl, + "spring.datasource.username=" + username, + "spring.datasource.password=" + password, + "spring.flyway.url=" + jdbcUrl, + "spring.flyway.user=" + username, + "spring.flyway.password=" + password, + "postgres.db.url=" + jdbcUrl, + "postgres.db.user=" + username, + "postgres.db.username=" + username, + "postgres.db.password=" + password, + "flyway.url=" + jdbcUrl, + "flyway.user=" + username, + "flyway.password=" + password, + "flyway.postgresql.transactional.lock=false") + .and(annotation.properties()) + .applyTo(context); + } +} diff --git a/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestExtension.java b/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestExtension.java index bc9b1483..d3fe7392 100644 --- a/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestExtension.java +++ b/src/main/java/dev/vality/testcontainers/annotations/postgresql/EmbeddedPostgresqlTestExtension.java @@ -7,11 +7,6 @@ import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.platform.commons.support.AnnotationSupport; -import org.springframework.boot.test.util.TestPropertyValues; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.test.context.ContextConfigurationAttributes; -import org.springframework.test.context.ContextCustomizer; -import org.springframework.test.context.ContextCustomizerFactory; import java.util.List; import java.util.Optional; @@ -54,11 +49,11 @@ private static Optional findAnnotation(ExtensionContext return AnnotationSupport.findAnnotation(context.getTestClass(), EmbeddedPostgresqlTest.class); } - private static Optional findAnnotation(Class testClass) { + static Optional findAnnotation(Class testClass) { return AnnotationSupport.findAnnotation(testClass, EmbeddedPostgresqlTest.class); } - private static EmbeddedPostgresql getOrStart(EmbeddedPostgresqlTest annotation) { + static EmbeddedPostgresql getOrStart(EmbeddedPostgresqlTest annotation) { var postgresql = THREAD_POSTGRESQL.get(); if (postgresql == null) { postgresql = EmbeddedPostgresql.start(annotation); @@ -67,43 +62,7 @@ private static EmbeddedPostgresql getOrStart(EmbeddedPostgresqlTest annotation) return postgresql; } - public static class EmbeddedPostgresqlTestContextCustomizerFactory implements ContextCustomizerFactory { - - @Override - public ContextCustomizer createContextCustomizer( - Class testClass, - List configAttributes) { - return (context, mergedConfig) -> - findAnnotation(testClass).ifPresent(annotation -> init(context, annotation)); - } - - private void init(ConfigurableApplicationContext context, EmbeddedPostgresqlTest annotation) { - var postgresql = getOrStart(annotation); - THREAD_POSTGRESQL.set(postgresql); - var jdbcUrl = postgresql.jdbcUrl(); - var username = annotation.username(); - var password = annotation.password(); - TestPropertyValues.of( - "spring.datasource.url=" + jdbcUrl, - "spring.datasource.username=" + username, - "spring.datasource.password=" + password, - "spring.flyway.url=" + jdbcUrl, - "spring.flyway.user=" + username, - "spring.flyway.password=" + password, - "postgres.db.url=" + jdbcUrl, - "postgres.db.user=" + username, - "postgres.db.username=" + username, - "postgres.db.password=" + password, - "flyway.url=" + jdbcUrl, - "flyway.user=" + username, - "flyway.password=" + password, - "flyway.postgresql.transactional.lock=false") - .and(annotation.properties()) - .applyTo(context); - } - } - - private record EmbeddedPostgresql(EmbeddedPostgres delegate, String jdbcUrl) { + record EmbeddedPostgresql(EmbeddedPostgres delegate, String jdbcUrl) { @SneakyThrows private static EmbeddedPostgresql start(EmbeddedPostgresqlTest annotation) { diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index 67b1c455..269b58e6 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -6,4 +6,4 @@ dev.vality.testcontainers.annotations.kafka.EmbeddedKafkaTestContextCustomizerFa dev.vality.testcontainers.annotations.clickhouse.ClickhouseTestcontainerExtension.ClickhouseTestcontainerContextCustomizerFactory,\ dev.vality.testcontainers.annotations.minio.MinioTestcontainerExtension.MinioTestcontainerContextCustomizerFactory,\ dev.vality.testcontainers.annotations.opensearch.OpensearchTestcontainerExtension.OpensearchTestcontainerContextCustomizerFactory,\ -dev.vality.testcontainers.annotations.postgresql.EmbeddedPostgresqlTestExtension.EmbeddedPostgresqlTestContextCustomizerFactory +dev.vality.testcontainers.annotations.postgresql.EmbeddedPostgresqlTestContextCustomizerFactory From 94408cc21e5414c76be135d86ac523dff8296f7b Mon Sep 17 00:00:00 2001 From: Anatoly Karlov Date: Fri, 15 May 2026 15:14:44 +0700 Subject: [PATCH 9/9] codestyle --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 829f5ec0..743ce461 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ jar testcontainers-annotations - 4.1.3 + 4.2.0 testcontainers-annotations testcontainers-annotations