Kotest의 Lifecycle Hook, IsolationMode

Glen·2023년 12월 21일
2

배운것

목록 보기
31/37

서론

테스트 코드는 어플리케이션의 동작을 보장해 준다.

또한, 잘 작성한 테스트 코드는 그 자체로 문서화가 된다.

TDD 방법론으로 개발을 진행하면, 실제 동작하는 클래스를 구현할 수 없으니, Mock 또는 Fake 객체 같은 모의 객체를 사용한다.

굳이 TDD가 아니더라도, 통합 테스트가 아닌 단위 테스트, 슬라이스 테스트같이 외부 의존성에 대해 강하게 결합된 의존을 끊고 빠르게 핵심을 테스트하기 위해 모의 객체를 사용한다.

테스트 코드에는 한 파일 내부에 여러 테스트를 작성하는데, 이때 모의 객체에 정의한 Stub이 다른 테스트에 영향을 줄 수 있다.

즉, 테스트의 격리성이 보장되지 않는다.

스프링을 사용한 통합 테스트에서는 @Transcational 어노테이션을 사용하면 롤백이 되기 때문에 테스트의 격리성이 보장되지만, 스프링을 사용하지 않는 일반적인 단위 테스트의 경우 이러한 격리성을 보장받지 못한다.

그렇다면 어떻게 테스트의 격리성을 지켜야 할까?

본론

다음과 같은 코틀린 테스트 코드가 있다.

class KotestTest: StringSpec({  
  
    val memberRepository = MemoryMemberRepository()  
  
    "회원의 수를 조회한다. 1" {  
        // given  
        val member = Member("1", SocialType.LOCAL, "seokjin8678")  
  
        // when  
        memberRepository.save(member)  
  
        // then  
        memberRepository.countBy() shouldBe 1  
    }  
  
    "회원의 수를 조회한다. 2" {  
        // given  
        val member = Member("1", SocialType.LOCAL, "seokjin8678")  
  
        // when  
        memberRepository.save(member)  
  
        // then  
        memberRepository.countBy() shouldBe 1  
    }  
})

해당 테스트를 따로 실행시킨다면 문제가 발생하지 않고 통과할 것이다.
하지만 동시에 모든 테스트를 실행하면 둘 중 하나의 테스트는 실패한다.

이유는 테스트에서 사용되는 memberRepository가 공유되기 때문이다.

하지만 Kotest가 아닌 JUnit5환경에서 실행하면 테스트는 실패하지 않는다.

class JUnitTest {  
  
    private val memberRepository = MemoryMemberRepository()  
  
    @Test  
    fun `회원의 수를 조회한다 1`() {  
        // given  
        val member = Member("1", SocialType.LOCAL, "seokjin8678")  
  
        // when  
        memberRepository.save(member)  
  
        // then  
        memberRepository.countBy() shouldBe 1  
    }  
  
    @Test  
    fun `회원의 수를 조회한다 2`() {  
        // given  
        val member = Member("1", SocialType.LOCAL, "seokjin8678")  
  
        // when  
        memberRepository.save(member)  
  
        // then  
        memberRepository.countBy() shouldBe 1  
    }  
}

이유는 기본 JUnit5 환경에서는 기본으로 테스트 메서드마다 새로운 테스트 인스턴스를 생성하기 때문이다.

@TestInstance(TestInstance.Lifecycle.PER_CLASS) 어노테이션을 사용하여 하나의 테스트 인스턴스를 사용하게 할 수 있다.

하지만 Kotest 환경에서는 기본으로 단 하나의 테스트 인스턴스를 생성하기 때문에 JUnit5와 다른 테스트 결과가 발생했고 격리성이 보장되지 않는다.

따라서 이 문제를 해결하기 가장 단순하고 쉬운 방법은 memberRepository 변수를 테스트 블럭안에 선언하는 것이다.

"회원을 저장한다." {  
    // given  
    val memberRepository = MemoryMemberRepository()  
    val member = Member("1", SocialType.LOCAL, "seokjin8678")  
  
    // when  
    memberRepository.save(member)  
  
    // then  
    memberRepository.findById(member.id) shouldBe member  
}  
  
"회원의 수를 조회한다." {  
    // given  
    val memberRepository = MemoryMemberRepository()  
    val member = Member("1", SocialType.LOCAL, "seokjin8678")  
  
    // when  
    memberRepository.save(member)  
  
    // then  
    memberRepository.countBy() shouldBe 1  
}

하지만 이 방법을 사용하게 되면, 테스트 코드마다 memberRepository를 중복적으로 선언하게 되는 문제가 발생한다.

이 문제는 JUnit5를 사용해 본 경험이 있다면 간단하게 해결할 수 있다.

바로 @AfterEach, @BeforeAll 어노테이션을 사용한 테스트 프레임워크의 생명 주기 훅을 사용하면 된다.

@BeforeEach  
fun setUp() {  
    memberRepository.deleteAll()  
}

KotestJUnit5에서 제공하는 훅과 다르게 어노테이션을 사용하지 않고 beforeTest(), beforeAny(), prepareSpec()과 같은 다양한 훅을 제공한다.

AnnotationSpec을 사용하면 JUnit5의 어노테이션을 사용할 수 있다.

Kotest의 다양한 훅을 이해하기 이전에 TestType에 대해 알아야 할 필요가 있다.

TestType

Kotest에서는 JUnit5@Nested와 마찬가지로 테스트 클래스 내부에 중첩으로 테스트를 만들 수 있는 기능을 제공한다.

DescribeSpec 기준 describe(), context()로 정의한다.

해당 테스트를 Container라고 부르며 타입은 TestType.Container이다.

그리고 일반 테스트의 타입은 TestType.Test이다.

테스트 스타일마다, ContainerTest 타입의 테스트를 선언하는 방법이 다르니 사용하는 테스트 스타일의 문서를 확인하자.

ContainerTest 모두 검증 로직을 수행할 수 있다.

하지만 Test 타입은 내부에 Test를 선언할 수 없다.

class KotestTest : DescribeSpec({  
  
    describe("Container - Test Case 포함 O") {  
        println(this.testCase.type) // Container  
  
        it("Test") {  
            println(this.testCase.type) // Test  
            1 shouldBe 1  
        }  
    }  
    
    describe("Container - Test Case 포함 X") {  
        println(this.testCase.type) // Container  
        1 shouldBe 1  
    }  
  
    it("Test Case") {  
        println(this.testCase.type) // Test  
        1 shouldBe 1  
        
        // it("InvalidDslException 예외가 발생한다.") {  
               // 1 shouldBe 1 
        // }  
    }  
})

참고로 TestType에는 Container, Test 말고도 Dynamic도 존재한다.

Property-based Testing 또는 Data-driven Testing을 사용하면 Dynamic 타입이 된다.

// Data-driven Testing
// "kotest-framework-datatest" 모듈을 추가해야 withDate() 메서드를 사용할 수 있다.
describe("Dynamic") {  
    println(this.testCase.type) // Container  
  
    withData(  
        nameFn = { "Dynamic $it" },  
        1 to "1",  
        2 to "2"  
    ) {  
        println(this.testCase.type) // Dynamic  
        it.first.toString() shouldBe it.second  
    }  
}

여기서 중요한 것은 TestType에 상관 없이, 테스트 메서드는 테스트 라는 점이다!

Kotest Lifecycle Hook

Kotest의 Lifecycle hook은 다음과 같은 종류가 있다.

Callback설명
beforeContainerType이 Container인 테스트가 실행되기 전에 실행된다.
afterContainerType이 Container인 테스트가 실행된 후에 실행된다.
테스트가 실패하더라도 실행된다.
beforeEachType이 Test인 테스트가 실행되기 전에 실행된다.
afterEachType이 Test인 테스트가 실행된 후에 실행된다.
테스트가 실패하더라도 실행된다.
beforeAnyType에 상관 없이 테스트가 실행되기 전에 실행된다.
afterAnyType에 상관 없이 테스트가 실행되기 전에 실행된다.
테스트가 실패하더라도 실행된다.
beforeTestbeforeAny와 같은 동작을 한다.
afterTestafterAny와 같은 동작을 한다.
beforeSpec테스트가 실행되는 인스턴스 이전에 실행된다.
IsolationMode가 SingleInstance인 경우, prepareSpec과 같은 동작을 한다.
afterSpec테스트가 실행되는 인스턴스 이후에 실행된다.
IsolationMode가 SingleInstance인 경우, finalizeSpec과 같은 동작을 한다.
해당 Callback에서 예외가 발생하면 이후 beforeSpec, AfterSpec은 스킵된다.
prepareSpec테스트 실행 전 단 한 번 실행된다.
인스턴스 횟수와 상관 없이 해당 콜백은 한 번만 호출된다.
테스트 케이스가 없어도 해당 콜백은 실행된다.
IsolationMode가 SingleInstance인 경우, beforeSpec과 같은 동작을 한다.
finalizeSpec모든 테스트가 실행되고 단 한 번 실행된다.
인스턴스 횟수와 상관 없이 해당 콜백은 한 번만 호출된다.
IsolationMode가 SingleInstance인 경우, afterSpec과 같은 동작을 한다.
beforeInvocation테스트가 invocation 설정을 통해 실행되기 전 실행된다.
invocation 설정이 없으면 beforeTest와 같은 동작을 한다.
afterInvocation테스트가 invocation 설정을 통해 실행된 후 실행된다.
invocation 설정이 없으면 afterTest와 같은 동작을 한다.

Kotest 5.7.2 기준 prepareSpec 훅 메서드가 존재하지 않아서 사용할 수 없었다. 😂

다음과 같이 Hook을 정의하고 테스트 코드를 실행하면 결과는 다음과 같다.

class KotestTest: FunSpec({  
  
    beforeAny {  
        println("beforeAny: ${it.name.testName}")  
    }  
      
    afterAny {  
        println("afterAny: ${it.a.name.testName}")  
    }  
      
    beforeTest {  
        println("beforeTest: ${it.name.testName}")  
    }  
      
    afterTest {  
        println("afterTest: ${it.a.name.testName}")  
    }  
      
    beforeEach {  
        println("beforeEach: ${it.name.testName}")  
    }  
      
    afterEach {  
        println("afterEach: ${it.a.name.testName}")  
    }  
      
    beforeContainer {  
        println("beforeContainer: ${it.name.testName}")  
    }  
      
    afterContainer {  
        println("afterContainer: ${it.a.name.testName}")  
    }  
      
    beforeSpec {  
        println("beforeSpec: $it")  
    }  
      
    afterSpec {  
        println("afterSpec: $it")  
    }  
      
    finalizeSpec {  
        println("finalizeSpec: ${it.a.simpleName}")  
    }

    test("Test") {  
        println(this.testCase.name.testName)  
        1 shouldBe 1  
    }
})
beforeSpec: kr.galaxyhub.sc.KotestTest@7ae211f5
beforeEach: Test
beforeAny: Test
beforeTest: Test
Test
afterTest: Test
afterAny: Test
afterEach: Test
afterSpec: kr.galaxyhub.sc.KotestTest@7ae211f5
finalizeSpec: KotestTest

그리고 Container에 여러 테스트를 정의하고 실행했을 때 결과는 다음과 같다.

class KotestTest: FunSpec({ 

    // 위에서 정의한 Hooks 포함
    
    context("Container") {  
        println(this.testCase.name.testName)  
        test("Inner Test 1") {  
            println(this.testCase.name.testName)  
            1 shouldBe 1  
        }  
      
        test("Inner Test 2") {  
            println(this.testCase.name.testName)  
            1 shouldBe 1  
        }  
    }
})
beforeSpec: kr.galaxyhub.sc.KotestTest@245583c7
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeEach: Inner Test 1
beforeAny: Inner Test 1
beforeTest: Inner Test 1
Inner Test 1
afterTest: Inner Test 1
afterAny: Inner Test 1
afterEach: Inner Test 1
beforeEach: Inner Test 2
beforeAny: Inner Test 2
beforeTest: Inner Test 2
Inner Test 2
afterTest: Inner Test 2
afterAny: Inner Test 2
afterEach: Inner Test 2
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@245583c7
finalizeSpec: KotestTest

Kotest Isolation Mode

다양한 Lifecycle Hook을 정의하여 테스트 간의 격리성을 보장할 수 있다.

하지만 JUnit5 환경에서는 굳이 Hook을 정의하지 않아도 됐었고, Hook 중에서 IsolationMode라는 용어가 자주 나왔다.

위에서 언급했지만, Kotest는 테스트 클래스를 기본으로 단 하나의 인스턴스만 생성하는데, JUnit5 환경처럼 테스트마다 새로운 인스턴스를 생성할 수 있다면 굳이 Hook을 정의하지 않아도 테스트의 격리성을 보장할 수 있다.

KotestIsolationMode를 제공하며, IsolationMode를 커스텀하여 JUnit5 환경과 같이 테스트 인스턴스를 테스트마다 생성하여 격리성을 보장시킬 수 있다.

IsolationMode는 Enum이며, 다음과 같은 값이 존재한다.

  • SingleInstance
  • InstancePerTest
  • InstancePerLeaf

Kotest의 기본 IsolationModeSingleInstance 이다.

따라서 위의 결과를 보면 단 하나의 인스턴스만 생성된 것을 볼 수 있다.

그렇다면 IsolationModeJUnit5와 같이 설정하려면 InstancePerTest를 사용하면 될까..?

InstancePerTest

IsolationMode는 프로퍼티를 재설정하거나, 메서드를 재정의하여 커스텀 할 수 있다.

class KotestTest : FunSpec({  
    isolationMode = IsolationMode.InstancePerTest
    ...
})
또는
class KotestTest : FunSpec({
    ...
} {
    override fun isolationMode(): IsolationMode = IsolationMode.InstancePerTest
}

그리고 다음과 같이 두 개의 테스트가 있을 때 실행 결과는 다음과 같다.

class KotestTest: FunSpec({  
  
    // 위에서 정의한 Hooks 포함

    test("Test 1") {  
        println(this.testCase.name.testName)  
        1 shouldBe 1  
    }
    
    test("Test 2") {  
        println(this.testCase.name.testName)  
        1 shouldBe 1  
    }
})
beforeSpec: kr.galaxyhub.sc.KotestTest@17c634f5
beforeEach: Test 1
beforeAny: Test 1
beforeTest: Test 1
Test 1
afterTest: Test 1
afterAny: Test 1
afterEach: Test 1
afterSpec: kr.galaxyhub.sc.KotestTest@17c634f5 
beforeSpec: kr.galaxyhub.sc.KotestTest@28cd7ad2
beforeEach: Test 2
beforeAny: Test 2
beforeTest: Test 2
Test 2
afterTest: Test 2
afterAny: Test 2
afterEach: Test 2
afterSpec: kr.galaxyhub.sc.KotestTest@28cd7ad2
finalizeSpec: KotestTest
finalizeSpec: KotestTest

결과를 보면 테스트 마다 다른 인스턴스를 사용하는 것을 볼 수 있다.

만약, Container에 포함된 여러 테스트라면 어떤 결과가 나올까?

class KotestTest: FunSpec({ 

    // 위에서 정의한 Hooks
    
    context("Container") {  
        println(this.testCase.name.testName)  
        test("Inner Test 1") {  
            println(this.testCase.name.testName)  
            1 shouldBe 1  
        }  
      
        test("Inner Test 2") {  
            println(this.testCase.name.testName)  
            1 shouldBe 1  
        }  
    }
})
beforeSpec: kr.galaxyhub.sc.KotestTest@4e02846c
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeSpec: kr.galaxyhub.sc.KotestTest@6b0562b2
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeEach: Inner Test 1
beforeAny: Inner Test 1
beforeTest: Inner Test 1
Inner Test 1
afterTest: Inner Test 1
afterAny: Inner Test 1
afterEach: Inner Test 1
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@6b0562b2
beforeSpec: kr.galaxyhub.sc.KotestTest@6757205a
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeEach: Inner Test 2
beforeAny: Inner Test 2
beforeTest: Inner Test 2
Inner Test 2
afterTest: Inner Test 2
afterAny: Inner Test 2
afterEach: Inner Test 2
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@6757205a
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@4e02846c
finalizeSpec: KotestTest
finalizeSpec: KotestTest
finalizeSpec: KotestTest

결과를 보면 약간 의아한 결과가 나왔다.

테스트는 2개인데, 총 3개의 인스턴스가 생성되었다.

  • Container
  • Container/InnerTest1
  • Container/InnerTest2

왜냐하면 TestType이 Container인 테스트도 테스트이기 때문이다.

따라서 의도하든, 의도치 않든 Container도 새로운 인스턴스로 테스트가 생성되었다.

테스트마다 인스턴스를 새로 생성하여 격리성을 보장받고 있는데, Container를 새롭게 인스턴스로 생성할 필요가 있을까?

그렇다면 Container를 제외하고 테스트 케이스에만 인스턴스를 생성하려면 어떻게 해야 할까?

InstancePerLeaf

InstancePerLeaf를 사용하면 각 테스트 케이스 마다 인스턴스를 생성하게 할 수 있다.

따라서 위 테스트의 IsolationModeInstancePerLeaf로 설정하고 실행하면 다음과 같은 결과가 나온다.

beforeSpec: kr.galaxyhub.sc.KotestTest@7a0c750a
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeEach: Inner Test 1
beforeAny: Inner Test 1
beforeTest: Inner Test 1
Inner Test 1
afterTest: Inner Test 1
afterAny: Inner Test 1
afterEach: Inner Test 1
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@7a0c750a
beforeSpec: kr.galaxyhub.sc.KotestTest@2fc729bd
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeEach: Inner Test 2
beforeAny: Inner Test 2
beforeTest: Inner Test 2
Inner Test 2
afterTest: Inner Test 2
afterAny: Inner Test 2
afterEach: Inner Test 2
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@2fc729bd
finalizeSpec: KotestTest
finalizeSpec: KotestTest

결과를 보면 우리가 원하던 2개의 테스트 인스턴스가 생성된 것을 볼 수 있다.

여기서 추가로, ContainerContainer가 있는 경우 인스턴스는 몇 개 생성될까?

context("Container") {  
    println(this.testCase.name.testName)
    context("Inner Container") {  
        println(this.testCase.name.testName) 
        1 shouldBe 1  
    }
}
beforeSpec: kr.galaxyhub.sc.KotestTest@36aeb1eb
beforeContainer: Container
beforeAny: Container
beforeTest: Container
Container
beforeContainer: Inner Container
beforeAny: Inner Container
beforeTest: Inner Container
Inner Container
afterTest: Inner Container
afterAny: Inner Container
afterContainer: Inner Container
afterTest: Container
afterAny: Container
afterContainer: Container
afterSpec: kr.galaxyhub.sc.KotestTest@36aeb1eb
finalizeSpec: KotestTest

단 하나의 테스트 인스턴스가 생성된 것을 볼 수 있다.

즉, InstancePerLeafTestType에 상관없이 말 그대로 Leaf(최하위) 테스트마다 인스턴스를 생성한다.

따라서 IsolationMode를 사용하여 테스트 격리성을 지키려면 InstancePerTest 보다는 InstancePerLeaf를 사용하는 것이 더 효율적이다.

결론

JUnit5와 다르게, Kotest에서는 테스트 클래스 당 하나의 인스턴스를 생성하기에 격리성을 보장하려면 추가적인 설정이 필요하다.

Kotest 환경에서 테스트 간 격리성을 지키기 위해 Lifecycle Hook 그리고 IsolationMode을 제공한다.

따라서 테스트의 격리성을 보장해 주려면 사용 중인 테스트 프레임워크의 격리 모드, 생명 주기를 잘 이해하고 적용할 수 있어야 한다.

또한, 테스트마다 격리 수준을 효과적으로 적용하기 위해 두 설정을 적절히 조합하여 적용해야 한다.

JUnit5과 다르게 Kotest에서 기본 격리 수준을 singleInstance로 설정한 것은 테스트 마다 인스턴스를 생성하지 않고 Lifecycle Hook을 사용하여 효율적으로 테스트 코드를 작성하라고 한 것이 아닌가 합리적 의심을 해본다.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글