From 6aad7e3d633d5ad79872101da56f162fbe73c436 Mon Sep 17 00:00:00 2001 From: Swordsteel Date: Mon, 10 Mar 2025 17:41:18 +0100 Subject: [PATCH] update postgres test container - update README.md - update PostgresContainer - add TestExecutionListeners - remove ExtendWith - update PostgresInitializer - cleanup - use properties for script and container - add afterTestClass - add beforeTestClass - extend TestExecutionListener - remove PostgresExtension - add debug logging to PostgresExecutor - add ContainerUtils - add dependencies - extract function from PostgresExtension to PostgresExecutor --- README.md | 16 ++-- build.gradle.kts | 1 + .../test/container/PostgresContainer.kt | 5 +- .../hlaeja/test/container/PostgresExecutor.kt | 55 ++++++++++++++ .../test/container/PostgresExtension.kt | 74 ------------------- .../test/container/PostgresInitializer.kt | 67 +++++++++++++---- .../ltd/hlaeja/test/util/ContainerUtil.kt | 26 +++++++ 7 files changed, 146 insertions(+), 98 deletions(-) create mode 100644 src/main/kotlin/ltd/hlaeja/test/container/PostgresExecutor.kt delete mode 100644 src/main/kotlin/ltd/hlaeja/test/container/PostgresExtension.kt create mode 100644 src/main/kotlin/ltd/hlaeja/test/util/ContainerUtil.kt diff --git a/README.md b/README.md index a47dbc4..ec0d441 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,22 @@ In the forge of software development, where annotations ignite, A crucible of testing, common classes to excite. Each annotation examined, with attention to detail and might, Their effects on code behavior, tested through day and night. From mockk objects to test doubles, a toolkit to refine, Developers and testers, their skills to redefine. The Annotation Validator, a sentinel of code integrity true, A library of verification, where testing wisdom shines anew. + ## Postgres Test Container `@PostgresContainer` Annotation for integration tests. -Initialize Postgres test container using spring properties for R2DBC, -script located in `src//resources/postgres` folder. +Initialize Postgres test container using spring properties for R2DBC, +script located in `src//resources` folder. And specify properties in `src//resources/application.properties`. -* `schema.sql` file containing all structure and functions when star. -* `data.sql` file containing all data added before all test. -* `reset.sql` file containing all to reset database after all test. +### Properties For Test Container -if file exist it will be loaded... +| name | required | example | info | +|----------------------------|:--------:|-------------|----------------------------------------------------------------| +| container.postgres.version | | postgres:17 | postgres container default postgres:latest | +| container.postgres.init | ✓ | schema.sql | Postgres init script containing all structure and functions | +| container.postgres.before | | data.sql | Postgres data script containing all data to populate database | +| container.postgres.after | | reset.sql | Postgres reset script containing all command to reset database | ## Releasing library diff --git a/build.gradle.kts b/build.gradle.kts index 9ec1a5a..107a5d7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { } dependencies { + implementation(hlaeja.kotlin.logging) implementation(hlaeja.kotlinx.coroutines) implementation(hlaeja.springboot.starter.r2dbc) implementation(hlaeja.springboot.starter.test) diff --git a/src/main/kotlin/ltd/hlaeja/test/container/PostgresContainer.kt b/src/main/kotlin/ltd/hlaeja/test/container/PostgresContainer.kt index 251896b..eb072dc 100644 --- a/src/main/kotlin/ltd/hlaeja/test/container/PostgresContainer.kt +++ b/src/main/kotlin/ltd/hlaeja/test/container/PostgresContainer.kt @@ -1,11 +1,12 @@ package ltd.hlaeja.test.container -import org.junit.jupiter.api.extension.ExtendWith import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestExecutionListeners +import org.springframework.test.context.TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS @Suppress("unused") @Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.CLASS) -@ExtendWith(PostgresExtension::class) @ContextConfiguration(initializers = [PostgresInitializer::class]) +@TestExecutionListeners(listeners = [PostgresInitializer::class], mergeMode = MERGE_WITH_DEFAULTS) annotation class PostgresContainer diff --git a/src/main/kotlin/ltd/hlaeja/test/container/PostgresExecutor.kt b/src/main/kotlin/ltd/hlaeja/test/container/PostgresExecutor.kt new file mode 100644 index 0000000..0d0142a --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/test/container/PostgresExecutor.kt @@ -0,0 +1,55 @@ +package ltd.hlaeja.test.container + +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import kotlinx.coroutines.runBlocking +import ltd.hlaeja.test.util.isResourceFile +import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.r2dbc.core.await +import org.springframework.stereotype.Component + +private val log = KotlinLogging.logger {} + +@Component +class PostgresExecutor( + private val databaseClient: DatabaseClient, +) { + + fun executeSqlFile( + sqlFile: String, + ) = runBlocking { + sqlFile.isResourceFile() + ?.inputStream + ?.use { + log.debug { "Executing SQL file: $sqlFile" } + executeSqlStatements(makeSqlStatements(it)) + } + ?: log.debug { "SQL file not found or not readable: $sqlFile" } + } + + @Suppress("TooGenericExceptionThrown") + private suspend fun executeSqlStatements( + statements: List, + ) = try { + statements.forEach { statement -> + log.debug { "Running statement: $statement" } + databaseClient.sql(statement).await() + } + } catch (e: Exception) { + throw RuntimeException("Failed to execute SQL statements", e) + } + + private suspend fun makeSqlStatements( + inputStream: InputStream, + ): List = BufferedReader(InputStreamReader(inputStream)) + .lines() + .filter { it.isNotEmpty() && !it.startsWith("--") } + .map { it.trim() } + .toList() + .joinToString(" ") + .split(';') + .filter { it.isNotBlank() } + .map { "${it.trim()};" } +} diff --git a/src/main/kotlin/ltd/hlaeja/test/container/PostgresExtension.kt b/src/main/kotlin/ltd/hlaeja/test/container/PostgresExtension.kt deleted file mode 100644 index d01268b..0000000 --- a/src/main/kotlin/ltd/hlaeja/test/container/PostgresExtension.kt +++ /dev/null @@ -1,74 +0,0 @@ -package ltd.hlaeja.test.container - -import java.io.BufferedReader -import java.io.InputStreamReader -import org.junit.jupiter.api.extension.AfterAllCallback -import org.junit.jupiter.api.extension.BeforeAllCallback -import org.junit.jupiter.api.extension.ExtensionContext -import org.springframework.core.io.ClassPathResource -import org.springframework.test.context.junit.jupiter.SpringExtension -import kotlinx.coroutines.runBlocking -import io.r2dbc.spi.Connection -import io.r2dbc.spi.ConnectionFactory -import kotlinx.coroutines.reactive.awaitFirstOrElse -import kotlinx.coroutines.reactive.awaitFirstOrNull - -class PostgresExtension : BeforeAllCallback, AfterAllCallback { - - override fun beforeAll(context: ExtensionContext) { - executeSqlFile(context, "postgres/data.sql") - } - - override fun afterAll(context: ExtensionContext) { - executeSqlFile(context, "postgres/reset.sql") - } - - private fun executeSqlFile( - context: ExtensionContext, - resourceSourcePath: String, - ) = runBlocking { - if (ClassPathResource(resourceSourcePath).exists()) { - executeSqlStatements( - postgresConnection(context), - makeSqlStatements(ClassPathResource(resourceSourcePath)), - ) - } - } - - @Suppress("TooGenericExceptionThrown") - private suspend fun postgresConnection( - context: ExtensionContext, - ) = SpringExtension.getApplicationContext(context) - .getBean(ConnectionFactory::class.java) - .create() - .awaitFirstOrElse { throw RuntimeException("Connection factory could not be created") } - - private suspend fun executeSqlStatements( - connection: Connection, - statements: List, - ) { - try { - statements.forEach { statement -> - connection.createStatement(statement) - .execute() - .awaitFirstOrNull() - } - } finally { - connection.close().awaitFirstOrNull() - } - } - - private fun makeSqlStatements( - classPathResource: ClassPathResource, - ): List = classPathResource.inputStream.use { inputStream -> - BufferedReader(InputStreamReader(inputStream)) - .lines() - .filter { it.isNotEmpty() && !it.startsWith("--") } - .map { it.trim() } - .toList() - .joinToString(" ") - .split(';') - .filter { it.isNotBlank() } - .map { "${it.trim()};" } - } -} diff --git a/src/main/kotlin/ltd/hlaeja/test/container/PostgresInitializer.kt b/src/main/kotlin/ltd/hlaeja/test/container/PostgresInitializer.kt index e12923e..8e163b3 100644 --- a/src/main/kotlin/ltd/hlaeja/test/container/PostgresInitializer.kt +++ b/src/main/kotlin/ltd/hlaeja/test/container/PostgresInitializer.kt @@ -1,32 +1,67 @@ package ltd.hlaeja.test.container +import io.github.oshai.kotlinlogging.KotlinLogging +import ltd.hlaeja.test.util.getProperty +import ltd.hlaeja.test.util.isResourceFile import org.springframework.boot.test.util.TestPropertyValues import org.springframework.context.ApplicationContextInitializer import org.springframework.context.ConfigurableApplicationContext -import org.springframework.core.io.ClassPathResource +import org.springframework.test.context.TestContext +import org.springframework.test.context.TestExecutionListener import org.testcontainers.containers.PostgreSQLContainer -@Suppress("unused") -class PostgresInitializer : ApplicationContextInitializer { +private val log = KotlinLogging.logger {} - override fun initialize(applicationContext: ConfigurableApplicationContext) { - postgres().apply { +@Suppress("unused") +class PostgresInitializer : ApplicationContextInitializer, TestExecutionListener { + + companion object { + const val SCRIPT_INIT = "container.postgres.init" + const val SCRIPT_BEFORE = "container.postgres.before" + const val SCRIPT_AFTER = "container.postgres.after" + const val POSTGRES_VERSION = "container.postgres.version" + const val POSTGRES_LATEST = "postgres:latest" + } + + override fun initialize( + context: ConfigurableApplicationContext, + ) { + postgres(context).apply { TestPropertyValues.of( "spring.r2dbc.url=r2dbc:pool:postgresql://$host:$firstMappedPort/$databaseName", "spring.r2dbc.username=$username", "spring.r2dbc.password=$password", - ).applyTo(applicationContext) + ).applyTo(context) } } - private fun postgres(): PostgreSQLContainer<*> = PostgreSQLContainer("postgres:17") - .withReuse(true) - .apply { - "postgres/schema.sql".let { - if (ClassPathResource(it).exists()) { - withInitScript(it) - } - } - start() - } + override fun beforeTestClass( + context: TestContext, + ) { + context.testClass + .also { log.debug { "Starting execution before class: ${it.simpleName}" } } + .getAnnotation(PostgresContainer::class.java) ?: return + context.getProperty(SCRIPT_BEFORE) + ?.let { context.applicationContext.getBean(PostgresExecutor::class.java).executeSqlFile(it) } + } + + override fun afterTestClass( + context: TestContext, + ) { + context.testClass + .also { log.debug { "Starting execution after class: ${it.simpleName}" } } + .getAnnotation(PostgresContainer::class.java) ?: return + context.getProperty(SCRIPT_AFTER) + ?.let { context.applicationContext.getBean(PostgresExecutor::class.java).executeSqlFile(it) } + } + + private fun postgres( + context: ConfigurableApplicationContext, + ): PostgreSQLContainer<*> = PostgreSQLContainer(context.getProperty(POSTGRES_VERSION, POSTGRES_LATEST)).apply { + context.getProperty(SCRIPT_INIT) + ?.isResourceFile() + ?.let { lala -> withInitScript(lala.path) } + ?: log.error { "Postgres init script not found" } + start() + } } diff --git a/src/main/kotlin/ltd/hlaeja/test/util/ContainerUtil.kt b/src/main/kotlin/ltd/hlaeja/test/util/ContainerUtil.kt new file mode 100644 index 0000000..751846f --- /dev/null +++ b/src/main/kotlin/ltd/hlaeja/test/util/ContainerUtil.kt @@ -0,0 +1,26 @@ +package ltd.hlaeja.test.util + +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.core.io.ClassPathResource +import org.springframework.test.context.TestContext + +fun ConfigurableApplicationContext.getProperty( + property: String, +): String? = this.environment.getProperty(property) + +fun ConfigurableApplicationContext.getProperty( + property: String, + default: String, +): String = this.environment.getProperty(property, default) + +fun TestContext.getProperty( + property: String, +): String? = this.applicationContext.environment.getProperty(property) + +fun String.isResourceFile(): ClassPathResource? { + val resource = ClassPathResource(this) + return when { + resource.exists() && resource.isReadable -> resource + else -> null + } +}