저는 현재 다중 DB 환경에서도 서비스를 개발, 운영하고 있습니다.
Test Container와 Flyway를 사용해서 운영 환경과 동일한 일관성 있는 테스트를 작성해보겠습니다.
저는 Kotest와 Postgresql을 사용해서 테스트 환경을 구축할 것이기에 의존성을 추가할 때 참고하고 진행해주세요.
dependencies {
runtimeOnly("org.postgresql:postgresql")
implementation("org.flywaydb:flyway-core:10.15.0")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.flywaydb:flyway-database-postgresql:10.15.0")
testRuntimeOnly("org.postgresql:postgresql")
testImplementation("org.testcontainers:postgresql")
testImplementation("org.testcontainers:jdbc:1.20.2")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("com.ninja-squad:springmockk:4.0.2")
testImplementation("io.kotest:kotest-runner-junit5:5.7.2")
testImplementation("io.kotest:kotest-assertions-core:5.7.2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.3")
testImplementation("io.kotest.extensions:kotest-extensions-testcontainers:2.0.2")
}
저희는 커스텀하게 DataSource를 구성할 것이기에 yml 파일에 noinspection 주석을 달아주겠습니다.
#file: noinspection SpringBootApplicationYaml
spring:
profiles:
active: test
datasource:
master:
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
url: jdbc:tc:postgresql:15.0:///cherhy
username: postgres
password: 1234
slave:
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
url: jdbc:tc:postgresql:15.0:///cherhy
username: postgres
password: 1234
flyway:
enabled: false
DataSourceAutoConfiguration과 FlywayAutoConfiguration을 비활성화 시키고 @ConfigurationPropertiesScan으로 설정 파일에 있는 값을 별도의 data class에 읽어오게 설정하겠습니다.
그리고 저는 추후에 Spring Batch를 테스트 할거라 @EnableBatchProcessing을 붙혀주겠습니다.
@SpringBootApplication(
exclude = [
DataSourceAutoConfiguration::class,
FlywayAutoConfiguration::class,
])
@EnableBatchProcessing
@ConfigurationPropertiesScan
class TestApplication
yml 파일에 data source를 읽어오는 모델도 설정하겠습니다.
ConfigurationPropertiesScan 설정을 해줬기 때문에 data class에 @ConfigurationProperties를 붙혀준다면 masterDB Connection과 SlaveDB Connection을 각각 읽어옵니다.
@ConfigurationProperties(prefix = "spring.datasource")
data class DataSourceProperty(
val master: Connection,
val slave: Connection,
)
data class Connection(
val driverClassName: String,
val url: String,
val username: String,
val password: String,
)
모델을 설정했다면 이제 Configuration 파일을 만들어서 Transaction Manager와 Database Routing, Flyway에 대한 설정을 해보겠습니다.
Transaction이 readOnly일 경우 slaveDB를 바라보고, readOnly false인 경우 masterDB를 바라보게 설정을 하겠습니다.
@Configuration
class DataSourceConfiguration(
private val dataSourceProperty: DataSourceProperty,
) {
@Bean(MASTER_DATA_SOURCE)
fun masterDataSource() =
DataSourceBuilder.create()
.url(dataSourceProperty.master.url)
.username(dataSourceProperty.master.username)
.password(dataSourceProperty.master.password)
.type(HikariDataSource::class.java)
.build()!!
@Bean(SLAVE_DATA_SOURCE)
fun slaveDataSource() =
DataSourceBuilder.create()
.url(dataSourceProperty.slave.url)
.username(dataSourceProperty.slave.username)
.password(dataSourceProperty.slave.password)
.type(HikariDataSource::class.java)
.build()!!
@Bean(ROUTING_DATA_SOURCE)
@DependsOn(MASTER_DATA_SOURCE, SLAVE_DATA_SOURCE)
fun routingDataSource(
@Qualifier(MASTER_DATA_SOURCE) masterDataSource: DataSource,
@Qualifier(SLAVE_DATA_SOURCE) slaveDataSource: DataSource,
): DataSource {
val dataSources = hashMapOf<Any, Any>().apply {
this[MASTER] = masterDataSource
this[SLAVE] = slaveDataSource
}
return object : AbstractRoutingDataSource() {
override fun determineCurrentLookupKey() =
when {
TransactionSynchronizationManager.isCurrentTransactionReadOnly() -> SLAVE
else -> MASTER
}
}.apply {
setTargetDataSources(dataSources)
setDefaultTargetDataSource(masterDataSource)
}
}
@Bean
@Primary
@DependsOn(ROUTING_DATA_SOURCE)
fun dataSource(
routingDataSource: DataSource,
) =
LazyConnectionDataSourceProxy(routingDataSource)
@Bean(TRANSACTION_MANAGER)
fun transactionManager(
dataSource: DataSource,
) =
JdbcTransactionManager(dataSource)
}
Flyway 설정도 이어서 진행하겠습니다.
Flyway는 test profile
일 때 수동으로 실행시켜주기 위해 @Profile을 붙혀주겠습니다.
@Configuration
class FlywayConfig(
private val dataSourceProperty: DataSourceProperty,
) {
@Profile("!test")
@Bean(initMethod = "migrate")
fun flyway() =
Flyway(
Flyway.configure()
.dataSource(
dataSourceProperty.master.url,
dataSourceProperty.master.username,
dataSourceProperty.master.password,
)
.baselineOnMigrate(true)
.locations("classpath:db/migration")
)
}
지금까지 설정을 다 하셨다면 이제 테스트 환경을 구축하겠습니다.
저는 Spring Boot + Kotest를 사용해서 테스트 환경을 구축하려고 합니다.
Kotest를 사용하지 않고 JUnit을 사용하신다면 Kotest 설정을 따라하지 않으셔도 됩니다.
가장 먼저 Kotest와 Spring Boot에 대한 설정을 해주겠습니다.
아래처럼 설정한다면 Spring의 ApplicationContext를 자동으로 로드해서 Integration Test를 진행할 때 ApplicationContext가 공유됩니다.
class KotestSpringExtension : AbstractProjectConfig() {
override fun extensions(): List<SpringTestExtension> = listOf(SpringExtension)
}
먼저 Test Container에서 사용 할 상수들을 정의하겠습니다.
object DataSource {
object Postgres {
object Property {
const val IMAGE = "postgres:15.0"
const val PORT = 5432
}
object Master {
const val NAME = "postgres-master-test-container"
const val BIND_PORT = 15432
const val DATABASE_NAME = "cherhy"
const val USERNAME = "postgres"
const val PASSWORD = "1234"
}
object Slave {
const val NAME = "postgres-slave-test-container"
const val BIND_PORT = 15433
const val DATABASE_NAME = "cherhy"
const val USERNAME = "postgres"
const val PASSWORD = "1234"
}
}
object Command {
const val POSTGRES = "postgres"
private const val ADD_OPTION = "-c"
val WAL_LEVEL = arrayOf(ADD_OPTION, "wal_level=replica")
val MAX_WAL_SENDERS = arrayOf(ADD_OPTION, "max_wal_senders=3")
val MAX_REPLICATION_SLOTS = arrayOf(ADD_OPTION, "max_replication_slots=3")
val HOT_STANDBY = arrayOf(ADD_OPTION, "hot_standby=on")
}
object Key {
const val MASTER_DATABASE_SOURCE_URL = "spring.datasource.master.url"
const val MASTER_DATABASE_SOURCE_USERNAME = "spring.datasource.master.username"
const val MASTER_DATABASE_SOURCE_PASSWORD = "spring.datasource.master.password"
const val SLAVE_DATABASE_SOURCE_URL = "spring.datasource.slave.url"
const val SLAVE_DATABASE_SOURCE_USERNAME = "spring.datasource.slave.username"
const val SLAVE_DATABASE_SOURCE_PASSWORD = "spring.datasource.slave.password"
}
}
바로 Master DB, Slave DB에 대한 Test Container 설정과 Flyway 수동 설정을 진행하겠습니다.
internal interface WithTestContainers {
companion object {
@JvmStatic
@DynamicPropertySource
fun initTestContainers(
registry: DynamicPropertyRegistry,
) {
activeTestContainers.parallelStream().forEach { it.start() }
injectProperties(registry)
migrate()
}
private fun injectProperties(
registry: DynamicPropertyRegistry,
) {
registry.add(MASTER_DATABASE_SOURCE_URL) { masterPostgres.jdbcUrl }
registry.add(MASTER_DATABASE_SOURCE_USERNAME) { masterPostgres.username }
registry.add(MASTER_DATABASE_SOURCE_PASSWORD) { masterPostgres.password }
registry.add(SLAVE_DATABASE_SOURCE_URL) { salvePostgres.jdbcUrl }
registry.add(SLAVE_DATABASE_SOURCE_USERNAME) { salvePostgres.username }
registry.add(SLAVE_DATABASE_SOURCE_PASSWORD) { salvePostgres.password }
}
private val masterPostgres by lazy {
PostgreSQLContainer<Nothing>(Postgres.Property.IMAGE).apply {
withCreateContainerCmdModifier {
it.withName(Postgres.Master.NAME)
.hostConfig
?.portBindings
?.add(
PortBinding(
bindPort(Postgres.Master.BIND_PORT),
ExposedPort(Postgres.Property.PORT),
)
)
}
withExposedPorts(Postgres.Property.PORT)
withDatabaseName(Postgres.Master.DATABASE_NAME)
withUsername(Postgres.Master.USERNAME)
withPassword(Postgres.Master.PASSWORD)
withCommand(
POSTGRES,
*WAL_LEVEL,
*MAX_WAL_SENDERS,
*MAX_REPLICATION_SLOTS,
*HOT_STANDBY,
)
}
}
private val salvePostgres by lazy {
PostgreSQLContainer<Nothing>(Postgres.Property.IMAGE).apply {
withCreateContainerCmdModifier {
it.withName(Postgres.Slave.NAME)
.hostConfig
?.portBindings
?.add(
PortBinding(
bindPort(Postgres.Slave.BIND_PORT),
ExposedPort(Postgres.Property.PORT),
)
)
}
withExposedPorts(Postgres.Property.PORT)
withDatabaseName(Postgres.Slave.DATABASE_NAME)
withUsername(Postgres.Slave.USERNAME)
withPassword(Postgres.Slave.PASSWORD)
}
}
private fun migrate() {
Flyway.configure()
.dataSource(
masterPostgres.jdbcUrl,
masterPostgres.username,
masterPostgres.password,
)
.load()
.migrate()
}
private val activeTestContainers = listOf(masterPostgres, salvePostgres)
}
}
Test Container로 띄운 데이터베이스에 대한 동기화에 대한 설정을 해줬습니다.
그리고 Kotest의 Test Spec들은 Abstract Class라서 Integration Test Class를 만들 시 다중 상속이 불가능해지기 때문에 이 부분을 Interface로 만들었습니다.
이제 WithTestContainers를 상속 받으면 테스트 실행 시 자동으로 테스트 컨테이너가 뜨게 됩니다.
또한 FlyWay도 수동 실행하게 만들어뒀기 때문에 이젠 운영 환경과 완전히 동일한 상태로 테스트가 진행됩니다.
저는 위에서 설정했던걸 기반으로 Spring Batch의 JOB을 Mocking하는 테스트 코드를 작성해보겠습니다.
@SpringBootTest
@SpringBatchTest
internal class JdbcJobTest(
@Autowired private val jobLauncherTestUtils: JobLauncherTestUtils,
@Autowired private val jobRepositoryTestUtils: JobRepositoryTestUtils,
@MockkBean private val exampleJobCompletionNotificationListener: ExampleJobCompletionNotificationListener,
) : WithTestContainers, StringSpec({
afterEach {
jobRepositoryTestUtils.removeJobExecutions()
clearAllMocks()
}
"Job을 실행하고 listener가 실행되는지 확인한다" {
val exampleJob = JobParameterFactory.create(EXAMPLE_JOB)
every { exampleJobCompletionNotificationListener.beforeJob() } just Runs
every { exampleJobCompletionNotificationListener.afterJob() } just Runs
jobLauncherTestUtils.launchJob(exampleJob)
verify(exactly = 1) { exampleJobCompletionNotificationListener.beforeJob() }
verify(exactly = 1) { exampleJobCompletionNotificationListener.afterJob() }
}
})
이제 위 테스트 코드를 실행한다면 아래처럼 실제로 Postgres Container가 뜨는 걸 볼 수 있습니다.
참고로 Testcontainers 라이브러리를 사용할 때 기본적으로 Ryuk 컨테이너가 별도로 뜨고 컨테이너들의 생명 주기를 관리해줍니다.