ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 테스트 코드 개선하기: Kotest 모킹 초기화 및 구조 개선
    📖 개발 공부/kotlin 2024. 10. 27. 16:37

    이번 개선의 목표는 테스트 코드의 가시성과 효율성을 높이는 데 있다.

    여러 케이스에서 원하는 동작을 정확히 호출하고 의도한 타입의 응답을 반환하는지 확인했지만, 기존 코드에서는 불필요한 모킹이 많아 가독성이 떨어졌다. 이에 개선한 사항과 과정에서 새롭게 알게 된 내용을 정리해보려고 한다.

    AS-IS

    기존 코드에서는 다음 세 가지 케이스를 나누어 테스트를 진행했다.

    1. LLM 최소 조건을 충족하고 LLM 응답이 성공하는 경우
      LLM을 호출하며, 결과로 ContentResultByLLM을 반환해야 한다.
    2. LLM 최소 조건을 충족하지만 LLM 응답이 실패하는 경우
      LLM 호출 후 실패 시, ContentResultByRuleBased를 반환해아 한다.
    3. LLM 최소 조건을 충족하지 않는 경우
      LLM을 호출하지 않고, ContentResultByRuleBased를 반환해야 한다.
    AS-IS: 전체 테스트 코드 
    더보기
    class ContentScorerTest : BehaviorSpec({
        val mockContentRepository = mockk<ContentRepository>()
        val mockLLMClient = mockk<LLMClient>()
        val mockCategoryClient = mockk<CategoryClient>()
    
        val scorer = ContentScorerImpl(mockContentRepository, mockLLMClient, mockCategoryClient)
    
        val targetCategoryId = Random.nextInt(1, 10_000)
    
        val longText = "아아아아아아주 좋습니다아아아아아아아"
        val shortText = "아주 좋습니다"
    
        Given("LLM 최소 조건에 충족하고, LLM 응답에 성공하는 경우") {
            val content = ContentFixture.sample(
                text = longText,
                categoryId = targetCategoryId,
            )
            
            every { mockCategoryClient.listCategoriesByIds(any<List<Int>>()) } returns listOf(
                Category.emptyCategory(targetCategoryId),
            )
    
            every {
                mockLLMClient.request(LLMProject.SAMPLE, any())
            } returns mapOf<String, Any>(
                "example" to false,
            )
    
            When("calculateScore") {
                val result = scorer.calculateScore(review)
    
                Then("LLM을 통한 스코어링 결과가 나온다.") {
                    verify { mockLLMClient.request(any(), any()) }
                    result.shouldBeInstanceOf<ContentResultByLLM>()
                }
            }
        }
    
        Given("LLM 최소 조건에 충족하고, LLM 응답에 실패하는 경우") {
            val content = ContentFixture.sample(
                text = longText,
                resourceCategoryId = targetCategoryId,
            )
    
            every { mockCategoryClient.listCategoriesByIds(any<List<Int>>()) } returns listOf(
                Category.emptyCategory(targetCategoryId),
            )
    
            every {
                mockLLMClient.request(
                    project = LLMProject.SAMPLE,
                    variables = any(),
                )
            } returns null
    
            When("calculateScore") {
                val result = scorer.calculateScore(content)
    
                Then("LLM Client를 호출하지만, LLM이 아닌 Rule Based를 통한 스코어링 결과가 나온다.") {
                    verify { mockLLMClient.request(any(), any()) }
                    result.shouldBeInstanceOf<ContentResultByRuleBased>()
                }
            }
        }
    
        Given("LLM 최소 조건에 충족하지 않는 경우") {
            val content = ContentFixture.sample(text = shortText)
    
            When("calculateScore") {
                val result = scorer.calculateScore(review)
    
                Then("Rule Based를 통한 스코어링 결과가 나온다.") {
                    result.shouldBeInstanceOf<ContentResultByRuleBased>()
                }
            }
        }
    })

    개선할 점들

    LLM 요청 시에만 모킹 설정 이동

    현재 코드에서는 모든 테스트 케이스에서 공통적으로 mockCategoryClient.listCategoriesByIds() 모킹을 설정하고 있지만, 실제로는 LLM 최소 조건을 충족하는 경우에만 필요한 설정이다.
    따라서, 불필요한 모킹을 줄이기 위해 LLM 요청이 필요한 테스트 케이스로 모킹을 옮겨 개선할 수 있다.
    게다가 listCategoriesByIds()의 결과는 LLM input 용도로만 사용되므로, 테스트의 주요 논리에는 영향을 미치지 않기 때문에 가볍게 설정할 수 있다.

     

    테스트 코드 가시성 개선을 위한 구조 재편

    현재 테스트 코드에서는 다음 3가지 케이스로 분리되어 있다.

    • Given("LLM 최소 조건에 충족하고, LLM 응답에 성공하는 경우")
    • Given("LLM 최소 조건에 충족하고, LLM 응답에 실패하는 경우")
    • Given("LLM 최소 조건에 충족하지 않는 경우")

     

    이 중, LLM 최소 조건에 충족하는 경우를 하나의 Given으로 그룹화하고, 그 하위에 When(LLM 응답 성공)When(LLM 응답 실패)를 배치하는 트리 구조를 활용하면 테스트 코드의 가독성과 유지보수성이 더 좋아질 것으로 보인다.

    // example
    Given("LLM 최소 조건에 충족하는 경우”) {
        When("LLM 응답이 성공하는 경우") {
            // 성공 케이스
        }
        When("LLM 응답이 실패하는 경우") {
            // 실패 케이스
        }
    }

     

    모킹 초기화 추가

    Given("LLM 최소 조건에 충족하지 않는 경우")에서 mockLLMClient.request 호출 여부를 검증할 때, 모킹 초기화가 누락되어 있다.
    이로 인해 verify(exactly=0) { mockLLMClient.request(any(), any()) } 검증이 실패할 수 있다. 이전 테스트 케이스에서 이미 mockLLMClient가 호출된 기록이 남아있기 때문이다. 따라서, 매 테스트 후 모킹을 초기화하여 각 테스트가 독립적으로 동작하도록 개선할 예정이다.

     

     

    TO-BE

    개선한 점들

    LLM 요청 조건에 따른 모킹 최적화

    현재 코드에서는 모든 테스트 케이스에서 공통적으로 mockCategoryClient.listCategoriesByIds() 모킹을 설정하고 있지만, 실제로는 LLM 최소 조건을 충족하는 경우에만 필요한 설정이다.

    따라서, 불필요한 모킹을 줄이기 위해 LLM 요청이 필요한 테스트 케이스로 모킹을 옮겨 개선할 수 있다.

    게다가 listCategoriesByIds()의 결과는 LLM input 용도로만 사용되므로, 테스트의 주요 논리에는 영향을 미치지 않기 때문에 가볍게 설정할 수 있다.

     

    mock 객체를 선언할 때 relaxed 설정을 추가해, 다음과 같이 모킹 코드를 생략할 수 있다.

    val mockCategoryClient = mockk<CategoryClient>(relaxed = true)

     

    이렇게 relaxed = true 설정을 사용하면, 반환값을 명시하지 않아도 기본값이 자동으로 반환되어 테스트 코드가 더 간결해진다.

     

    테스트 코드 가독성 개선을 위한 구조 재편

    현재 테스트 코드에서는 다음 3가지 케이스로 분리되어 있다.


    - Given("LLM 최소 조건에 충족하고, LLM 응답에 성공하는 경우")
    - Given("LLM 최소 조건에 충족하고, LLM 응답에 실패하는 경우")
    - Given("LLM 최소 조건에 충족하지 않는 경우")

    이 중, LLM 최소 조건에 충족하는 경우를 하나의 Given으로 그룹화하고, 그 하위에 When(LLM 응답 성공)과 When(LLM 응답 실패)를 배치하는 트리 구조를 활용하면 테스트 코드의 가독성과 유지보수성이 더 좋아질 것으로 보인다.

     

    다음과 같이 LLM 최소 조건에 충족하는 경우 / LLM 최소 조건에 충족하지 않는 경우 로 나누었다.

    이를 통해 중복으로 작성되었던 설정을 하나로 합치고, 각 조건을 더욱 명확하게 확인할 수 있게 되었다.

     

    AS-IS

    Given("LLM 최소 조건에 충족하고, LLM 응답에 성공하는 경우") {
            val content = ContentFixture.sample(
                text = longText,
                categoryId = targetCategoryId,
            )
            
            every { mockCategoryClient.listCategoriesByIds(any<List<Int>>()) } returns listOf(
                Category.emptyCategory(targetCategoryId),
            )
    
            ...
        }
    
        Given("LLM 최소 조건에 충족하고, LLM 응답에 실패하는 경우") {
            val content = ContentFixture.sample(
                text = longText,
                resourceCategoryId = targetCategoryId,
            )
    
            every { mockCategoryClient.listCategoriesByIds(any<List<Int>>()) } returns listOf(
                Category.emptyCategory(targetCategoryId),
            )
    
            ...
        }

     

    TO-BE

    Given("LLM 최소 조건에 충족하는 경우") {
            val longText = "아아아아아아주 좋습니다아아아아아아아"
    
            val content = ContentFixture.sample(
                text = longText,
            )
    
           ...
        }

     

    모킹 초기화 추가

    Given("LLM 최소 조건에 충족하지 않는 경우")에서 mockLLMClient.request 호출 여부를 검증할 때, 모킹 초기화가 누락되어 있다.

    이로 인해 verify(exactly=0) { mockLLMClient.request(any(), any()) } 검증이 실패할 수 있다. 이전 테스트 케이스에서 이미 mockLLMClient가 호출된 기록이 남아있기 때문이다.

     

    매 테스트가 끝난 후 afterEach 블록에서 clearAllMocks()를 호출하여 모든 모킹 설정을 초기화했다. 이렇게 설정함으로써 각 테스트가 독립적으로 동작하게 되어, 이전 테스트 설정이 이후 테스트에 영향을 미치지 않도록 했다.

     

     afterEach {
          clearAllMocks()
      }
    

    clearAllMocks()는 모든 mock 객체를 초기화하므로, 매 테스트마다 완전히 초기화된 상태에서 실행할 수 있다.

    추가로, verify(exactly = 0) { mockLLMRestClient.request(any(), any()) } 를 통해 LLM 최소 조건을 충족하지 않는 경우에는 mockLLMClient 가 호출되지 않는다는 점을 명확히 검증하도록 추가했다.

        Given("isNotEligibleForLLMScoring") {
            val reviewShortText = "아주 좋습니다"
    
            val review =
                TestFixtures.ReviewFixture.sampleReview(content = reviewShortText)
    
            When("calculateScore") {
                val result = qualityScorer.calculateScore(review)
    
                Then("Rule Based를 통한 스코어링 결과가 나온다.") {
                    verify(exactly = 0) { mockLLMRestClient.request(any(), any()) }
                    result.reviewTextAnalysis.shouldBeInstanceOf<ReviewQualityScorer.ReviewQualityScoreWrapper.ReviewTextAnalysisByRuleBased>()
                }
            }
        }

     

     

    TO-BE: 전체 테스트 코드
    더보기

     

    class ContentScorerTest : BehaviorSpec({
        val mockContentRepository = mockk<ContentRepository>(relaxed = true)
        val mockCategoryClient = mockk<CategoryClient>(relaxed = true)
        val mockLLMClient = mockk<LLMClient>()
    
        val scorer = ContentScorerImpl(mockContentRepository, mockLLMClient, mockCategoryClient)
    
        Given("LLM 최소 조건에 충족하는 경우") {
            val longText = "아아아아아아주 좋습니다아아아아아아아"
    
            val content = ContentFixture.sample(
                text = longText,
            )
    
            When("LLM 응답에 성공하면") {
                every {
                    mockLLMRestClient.request(LLMProject.SAMPLE, any())
                } returns mapOf<String, Any>(
                    "example" to false,
                )
    
                val result = scorer.calculateScore(review)
    
                Then("LLM을 통한 스코어링 결과가 나온다.") {
                    verify(exactly = 1) { mockLLMRestClient.request(any(), any()) }
                    result.shouldBeInstanceOf<ContentResultByLLM>()
                }
            }
    
            When("LLM 응답에 실패하면") {
                every {
                    mockLLMClient.request(
                        project = LLMProject.SAMPLE,
                        variables = any(),
                    )
                } returns null
    
                val result = scorer.calculateScore(content)
    
                Then("LLM Client를 호출하지만, LLM이 아닌 Rule Based를 통한 스코어링 결과가 나온다.") {
                    verify(exactly = 1) { mockLLMClient.request(any(), any()) }
                    result.shouldBeInstanceOf<ContentResultByRuleBased>()
                }
            }
        }
    
        Given("LLM 최소 조건에 충족하지 않는 경우") {
            val reviewShortText = "아주 좋습니다"
    
            val content =
                ContnetFixture.sample(text = shortText)
    
            When("calculateScore") {
                val result = qualityScorer.calculateScore(content)
    
                Then("Rule Based를 통한 스코어링 결과가 나온다.") {
                    verify(exactly = 0) { mockLLMClient.request(any(), any()) }
                    result.shouldBeInstanceOf<ContentResultByRuleBased>()
                }
            }
        }
    
        afterEach {
            clearAllMocks()
        }
    })

     


    개선하면서 겪었던 이슈 - mocking 리셋 방식의 차이

    초기에 clearAllMocks()를 afterEach 대신 beforeTest에 설정했을 때, 테스트 코드가 지속적으로 실패하는 문제가 발생했다. 실패 메시지는 아래와 같았다.

     

    Verification failed: call 1 of 1: LLMClient(#3).request(any(), any())) was not called
    

    Kotest 공식 문서를 참고한 결과, beforeTest, afterTest와 beforeEach, afterEach의 차이로 인한 이슈임을 알았다.

     

    우선 이 차이점을 이해하기 위해 Kotest에서의 Test ContainerTest Case의 개념을 알아보자.

    • Test Container: Given, When처럼 다른 테스트를 포함하는 테스트 노드이다.
    • Test Case: Then처럼 리프 노드에 해당하는 테스트로, 구체적인 로직과 검증을 포함한다.

     

    콜백의 차이점

    • beforeTest, afterTest: 모든 Test Container와 Test Case에 대해 콜백을 실행한다. (Given, When, Then 전체에 대해 실행)
    • beforeEach, afterEach: 오직 Test Case에 대해서만 콜백을 실행한다. ( Then에 해당)

    이제 다시 실패한 이슈에 대해 다시 파악해보자.

     

    실패 원인 분석 (기존에 beforeTest로 clearAllMocks()을 수행하면 왜 실패하는가?)
    beforeTest에서 clearAllMocks()를 수행할 경우, Test Container(Given, When)가 실행되기 전에도 모킹이 초기화된다. 이에 따라 Then에서 검증해야 하는 모킹 설정이 사전에 초기화되어, 결과적으로 테스트가 실패하게 되는 것이다.

     

    해결 방법
    이 문제는 beforeTest 대신 afterTest에서 clearAllMocks()를 호출하여 해결할 수 있었다. 각 테스트 케이스가 완료된 후 모킹을 초기화하기 때문에, Then의 검증까지 정상적으로 실행된다.
    이에 따라, 작성한 테스트에서는 Then 실행 후에만 clearAllMocks()가 필요하므로, afterEach에 설정하는 것으로 수정했다.

     


    Kotest 문서에서 제안하는 두 가지 모킹 초기화 방식

    Kotest 공식 문서에서는 다음 두 가지 방식의 모킹 초기화를 제안하고 있다.

    1. setup mocks before tests
    테스트 시작 전에 lateinit 변수를 사용해 객체를 초기화하는 방식이다. 이렇게 하면 각 테스트에서 필요한 모킹을 미리 설정해 두고 사용할 수 있다.

        lateinit var repository: MyRepository
        lateinit var target: MyService
    
        beforeTest {
            repository = mockk()
            target = MyService(repository)
        }
    



    2. reset mocks after tests (사용한 방식)

    각 테스트가 완료된 후 clearAllMocks()를 통해 모든 모킹 객체를 초기화하는 방식이다. 이 방법은 이전 테스트 상태가 다음 테스트에 영향을 미치지 않도록 보장한다.

    afterEach {
        clearAllMocks()
    }
    

     


    테스트 코드 개선 과정을 통해 Kotest에서의 모킹 초기화 방식과 Test Container/Test Case의 콜백 실행 방식을 이해하게 됐다. 몰랐던 개념들도 알게 되었고, 좋은 가시성을 위해 어떤 방향으로 테스트를 작성하면 좋은지 알게 된 것 같다! 👍

    728x90
    반응형

    댓글

Designed by Tistory.