kotest

정현승·2025년 4월 29일
0

테스트

목록 보기
2/3
post-thumbnail

1. kotest 기본 사용법

1.1. kotest의 특징

  • 코틀린에서는 다양한 DSL(Domain Specific Language) 스타일의 문법을 제공한다.
  • 기존에 사용하던 Junit과 AssertJ, Mockito를 사용하면 이런 DSL 스타일을 제대로 살려서 테스트를 진행할 수 없다.
  • 따라서 kotest 테스트 라이브러를 사용하면, 코틀린 문법의 특성을 살리면서 가독성 있는 테스트를 작성하기 쉽다.

1.2. kotest 의존성

testImplementation("io.kotest:kotest-runner-junit5:${Versions.KOTEST}")
testImplementation("io.kotest:kotest-assertions-core:${Versions.KOTEST}")

위의 두 의존성을 추가해준다. 나는 5.5.4 버전을 택해 사용했다.

kotest-runner-junit5는 다양한 방식의 테스트 코드 작성을 쉽게 하기 위해,
kotest-assertions-core는 DSL 스타일에 맞는 검증을 하기 위해 추가하였다.

1.3. kotest 사용 비교

간단한 단위 테스트를 보며 비교해보려 한다. 아래와 같은 테스트를 기존의 Junit과 AssertJ를 사용해 작성하였다.

internal class AESEncryptBOTest {

    @Test
    @DisplayName("encrypt된 패스워드를 decrypt하면 원본이 나온다.")
    fun decryptTest() {
         // given
        val password = "test-my-password"

        // when
        val aesEncryptBO = AESEncryptBO.encrypt(password.toByteArray(Charsets.UTF_8))

        // then
        Assertions.assertThat(AESEncryptBO.decrypt(aesEncryptBO)).isEqualTo(password)
    }
}

위의 코드를 kotest를 사용하여 다시 작성해본다면 다음과 같이 개선할 수 있다.

internal class AESEncryptBOTest : StringSpec({

    "encrypt된 패스워드를 decrypt하면 원본이 나온다." {
        // given
        val password = "test-my-password"

        // when
        val aesEncryptBO = AESEncryptBO.encrypt(password.toByteArray(Charsets.UTF_8))

        // then
        AESEncryptBO.decrypt(aesEncryptBO) shouldBe password
    }
})

가장 큰 차이점은 @Test, @DisplayName을 사용하지 않고 깔끔하게 테스트를 작성할 수 있고, 무엇보다도 객체의 값을 검증할 때에 DSL 스타일인 shouldBe로 동등성 검증을 했다는 점이다.

kotest를 작성할 대에는 StringSpec 말고도 다양한 방법이 있다.

  • AnnotationSepc : 기존의 Junit 테스트 방식과 유사하게, 애너테이션을 기반으로 테스트를 작성
  • StringSpec : 간단하게 테스트의 DisplayName을 명시하여 테스트를 할 때 사용
  • BehaviorSpec : Given, When, Then 방식으로 테스트를 할 때 사용
  • DescribeSpec : Describe, Context, It 방식으로 테스트를 할 때 사용
    • 각 방법에 대한 구체적인 사용 예시는 이곳에서!

검증 방법도 shouldBe 말고도 다양하게 있다.

  • shouldBe
  • shouldNotBe
  • shouldStartWith
  • shouldContain
  • shouldContainAll
  • shouldBeEqualIgnoringCase
  • shouldBeGreaterThanOrEqualTo
  • 등등..

2. kotest 사용예

2.1. BehaviorSpec 활용

  • kotest를 사용해서 ApplicationService를 테스트하는 예제 코드다.
  • ApplicationService는 외부 서비스 혹은 DB 쿼리를 날리는 계층이므로 적절하게 mocking을 해서 사용해야 한다.
  • 여러 분기로직이 있을 것이고, 각 상황에 따라 테스트를 하고 싶기 때문에 BehaviorSpec 기반에서 MockK와 함께 사용하였다.
@ExtendWith(MockKExtension::class)
class AccountCommandServiceTest : BehaviorSpec() {
    @InjectMockKs private lateinit var accountCommandService: AccountCommandService

    @MockK private lateinit var userRepository: UserRepository
    @MockK private lateinit var userUpdateHistoryRepository: UserUpdateHistoryRepository
    ...


    init {
        isolationMode = IsolationMode.InstacePerLeaf // 아래에서 설명
        MockKAnnotations.init(this) // 필드에 선언한 @MockK를 초기화하기 위함

        Given("회원탈퇴시") {
            val mockCommand = mockk<UserDeleteCommand>(relaxed = true)

            When("사용자가 존재하고, 회원 탈퇴 이력이 없다면") {
                every { userRepository.existsByNo(mockCommand.userNo) } returns true
                every { userDeleteHistoryRepository.existsByUserId(mockCommand.userNo) } returns false
                every { userDeleteHistoryRepository.save(any<UserDeleteHistory>()) } returns mockk()

                Then("정상적으로 탈퇴한다.") {
	                accountCommandService.withdrawalUser(command)
                    assertSoftly {
                        verify { userRepository.existsByNo(mockCommand.userNo) }
                        verify { userDeleteHistoryRepository.existsByUserId(mockCommand.userNo) }
                        verify { userDeleteHistoryRepository.save(any<UserDeleteHistory>()) }
                    }
                }
            }

            When("사용자가 존재하지 않으면 ") {
                every { userRepository.existsByNo(mockCommand.userNo) } returns false

                Then("예외가 발생한다.") {
                    assertSoftly {
                        assertThrows<UserNotFoundException> {
                            accountCommandService.withdrawalUser(mockCommand)
                        }
                        verify { userRepository.existsByNo(mockCommand.userNo) }
                        verify { userDeleteHistoryRepository wasNot Called }
                    }
                }
            }

            When("이미 회원 탈퇴 요청을 했었다면 ") {
                every { userRepository.existsByNo(mockCommand.userNo) } returns true
                every { userDeleteHistoryRepository.existsByUserId(mockCommand.userNo) } returns true

                Then("예외가 발생한다.") {
                    assertSoftly {
                        assertThrows<DeleteDuplicatedException> {
                            accountCommandService.withdrawalUser(mockCommand)
                        }
                        verify { userRepository.existsByNo(mockCommand.userNo) }
                        verify { userDeleteHistoryRepository.existsByUserId(mockCommand.userNo) }
                        verify { userDeleteHistoryRepository.save(any<UserDeleteHistory>()) wasNot Called }
                    }
                }
            }
        }
    }
}

  • 기존에 Junit에서 이렇게 다양한 상황을 테스트했어야 한다면, nested class로 구성했어야 했기 때문에 코드가 복잡했을 것이다.
  • 하지만 위에서는 kotest에서 제공하는 BehaviorSpec을 사용해, BDD 스타일로 테스트를 가독성있게 작성할 수 있다.
  • 더불어 BehaviorSpec 덕분에 기존의 중첩 클래스와 @BeforeEach 애너테이션 사용을 줄일 수 있게되었다.
  • 무엇보다도 각각의 상황에서 어떤 행위가 일어나는지 제삼자가 보더라도 쉽게 파악할 수 있다. (일종의 문서화)

참고
Then 절에서 여러 값을 검증해야 한다면 assertSoftly { ... }를 사용하자.
assertSoftly를 사용하지 않는다면 여러 개가 실패했더라도 하나만 에러 로그가 남는다.
assertSoftly를 사용하면 여러 개가 실패하더라도 어떤 항목이 실패했는지 에러 로그가 자세하게 남는다.

2.2. 여러 개를 테스트하고 싶을 때

2.2.1. 단일값

 Given("IdReservedKeywordsValidator") {
    val validator = ReservedKewordValidator()

    listOf("reserved", "admin", "test", "master").forEach { keyword ->
        Then("실패 : $keyword") {
            assertThrows<ReservedKeywordException> {
            	validator.validate(keyword)
            }
        }
    }

    listOf("hyunseung", "gustmd", "goodday").forEach { keyword ->
        Then("성공 : $keyword") {
            assertDoesNotThrows {
            	validator.validate(keyword)
            }
        }
    }
}

2.2.2. 여러값

data class KeywordTestData(val keyword: String, val shouldThrow: Boolean)

class ReservedKeywordValidatorTest : FunSpec({
    val validator = ReservedKeywordValidator()

    withData(
        KeywordTestData("reserved", true),
        KeywordTestData("admin", true),
        KeywordTestData("test", true),
        KeywordTestData("master", true),
        KeywordTestData("hyunseung", false),
        KeywordTestData("gustmd", false),
        KeywordTestData("goodday", false)
    ) { (keyword, shouldThrow) ->
        if (shouldThrow) {
            shouldThrow<ReservedKeywordException> {
                validator.validate(keyword)
            }
        } else {
            shouldNotThrowAny {
                validator.validate(keyword)
            }
        }
    }
})

3. kotest IsolationMode

  • kotest는 Junit5와 달리 각 테스트마다 인스턴스를 생성하지 않는다.
  • 위의 상황에서 첫 번째 When절에서 userDeleteHistory 호출부를 모킹했는데, 이 동작이 두 번째 When절에도 영향을 미쳐 해당 호출부가 호출되지 않음을 검증했지만 예상과는 달리 호출된 상황이 발생한다. 이는 테스트의 격리 레벨이 의도와 다르게 동작함을 알 수 있다.
  • 이를 해결하기 위해 isolationMode를 조정하면 된다.
    • IsolationMode.SingleInstance (기본값) : Spec 클래스 내에서 단 하나의 인스턴스만 생성해 테스트 진행
    • IsolationMode.InstancePerTest : 매 테스트마다 새로운 인스턴스를 생성해 테스트 진행
    • IsolationMode.InstancePerLeaf : 최하위 테스트마다 인스턴스를 생성해 테스트 진행
  • 거의 대부분의 상황에서 Then을 기준으로 모든 테스트가 격리되는 것이 바람직하기에, 테스트 격리가 필요한 상황에서는 InstancePerTest보단 InstancePerLeaf로 설정해주는 것이 좋을 것이다.
  • 아래의 각 상황에서 uuid 값이 어떻게 출력되는지 보면 이해하기 더 쉬울 것이다.

3.1. SingleInstance

class Test : BehaviorSpec({

    Given("given") {
        val id = UUID.randomUUID()
        println(id)
        When("when") {
            println(id)
            Then("then") {
                println(id)
            }
            Then("then") {
                println(id)
            }
        }
    }
})
/**
 * 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
 * 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
 * 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
 *
 * 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
 * 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
 * 732bd49c-d73a-4ae5-9a20-ff1a3e790c84
 **/

3.2. InstancePerTest

class Test : BehaviorSpec({
    isolationMode = IsolationMode.InstancePerTest

    Given("given") {
        val id = UUID.randomUUID()
        println(id)
        When("when") {
            println(id)
            Then("then") {
                println(id)
            }
        }
    }
})
/**
 * 6c9958a4-d456-4ca0-8905-098bfebaf464 // given
 *
 * bd751482-7d51-4f99-b688-879521eee273 // given
 * bd751482-7d51-4f99-b688-879521eee273 // when
 *
 * 39b9a979-eef4-4e06-8e0d-f14bf1a719e8 // given
 * 39b9a979-eef4-4e06-8e0d-f14bf1a719e8 // when
 * 39b9a979-eef4-4e06-8e0d-f14bf1a719e8 // then
 **/

3.3. InstancePerLeaf

class Test : BehaviorSpec({
    isolationMode = IsolationMode.InstancePerLeaf

    Given("given") {
        val id = UUID.randomUUID()
        println(id)
        When("when") {
            println(id)
            Then("then") {
                println(id)
            }
            Then("then") {
                println(id)
            }
        }
    }
})
/**
 * 39b9a979-eef4-4e06-8e0d-f14bf1a719e8
 * 39b9a979-eef4-4e06-8e0d-f14bf1a719e8
 * 39b9a979-eef4-4e06-8e0d-f14bf1a719e8
 *
 * bd751482-7d51-4f99-b688-879521eee273
 * bd751482-7d51-4f99-b688-879521eee273
 * bd751482-7d51-4f99-b688-879521eee273
 **/

4. kotest를 사용하면 좋은 점

  • 사실 기존의 AssertJ나 Junit5를 사용하는 것과 별반 다르지는 않다.
  • 다만 BDD 스타일로 테스트를 작성할 때 kotest를 활용하면 가독성이 개선된다는 점에서 좋았다.
  • 그리고 어쩌면 테스트 작성이 재밌어질 수 있고... 조금 더 테스트를 많이 작성할 수 있지 않을까 기대한다.
  • 또한 각 상황에서 로직이 어떻게 행동하는지 명시하기 쉬워 테스트 코드 자체가 문서가 될 수 있다. 이는 restdocs, swagger 보다도 더 정확한 문서가 될 것이다.

5. 참고

0개의 댓글