지난 글에서 이어지는 내용입니다.
Spring에서 DataJpaTest와 TestContainer를 같이 써보자
회사에서 많은 분들이 테스트를 @SpringBootTest로 사용하시고 H2를 안 쓰고 local 또는 dev 환경 데이터베이스에 붙어서 테스트를 진행하고 있습니다.더 좋은 테스트 환경(빠른 테스트, 외부 환경에 의
jiwonchoi-dev.tistory.com
지난 글에서 Spring 환경에서 DataJpaTest와 TestContainer를 같이 쓰는 법에 대해 포스팅했습니다.
이번에는 한 발 더 나아가 Kotest 환경에서 DataJpaTest와 TestContainer를 같이 쓰는 방법에 대해서 포스팅하겠습니다.
주의 1) Kotest는 레퍼런스가 많이 없다 보니 제 나름대로 방법을 찾아서 적은 글입니다. 올바른 방법이 아닐 수 있으니 주의해 주세요~ 더 좋은 방법이 생각나셨거나 알고 계신다면 알려주세요~
주의 2) 이 글은 삽질을 한 기록을 모두 담기 때문에 최종적으로 완성된 코드만 보고 싶으시다면 마지막 최종 결과물 부분만 읽는 걸 권장합니다~
목표
지난 글과 같은 목표(사용하기 편함, 빠름)를 가지면서 Kotest를 사용할 수 있게 세팅하는 게 목표입니다.
Kotest는 기본적으로 Spec 추상 클래스를 상속받아서 테스트를 진행하는 형식이다 보니 지난 글의 추상 클래스 세팅을 통해서는 테스트 컨테이너를 사용하기 어렵습니다. 그러면 다른 방법을 찾아봐야겠죠??
처음 생각한 방법
처음 생각한 방법은 Kotest 공식 문서에 나와있는 TestContainer 사용 문서를 기반으로 구현하려고 했습니다.
Testcontainers | Kotest
Testcontainers
kotest.io
해당 방식으로 구현하게 되면 발생하는 문제는 다음과 같아서 사용하지 않았습니다.
1. 모든 테스트 클래스에 TestContainer 관련 코드가 중복으로 들어감 (세팅 빡셈)
2. 컨테이너 LifeCycle이 루트 테스트, 리프 테스트 단위로만 가능해서 전체 테스트에 대해서 단 한 번 테스트 컨테이너를 실행시킬 수 없음
쉽고 빠른 테스트를 위해 다른 방법을 찾아봤습니다.
Kotest Extension
Kotest는 Extension을 지원하며 이를 활용하여 테스트 전, 후 과정에서 특정 행위를 실행시킬 수 있습니다.
Introduction to Extensions | Kotest
Extensions are reusable lifecycle hooks. In fact, lifecycle hooks are themselves represented internally as instances
kotest.io
Extension 중 BeforeProjectListener, AfterProjectListener라는 Extension이 있는데 설명을 보면 다음과 같습니다.

GPT 선생님 번역
BeforeProjectListener: 스펙이 생성되기 전에 호출됩니다.
AfterProjectListener: 모든 테스트와 스펙이 완료된 후에 호출됩니다.
해당 Extension을 활용하면 테스트가 시작할 때 컨테이너를 띄우고 모든 테스트가 종료되면 컨테이너를 끄게 만들 수 있을 거 같아 바로 작업에 들어갔습니다.
Extension을 활용한 TestContainerListener
Extension을 바탕으로 탄생한 코드는 다음과 같습니다.
@AutoScan
object TestContainerListener : BeforeProjectListener, AfterProjectListener {
private val mySQLContainer = MySQLContainer(DockerImageName.parse("mysql:8.0.39"))
override suspend fun beforeProject() {
mySQLContainer.start()
// datasource properties 설정
System.setProperty("spring.datasource.url", mySQLContainer.jdbcUrl)
System.setProperty("spring.datasource.driver-class-name", mySQLContainer.driverClassName)
System.setProperty("spring.datasource.username", mySQLContainer.username)
System.setProperty("spring.datasource.password", mySQLContainer.password)
// Test Container ddl-auto 설정
System.setProperty("spring.jpa.hibernate.ddl-auto", "update")
}
override suspend fun afterProject() {
mySQLContainer.stop()
}
}
참고로 공식 문서에 있는 내용인데 프로젝트 레벨의 리스너는 extension()으로 등록하는 것이 아닌 AutoScan으로 등록하라고 되어있습니다. 제가 이걸 안 보고 만들었다가 고생 좀 했습니다.

이렇게 세팅을 하고 이제 이를 활용하기 위해 커스텀 Spec을 하나 만들었습니다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(QuerydslConfig::class, JpaConfig::class, DataSourceConfig::class)
abstract class BaseDataJpaTestFeatureSpec(body: FeatureSpec.() -> Unit = {}) : FeatureSpec(body) {
override fun extensions() = listOf(SpringExtension)
}
저는 FeatureSpec을 애용하는 편이라 FeatureSpec을 사용했습니다. (Kotest에서 트랜잭션 관리가 너무 어지러워서 FeatureSpec을 씁니다.)
이렇게 세팅하고 해당 커스텀 Spec을 사용하는 테스트를 작성하면 테스트 컨테이너가 띄워지는 것을 알 수 있습니다.
class SampleTest: BaseDataJpaTestFeatureSpec() {
init {
// 테스트 작성
}
}
문제점
처음에는 이렇게 개발을 하고 되게 만족하고 있었는데 한 가지 치명적인 문제를 발견했는데 문제점은 다음과 같습니다.
- 테스트 컨테이너를 사용하지 않는 단위 테스트에 테스트 컨테이너가 띄워진다.
DataBase 접속을 요구하지 않는 테스트에 대해서 테스트 컨테이너가 띄워지는 건 상당히 큰 문제라 다른 방법을 생각해야 됐습니다.
해결책
제가 생각한 해결 방법은 다음과 같았습니다.
- DataJpaTest 또는 Database 계층에 접근하는 SpringBootTest를 시작할 때 테스트 컨테이너를 띄우자
특정 조건에만 테스트 컨테이너를 띄우기 위해 이번에는 SpecExtension을 활용했습니다.
class BeforeDbTestListener: SpecExtension {
override suspend fun intercept(spec: Spec, execute: suspend (Spec) -> Unit) {
if (!TestContainerListener.mySQLContainer.isRunning) {
TestContainerListener.mySQLContainer.start()
// Data JPA Test datasource properties 설정
System.setProperty("spring.datasource.url", TestContainerListener.mySQLContainer.jdbcUrl)
System.setProperty("spring.datasource.driver-class-name", TestContainerListener.mySQLContainer.driverClassName)
System.setProperty("spring.datasource.username", TestContainerListener.mySQLContainer.username)
System.setProperty("spring.datasource.password", TestContainerListener.mySQLContainer.password)
// Test Container ddl-auto 설정
System.setProperty("spring.jpa.hibernate.ddl-auto", "update")
}
execute(spec)
}
}
그다음 이를 커스텀 Spec의 Extension으로 등록해 둡니다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(QuerydslConfig::class, JpaConfig::class, DataSourceConfig::class)
abstract class BaseDataJpaTestFeatureSpec(body: FeatureSpec.() -> Unit = {}) : FeatureSpec(body) {
override fun extensions() = listOf(BeforeDbTestListener(), SpringExtension)
}
이렇게 세팅을 하면 테스트 시작 전 컨테이너가 켜진지 꺼진 지 확인하고 꺼진 상태라면 테스트 컨테이너를 실행시켜 줍니다.
또 다른 문제점
나름 최선의 방법을 찾았다고 생각했는데 이 해결책에도 문제점이 있었습니다. (위의 BeforeDbTestListener 코드는 문제점 해결해 둔 코드라 문제없습니다. 기존 코드가 사라져서 그냥 해결된 코드로 올려뒀어요~)
테스트를 하면 Spring Application이 먼저 실행되고 그 뒤에 테스트 컨테이너가 띄워져 결국엔 H2로 테스트를 진행하는 문제가 있었습니다.
SpringExtension의 LifeCycleHook은 어떻게 되더라?
계속 Spring이 먼저 구동되길래 SpringExtension의 LifeCycleHook이 어떻게 되는지 찾으러 갔습니다.
kotest-extensions-spring/src/main/kotlin/io/kotest/extensions/spring/SpringTestExtension.kt at master · kotest/kotest-extension
Kotest extension for Spring framework. Contribute to kotest/kotest-extensions-spring development by creating an account on GitHub.
github.com

이를 확인하고 BeforeDbTestListener 클래스를 SpecExtension으로 변경했습니다. 그다음 구현은 SpringTestExtension 코드를 레퍼런스 삼아서 구현했습니다.
궁금증) 같은 LifeCycleHook을 가진 Extension들을 동시에 사용하면 누가 먼저 실행될까?
이렇게 구현하고 문득 누가 먼저 실행되지?라는 의문이 들어 이번에는 Kotest 깃허브를 탐방했습니다.
사실 서로 순서를 바꿔서 실행해 보니 먼저 등록한 Extension이 먼저 실행되는 것을 확인했지만 그래도 눈으로 보고 싶어서 찾아봤습니다.
먼저 Extension이 어디서 등록되는지 확인하기 위해 디버깅을 찍고 Extension을 등록하는 부분을 찾아봤습니다.

디버깅으로 확인 후 이 수상한 친구를 찾으러 Kotest 깃허브로 들어갔습니다.
kotest/kotest-framework/kotest-framework-engine/src/commonMain/kotlin/io/kotest/engine/spec/SpecExtensions.kt at 9a1e24bad9a5ead
Powerful, elegant and flexible test framework for Kotlin with assertions, property testing and data driven tests. - kotest/kotest
github.com
들어가 보니 아래의 코드가 있는 걸 확인하고 extensions 리스트에 등록한 순서대로 beforeSpec()을 실행시킴을 알았습니다.

두 눈으로 확인했으니 이제 편하게 Extension을 등록할 수 있게 됐습니다~~
이슈 사항
Kotest는 주로 두 가지 방법으로 짤 수 있습니다.
class Test(): BehaviorSpec({
// 여기에 구현하는 방법
})
class Test(): BehaviorSpec() {
init {
// 여기에 구현하는 방법
}
}
이 방식에 따라서도 이슈 사항이 존재했습니다.
제가 원하는 방향대로 테스트가 진행되기 위해서는 init{} 내부에 테스트를 작성해야 됐습니다.
Spec 클래스 내부에 테스트를 작성하는 방식으로 코드를 짜면 무조건 스프링 컨텍스트가 로딩이 된 후 테스트 컨테이너가 실행되어 원하는 결과가 나오지 않았습니다.
이 이유에 대해서 찾아보려고 했는데 아무리 구글링 해도 도저히 찾아지지 않아 GPT 선생님한테 물어보니 다음과 같은 답변을 해줬습니다.
질문 1. 두 방식이 뭐가 다른지 질문함
Kotest에서 init 블록과 XxxSpec({})의 {} 내부에서 테스트를 작성하는 것의 차이점은 주로 테스트의 구성 및 실행 시점에 있습니다. 아래에서 각각의 방식에 대해 설명하겠습니다.
1. init 블록에서 테스트 작성
구성: init 블록은 클래스의 초기화 블록으로, 클래스가 인스턴스화될 때 실행됩니다. 따라서 테스트 케이스는 Spec의 인스턴스가 생성될 때 등록됩니다. 특징:테스트 케이스를 정의할 때, Spec의 상태를 사용할 수 있습니다. 클래스의 프로퍼티나 메서드를 호출하여 초기화 작업을 수행할 수 있습니다. init 블록 내에서 직접적인 상태 변경이나 로직을 수행하는 것이 가능합니다.
2. XxxSpec({})의 {} 내부에서 테스트 작성
구성: XxxSpec({})의 {} 내부는 람다 표현식으로, 이 블록은 Spec의 실행 시점에 맞춰 평가됩니다. 이 방식은 Spec을 정의할 때 더 유연하게 사용할 수 있습니다. 특징:여러 개의 테스트를 그룹화하거나 특정 조건에 따라 테스트를 정의하는 데 유용합니다. beforeSpec, afterSpec 등의 메서드를 활용하여 테스트 전후의 설정을 더 쉽게 관리할 수 있습니다. 테스트 케이스를 동적으로 생성할 수 있는 유연성이 있습니다.
주요 차이점 요약
시점: init 블록은 클래스 인스턴스가 생성될 때 실행되며, XxxSpec({})의 {} 블록은 Spec이 정의될 때 실행됩니다. 구조: init 블록은 클래스의 프로퍼티 및 메서드에 직접 접근할 수 있지만, {} 블록은 Spec의 로직을 더 유연하게 구성할 수 있습니다. 유연성: {} 블록을 사용하면 조건부로 테스트를 추가하거나, 더 복잡한 구조의 테스트를 작성할 수 있는 유연성이 있습니다.
질문 2. 두 방식에서 Spring 컨텍스트 로딩 시점이 어떻게 되는지
1. init 블록
동작 방식: init 블록은 테스트 클래스가 인스턴스화될 때 실행됩니다. 즉, 클래스의 인스턴스가 생성될 때 aSpringBoo tTest의 컨텍스트가 초기화되기 전에 먼저 init 블록의 코드가 실행됩니다.
Spring 컨텍스트 로드: init 블록에서 Spec을 등록하는 작업이 완료된 후, Spring 컨텍스트가 로드됩니다. 이는 Kotes +의 Spec 등록 및 초기화 과정이 Spring 컨텍스트 초기화보다 우선적으로 수행된다는 것을 의미합니다.
2. Spec({}) 블록
동작 방식: Spec({}) 블록 내의 람다 함수는 테스트 실행을 위해 Spec 인스턴스가 생성된 후에 실행됩니다. 이 시점에서 Spring 컨텍스트가 이미 로드되었고, 모든 Bean에 접근할 수 있는 상태입니다.
Spring 컨텍스트 로드: @SpringBootTest에 의해 Spring 컨텍스트가 먼저 로드되고, 그 이후에 Spec 내의 테스트 케 이스가 실행됩니다. 따라서, 이 블록 내에서는 Spring Bean을 안전하게 사용할 수 있습니다.
요약
init 블록: Spec 클래스가 인스턴스화될 때 실행되며, Spring 컨텍스트가 로드되기 전에 Spec 등록이 이루어집니다. Spec ({}) 블록: Spec 클래스 인스턴스가 생성된 후, Spring 컨텍스트가 로드되고, 그 후에 테스트가 실행됩니다.
결론
이러한 차이는 Kotest의 구조와 Spring Boot의 초기화 과정에 기인합니다. init 블록은 Spec의 초기화를 위한 첫 단계로 작용하고, 이 과정이 끝난 후 Spring 컨텍스트가 로드됩니다. 반면, Spec ({}) 블록은 Spring 컨텍스트가 이미 로드된 상태에서 실행됩니다. 이러한 특성 때문에 특정 상황에서 Bean 주입이나 의존성 관리가 필요할 때, 어떤 블록을 사용하는지가 중요합니다.
이런 구조적인 차이점으로 인해 init{} 내부에서 테스트를 작성하는 걸로 팀에서 다 같이 합의를 보게 됐습니다.
여러 기업들 기술 블로그를 보면 Kotest의 SpringBootTest는 init{} 블록을 사용하던데 그 이유가 있었나 봅니다.
늦었지만 최종 결과물 (만약 쓰고 싶으시다면 이 부분을 참고해 주세요~)
// BeforeDbTestListener class
class BeforeDbTestListener: SpecExtension {
override suspend fun intercept(spec: Spec, execute: suspend (Spec) -> Unit) {
if (!TestContainerListener.mySQLContainer.isRunning) {
TestContainerListener.mySQLContainer.start()
// Data JPA Test datasource properties 설정
System.setProperty("spring.datasource.url", TestContainerListener.mySQLContainer.jdbcUrl)
System.setProperty("spring.datasource.driver-class-name", TestContainerListener.mySQLContainer.driverClassName)
System.setProperty("spring.datasource.username", TestContainerListener.mySQLContainer.username)
System.setProperty("spring.datasource.password", TestContainerListener.mySQLContainer.password)
// Test Container ddl-auto 설정
System.setProperty("spring.jpa.hibernate.ddl-auto", "update")
}
execute(spec)
}
}
// TestContainerListener
@AutoScan
object TestContainerListener : AfterProjectListener {
val mySQLContainer = MySQLContainer(DockerImageName.parse("mysql:8.0.39"))
override suspend fun afterProject() {
if (mySQLContainer.isRunning) {
mySQLContainer.stop()
}
}
}
// Custom Spec
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(QuerydslConfig::class, JpaConfig::class, DataSourceConfig::class)
abstract class BaseDataJpaTestFeatureSpec(body: FeatureSpec.() -> Unit = {}) : FeatureSpec(body) {
override fun extensions() = listOf(BeforeDbTestListener(), SpringExtension)
}
// 중요) 테스트 작성 방법
class UserManageServiceTest : BaseDataJpaTestFeatureSpec() {
init {
// 반드시 여기에 테스트 작성
}
}
끝!
어쩌다 글이 길어지게 됐는데 그만큼 삽질을 많이 했습니다..
늘 그렇듯 어찌저찌 구현하긴 했는데 뭔가 더 좋은 방법이 있을 거 같아 나중에 실력이 쌓이고 더 공부해서 개선해 보려고 합니다.
언젠가 개선에 성공하면 꼭 다시 글 남기도록 하겠습니다.
'Spring' 카테고리의 다른 글
| Spring에서 DB Read, Write 분기를 해보자 + 동작 방식도 알아보자 (0) | 2024.12.18 |
|---|---|
| Spring에서 DataJpaTest와 TestContainer를 같이 써보자 (0) | 2024.08.20 |