ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Kotest, Mockk, JUnit] 테스트 중 자주하는 착각 모음
    웹 개발/Spring Framework 2024. 3. 12. 00:27
    업무 중에 테스트 코드를 작성하다가 착각해서 잘못 사용해뒀던 것들을 정리하기.

     
    알아두면 테스트 코드 작성하다 왜 안되지? 왜 이상하지하는 시간을 줄여줄 것이다! 별거 아닌 것처럼 보이는지만 원인을 알아채는데도 시간을 꽤 썼다.
     

     

    1. Mockk 사용 시, Mocking 하지 않았는데, Mock 함수를 호출한다.

    실제 인스턴스를 생성하더라도 mockk 기능을 쓰면 해당 객체의 함수 리턴 결과를 조작할 수 있을거라 생각하지만 그렇지 않다. mock 처리된 객체만 mockk 기능이 동작한다. 
     

    착각하는 예시

    // 클래스
    class Confusion {
        fun getHello(language: String): String {
            return when(language) {
                "kr" -> "안녕"
                else -> "Hello"
            }
        }
    }
    
    // 테스트
    @SpringBootTest
    class ConfusionTest: BehaviorSpec({
    
        Given("각 나라에 맞춰 인삿말을 가져온다") {
            val confusion = Confusion()
            every {
                confusion.getHello("kr")
            } returns "안녕"
    
            When("Hello 함수 호출") {
                val result = confusion.getHello("kr")
    
                Then("Hello를 반환 받는다") {
                    result shouldBe "안녕"
                }
            }
        }
    })

     

    에러 발생

     
    수정한 예시

    @SpringBootTest
    class ConfusionTest: BehaviorSpec({
    
        Given("각 나라에 맞춰 인삿말을 가져온다") {
        	// mocking 처리해준다
            val confusion = mockk<Confusion>()
            every {
                confusion.getHello("kr")
            } returns "안녕"
    
            When("Hello 함수 호출") {
                val result = confusion.getHello("kr")
    
                Then("Hello를 반환 받는다") {
                    result shouldBe "안녕"
                }
            }
        }
    })

     

    성공

     
    간단한 예시로 해서 그렇지만, 실제 인스턴스와 mock 객체를 혼용하면서 테스트 코드를 쓸 경우 이런 경우가 생길 수 있다. 또, 실제 인스턴스는 mockk 기능을 쓸 수 없단걸 모르면 계속 헤맬 수 있다.


    2. kotlinfixture 사용시,찐으로 랜덤한 Number를 만들어내기 위해 범위를 굉장히 넓게 잡는다.

    이 부분은 인텔리J에서 제공하는 profiler로  찾아냈다. 전체 테스트를 돌릴 때마다 OOM이 떨어지니 정확히 어디서 발생하는지 알기 어려웠는데 kotlinfixture로 큰 범위를 만들어내는 부분에서 발생한다는 걸 찾았다.
     
    kotlinfixture에서 랜덤한 숫자를 뽑을 때 뭔가 마법 같이 랜덤한 숫자를 바로 꼽는게 아니다. 정해진 범위만큼의 리스트는 인스턴스화 시키고 거기서 랜덤하게 뽑는다. 결국 범위를 아주 크게 잡으면 OOM이 떨어질 수 있다는 것이다.
     
    보통 로컬 PC(개발자 본인 PC)는 메모리가 짱짱하고 CPU 성능 좋은 맥북이 보통이라 로컬에서 테스트 돌릴 때는 알아채지 못하고, 젠킨스처럼 CI를 해주는 (맥북보다 성능이 떨어지는)서버에서 테스트가 돌다가 깨져버린다. 그래서 자신의 PC에서는 잘 되는데 뭐가 문제인지 헤맬 수 있다.
     
    문제발생 예시

    class ConfusionTest: BehaviorSpec({
    
        val fixture = kotlinFixture()
    
        Given("모든 숫자를 더한다") {
            val input = (1..100).map {
                fixture(1..100_000_000_000)
            }
    
            When("sum 함수 호출") {
                val result = input.sum()
    
                Then("총 합을 반환받는다") {
                    result shouldBe input.reduce { acc, i ->  acc + i }
                }
            }
        }
    })

     

    OOM 발생

     
    kotlinfixture(1..100_000_000_000)처럼 범위내에서 random한 숫자를 만들 때, 내부적으로 아래의 함수를 호출한다.

     
    toMutableList에서 range만큼 리스트를 메모리에 올리는데 이 때 큰 범위의 리스트가 여러개 생기다보니 OOM이 발생한다.
    OOM은 안 나더라도 범위는 적당히 잡아주는게 테스트 성능에 영향을 주지않을 것이다.


    3. @MockkBean 또는 @MockkBean을 사용하더라도 Spring Container는 한번만 띄워질 것이다.

    @MockBean 또는 @MockkBean을 사용할 경우, ApplicationContext를 재활용하지 못하고 Spring Container는 매번 reloading된다.
     
    그래서 이럴 경우, mock 객체를 만들어서 Bean으로 등록해서 주입받거나, 테스트 하는 클래스에서 mock 객체를 직접 만들어서 사용하는게 낫다. (@MockkBean, @MockBean은 그냥 쓰지 않는게 편하다)
     
    그 이유는 여기서 설명하고 있다. 
     
    주된 이유는 이러하다.

    The Spring test framework will cache an ApplicationContext whenever possible between test runs.
    In order to be cached, the context must have an exactly equivalent configuration.
    Whenever you use @MockBean, you are by definition changing the context configuration.

    스프링 테스트 프레임워크는 테스트가 돌아가면서 ApplicationContext를 캐싱할 것이다.
    캐시하기 위해서는 context가 반드시 동일한 configuration을 가져야한다. 
    @MockBean을 사용할때마다, context의 configuration은 변할 것이다.

     
    그래서 @MockBean을 만날때마다 캐싱을 하지 못하고 reloading 하는 것이다.
     
     
    문제발생 예시

    @Service
    class ConfusionService(
        private val dependencyService: DependencyService,
        private val otherDependencyService: OtherDependencyService,
    ) {
        fun hello(): String {
            dependencyService.doSomething()
            otherDependencyService.doSomething()
            return "Hello"
        }
    }
    
    @Service
    class DependencyService {
        fun doSomething() {
            println("do something by dependency")
        }
    }
    
    @Service
    class OtherDependencyService {
        fun doSomething() {
            println("do something by otherDependency")
        }
    }

     

    @SpringBootTest
    class ConfusionTest(
        @MockkBean
        private val dependencyService: DependencyService,
        private val confusionService: ConfusionService
    ) : BehaviorSpec() {
    
        init {
            Given("MockkBean 사용 시, ApplicationContext를 캐싱하지 않는지 테스트 한다") {
                every {
                    dependencyService.doSomething()
                } just Runs
                
                When("함수 호출") {
                    println(confusionService.hello())
    
                    Then("캐싱하지 않는다")
                }
            }
        }
    }
    
    
    @SpringBootTest
    class OtherConfusionTest(
        @MockkBean
        private val otherDependencyService: OtherDependencyService,
        private val confusionService: ConfusionService
    ): BehaviorSpec(){
    
        init {
            Given("MockkBean 사용 시, ApplicationContext를 캐싱하지 않는지 테스트 한다") {
                every {
                    otherDependencyService.doSomething()
                } just Runs
                
                When("함수 호출") {
                    println(confusionService.hello())
    
                    Then("캐싱하지 않는다")
                }
            }
        }
    }

     
    이 테스트들을 돌리면 어떻게 될까?

    $ ./gradlew -i clean test

     
    위 커맨드를 날리게 되면, Spring Container가 두 번 뜬 걸 볼 수 있다.

     
    수정한 예시
    외부로 통신해야할 의존성만 mocking해서 사용하는 편이다. 위 예시에서 ConfusionTest는 OtherDependencyService가 외부 api로 요청하는 서비스이고, OtherConfusionTest는 DependencyService가 외부 api로 요청하는 서비스라 mocking한다고 가정해보자.
     

    @SpringBootTest
    class ConfusionTest(
        dependencyService: DependencyService,
    ) : BehaviorSpec() {
        private val otherDependencyService = mockk<OtherDependencyService>()
    
        private val confusionService = ConfusionService(dependencyService, otherDependencyService)
    
        init {
            Given("MockkBean 사용 시, ApplicationContext를 캐싱하지 않는지 테스트 한다") {
                every {
                    otherDependencyService.doSomething()
                } just Runs
                When("함수 호출") {
                    println(confusionService.hello())
    
                    Then("캐싱하지 않는다")
                }
            }
        }
    }
    
    
    @SpringBootTest
    class OtherConfusionTest(
        otherDependencyService: OtherDependencyService,
    ): BehaviorSpec(){
        private val dependencyService = mockk<DependencyService>()
    
        private val confusionService = ConfusionService(dependencyService, otherDependencyService)
    
        init {
            Given("MockkBean 사용 시, ApplicationContext를 캐싱하지 않는지 테스트 한다") {
                every {
                    dependencyService.doSomething()
                } just Runs
    
                When("함수 호출") {
                    println(confusionService.hello())
    
                    Then("캐싱하지 않는다")
                }
            }
        }
    }

     

     
    Spring Container가 딱 한 번만 뜬 것을 볼 수 있다.

Designed by Tistory.