오늘은 실무에서 Kotest와 스프링을 같이 사용하면서 당황한 부분을 정리하고, Kotest Isolation Modes에 대해 정리할 예정이다.
Kotest Isolation Modes
IsolationMode라는 Enum을 활용해 테스트 케이스 인스턴스를 어떻게 생성하는지 제어할 수 있다.
IsolationMode Enum은 총 3가지 값이 있다.
- IsolationMode.SingleInstance
- IsolationMode.InstancePerTest
- IsolationMode.InstancePerLeaf
IsolationMode.SingleInstance
해당 Enum은 Spec(Fun Spec, Behavior Spec 등) 클래스의 인스턴스가 하나만 생성되고,
모든 테스트 케이스가 차례대로 실행되며, 모든 테스트가 완료될 때까지 동일한 인스턴스가 사용된다.
즉, 하나의 인스턴스에서 여러 테스트가 실행되며,
그 인스턴스에서의 상태나 변수 값이 테스트 간에 공유될 수 있다는 의미다.
다음과 같은 코드에서는 모두 동일한 값이 출력된다.
class ExampleTest: BehaviorSpec({
isolationMode = IsolationMode.SingleInstance
given("A") {
val id = UUID.randomUUID()
println(id)
`when`("B") {
println(id)
then("C") {
println(id)
}
}
}
})

IsolationMode.InstancePerTest
해당 enum을 IsolationMode로 설정하면 각각의 테스트 케이스마다 새로운 Spec 인스턴스가 생성된다.
공식 문서에서는 내부 컨텍스트도 포함되며, 외부 컨텍스트가 각각 독립적인 테스트로 실행되고 각기 다른 Spec 인스턴스에서 실행된다고 한다.
외부 컨텍스트는 given, when이고 내부 컨텍스트는 then이라고 이해하면 될 것 같다.
class ExampleTest: BehaviorSpec({
isolationMode = IsolationMode.InstancePerTest
given("A") {
val id = UUID.randomUUID()
println("A: ${id}")
`when`("B") {
println("B: ${id}")
then("C") {
println("C: ${id}")
}
}
}
})

A가 먼저 실행되고, 그런 다음 B를 다시 실행하고, 마지막에 C에 대해서도 실행했다.
각각의 실행이 새로운 Spec 클래스의 새로운 인스턴스에서 이루어진 것이다.
이 방식은 이전 테스트에서 사용된 변수가 다음 테스트에 영향되지 않으므로, 변수를 재사용하고 할 때 유용하다.
이를 통해 테스트가 독립적으로 실행된다.
IsolationMode.InstancePerLeaf
해당 enum을 IsolationMode로 설정하면 각각의 리프 테스트 케이스마다 새로운 Spec 인스터느가 생성된다.
리프 테스트 케이스란 실제 테스트가 실행되는 끝 부분을 의미하며, 내부 컨텍스트는 포함되지 않는다.
즉, 내부 컨텍스트는 외부 테스트로 가는 path의 일부로만 실행되는 것이다.
이 방식은 내부 컨텍스트가 외부 테스트를 위한 준비 역할을 할 때 유용하다.
class ExampleTest: BehaviorSpec({
isolationMode = IsolationMode.InstancePerLeaf
given("A") {
val id = UUID.randomUUID()
println("A: ${id}")
`when`("B") {
println("B: ${id}")
then("C") {
println("C: ${id}")
}
}
`when`("D") {
println("D: ${id}")
then("E") {
println("E: ${id}")
}
}
}
})

외부 컨텍스트인 테스트 a,b가 먼저 실행된 후, 테스트 c는 같은 인스턴스에서 실행된다.
그런 다음 새로운 Spec 인스턴스가 생성되고, 다시 테스트 a,d가 실행된 후, 테스트 e가 실행됩니다.
즉, 테스트 c는 같은 Spec 인스턴스에서 실행되지만, 테스트 e는 새로운 Spec 인스턴스에서 실행된다.
Kotest 사용하기 with Spring
kotest를 활용하는 테스트 코드에서 AbstractTestExecutionListener 구현해 사용하려고 했는데,
구현체 리스너가 동작하지 않았다.
공식 문서를 살펴보니 SpringExtension을 사용해야한다고 적혀있었다.
이를 설정해 통합 테스트를 돌려보았다.
class KotestProjectConfig : AbstractProjectConfig() {
override fun extensions() = listOf(SpringExtension)
}
다음과 같은 설정을 해도 의도한대로 테스트가 수행되지 않았다.
원인을 살펴보니 위 설정이 SpringTestLifecycleMode.Test를 디폴트 값으로 사용하고 있었기 때문이다.
이 부분에 대해 먼저 정리하겠다.
SpringTestLifecycleMode는 먼저 2가지가 있다.
- SpringTestLifecycleMode.Root: 루트 노드에서 테스트 메서드 콜백을 실행하도록 설정한다.
- SpringTestLifecycleMode.Test: 리프 노드에서 테스트 메서드 콜백을 실행하도록 설정한다.
SpringExtension을 뜯어보면 아래 조건을 맞추면 TestExecutionListener들이 동작한다.

먼저 보이는 TestType에 대해 정리하자.
Container, Test, Dynamic 총 3가지가 있다.
테스트가 부모 노드(parent node)로 다른 노드를 포함할 수 있다면, 이를 'Container' 유형이라고 한다.
describe나 context와 같은 블록이 해당할 수 있으며, 이 블록 안에서 여러 개의 it 테스트를 정의할 수 있다.
만약 테스트가 단말 노드(leaf node)이고 다른 테스트를 포함할 수 없다면, 이를 'Test' 유형이라고 한다.
it, then과 같은 구문이 해당된다.
'Dynamic'은 동적 테스트는 컨테이너일 수도 있고, 단말 테스트일 수도 있는 유형으로,
속성 테스트나 데이터 테스트와 같은 기능을 통해 동적으로 테스트가 추가되는 경우 사용된다.
실제 테스트 코드 구조는 아래와 같다.
class XxTest: BehaviorSpec({
given("값 세팅") {
`when`("로직 수행") {
then("결과 검증") {
}
}
}
})
그럼 결국 현재 TestType.Test에 걸리므로 then 이전에 리스너가 동작을 해서 발생하는 문제인 것이다
실제로 디버깅을 해보니 TestExecutionListener.beforeTestMethod()가 then 절 직전에 동작하고 있었다.
결론
필자가 의도한 테스트 케이스 순서는 IsolationMode.InstancePerTest와 맞아 이를 선택했으며,
(then 절 갯수만큼, given과 when절이 매번 재수행)
TestExecutionListener가 given 이전에 동작하는 것을 원해 SpringTestLifecycleMode.Root를 설정으로 주었다.
kotest를 기반으로 작성한 스프링 테스트가 잘 동작한다..!
역시 공식 문서 최고.
이상으로 포스팅을 마칩니다. 감사합니다.
참고 자료
댓글