본문 바로가기
Develop/Kotlin

[Kotest] 오버로딩한 메서드 테스트하기 (feat: slot)

by 연로그 2023. 3. 5.
반응형

현재 우리 팀에서는 모종의 이유로 애플리케이션 코드는 Java, 테스트 코드는 Kotest로 작성하고 있다. 오늘은 테스트 코드를 작성하다 겪은 일에 대해 작성할 예정이다. (예제 코드는 문제 상황과 유사하게 만든 코드일 뿐, 실무 코드와는 관련이 없습니다.)

 

자바에서는 한 클래스 내에서 이름이 같은 메서드를 중복으로 정의할 수 없다. 하지만 '메서드 오버로딩'을 통해 매개변수의 개수나 타입 등을 다르게 하면 같은 이름의 메서드를 작성하는 것이 가능하다. 메소드 내부 구현은 상황에 따라 달라지겠지만 오늘은 아래와 같은 코드에 대한 테스트 코드를 작성하려고 한다.

 

public class Order {

	public void order(OrderRequest orderRequest) {
		order(orderRequest, null);
	}

	public void order(OrderRequest orderRequest, Long userId) {
		OrderValidator.validate(orderRequest, userId);
		// 주문 로직 실행
	}
}

 


첫 번째 고민 - 테스트 코드가 필요할까?

order(orderRequest)는 order(orderRequest, userId)를 호출하기만 하고 그 외의 로직은 전혀 없다. 실질적인 로직은 order(orderRequest, userId) 메서드에 전부 존재한다. 여기서 첫 번째 고민이 생긴다. order(orderRequest)에 대한 테스트 코드가 꼭 필요할까? 개인적으로는 필수가 아닌 선택이라고 생각한다. 테스트 코드를 작성한다면 추후에 order(orderRequest)의 로직이 변경될 때 좀 더 안심하고 로직을 변경할 수 있다. 반대로 테스트 코드를 작성하지 않더라도 실질적인 로직에 대한 테스트는 order(orderRequest, userId)에서 모두 검증했으니 충분하다는 생각도 든다. 하지만 나는 좀 더 꼼꼼한 테스트 케이스 작성과 우리 팀의 테스트 커버리지를 위해 테스트 코드를 작성하기로 마음 먹는다.

 

 


두 번째 고민 - 무엇을 테스트할까?

 

주문 로직에 대한 테스트는 실제 로직이 존재하는 order(orderRequest, userId)의 테스트에서 이미 충분하다고 생각한다. 그렇다면 order(orderRequest, userId)를 호출하기만 하는 order(orderRequest)에서는 무엇을 테스트 해야할까? 여기서 두 가지 정도의 아이디어가 떠올랐다.

 

💡 idea 1. 테스트 코드 중복으로 작성하기

order(orderRequest, userId)에서 userId가 null이라면 어떻게 동작하는지 테스트 코드 작성하기. 이미 order(orderRequest, userId)의 테스트에서 검증한 부분이지만 테스트 코드를 중복으로 작성하면 된다. 이렇게 되면 정말 꼼꼼한 테스트 코드를 짤 수 있다. 하지만 order(orderRequest, userId)의 로직이 변경된다면 변경해야하는 테스트 코드가 불필요하게 많아진다는 느낌이 들었다. order(orderRequest, userId)와는 별개로 order(orderRequest)의 동작만을 검증하고 싶다고 생각했다.

 

 

💡 idea 2. null 값이 넘어가는지 검증하기

order(orderRequest)을 호출할 때 order(orderRequest, userId)의 userId 파라미터가 'null'로 잘 넘어갔는지 검증한다. order(orderRequest) 메서드 내부의 코드만 검증한다는 점이 마음에 들어서 해당 방법을 선택했다.

 

 

위 의견에 대한 견해는 사람마다 생각이 다를 것 같다. 동일 클래스의 내부 메서드를 호출한 것이니 중복이더라도 테스트 코드를 작성하는게 맞다는 사람들도 존재할 것이다. 하지만 나는 더이상 order(orderRequest, userId)를 호출하지 않는다던가 추가적인 로직이 생긴다던가 등등 로직에 변화가 생기면 그때 가서 테스트 코드를 추가/변경해도 충분하다고 판단했다.

 

 


세 번째 고민 - 어떻게 테스트할까?

 

💡 idea 1. verify 이용하기

이제 본격적으로 테스트 코드를 작성할 시간이다. 처음에는 간단하게 목 객체를 통해 order(orderRequest, null)이 호출되는지 verify를 할까 고민했다.

class OrderTest : ShouldSpec({
    context("주문 시") {
        context("userId를 인자로 받지 않는 경우") {
            should("userId 값이 null인 상태로 주문 로직이 실행된다.") {
                val order = mockk<Order>()
                order.order(OrderRequest())
                verify { order.order(any(), null) }
            }
        }
    }
})

 

하지만 verify를 사용하기 위해서는 Order 객체를 모킹해야한다. Order의 메서드를 검증해야하는데 Order를 모킹해야하는 기묘한 상황이 온 것이다.  위 테스트 코드는 당연하게도 MockkException이 발생한다.

 

 

💡 idea 2. slot 이용하기

이 상황을 팀원들한테 알리고 도움을 구했는데 한 팀원 분이 아래와 같은 조언을 주셨다.

🧑‍🌾: Long userId가 받아지는 쪽을 captor로 잡아서, 해당 값이 null이 들어왔는지 테스트할 것 같아요. kotest에서는 slot입니다!

 

capture, slot이라는 키워드를 모두 처음 들어봐서 잠깐 예제를 찾아보았다. capture, slot은 Mockk에서 지원하는 기능 중 하나로 모킹한 객체의 인자로 slot을 넘기면 slot의 captured 속성을 통해 인자로 어떤 값이 넘어갔는지 확인할 수 있는 것 같다. 아래 예제는 Baeldung에서 가져온 코드로 잠깐 살펴보면 충분히 이해할 수 있을 것이다.

 

@Test
fun givenMock_whenCapturingParamValue_thenProperValueShouldBeCaptured() {
    // given
    val service = mockk<TestableService>()
    val slot = slot<String>()
    every { service.getDataFromDb(capture(slot)) } returns "Expected Output"
 
    // when
    service.getDataFromDb("Expected Param")
 
    // then
    assertEquals("Expected Param", slot.captured)
}

 

여기서 문제는 모킹한 객체에 대한 인자를 확인할 수 있다는 것인데 나는 실제 객체의 내부 메서드에 넘어가는 인자를 확인해야한다. 해당 객체를 모킹할 수 없기 때문에 약간의 타협을 봤다. 여기서 order(orderRequest, userId) 코드를 잠깐 다시 보면 인자로 넘어가는 값을 OrderValidator를 통해 검증하는 것을 확인할 수 있다. 나는 이 OrderValidator를 이용해 userId가 실제로 null 값이 넘어오는지 검증하기로 했다. 내가 생각하는 이상적인 테스트 코드는 아니지만 내 의도를 위해 어쩔 수 없는 타협이었다.🥲

 

 아래와 같은 테스트 코드를 작성하고 실행했더니 오류가 발생했다.

class OrderTest : ShouldSpec({

    val order = Order()

    context("주문 시") {
        context("userId를 인자로 받지 않는 경우") {
            mockkStatic(OrderValidator::class)
            
            should("userId 값이 null인 상태로 주문 로직이 실행된다.") {
                val slot = slot<Long>()
                every { OrderValidator.validate(any(), capture(slot)) } just Runs

                order.order(OrderRequest())

                slot.captured.shouldBeNull()
            }
        }
    }
})

kotlin.UninitializedPropertyAccessException: lateinit property captured has not been initialized

 

slot에 캡쳐된 값이 없어서 발생하는 문제이다. 하나하나 디버그를 해보면 order(orderRequest) -> order(orderRequest, userId) -> OrderValidator.validate(orderRequest, userId) 순으로 동작된다. 인자로 넣은 값들도 내가 의도한 값이랑 일치한다. 근데 왜 테스트에서는 예외가 발생하는걸까? 이 부분에 대해서는 친구와의 대화를 통해 알게 되었는데 코틀린은 null 값을 넣으려면 타입 뒤에 ?를 붙여야한다고 한다.

😱

 

userId라는 인자에 null을 넣으면 모킹한 메서드가 아닌 실제 메서드를 호출한다. 내가 모킹한 메서드는 인자를 null로 받을 수 없으니 모킹한 메서드가 아닌 실제 메서드를 호출하는 것이다. 모킹한 메서드가 호출된 적이 없으므로 당연히 capture도 안된다. 인자를 null로 넣어도 인식할 수 있게 ?를 붙여보려했으나 아래와 같은 오류가 또 발생했다. 이 현상에 대해서는 누군가 mockk 레포의 issue에서 개선을 요구했지만 우선순위가 밀린건지 해결된 것 같지는 않다. (관련 정보를 아시는 분들은 댓글 부탁드립니다ㅜㅜ)

 

여기서 목표를 다시 생각해보자. 내 목표는 slot을 이용하는 것이 아닌 인자로 null이 들어왔는지 확인하는 것이다. 그래서 idea 1로 돌아가기로 마음 먹었다. slot을 제거하고 verify를 이용하니 코드도 훨씬 깔끔해보였다.

class OrderTest : ShouldSpec({

    val order = Order()

    context("주문 시") {
        context("userId를 인자로 받지 않는 경우") {
            should("userId 값이 null인 상태로 주문 로직이 실행된다.") {
                mockkStatic(OrderValidator::class)
                every { OrderValidator.validate(any(), any()) } just Runs
                
                order.order(OrderRequest())
                
                verify { OrderValidator.validate(any(), null) }
            }
        }
    }
})

성공한 테스트

 

 

드디어 테스트를 성공했다! 단 한 줄을 테스트 하는데 많은 고민을 하고, slot의 존재에 대해 알게 되었으며, Kotlin에서 null을 넣기 위해서는 ?를 명시해줘야한다는 사실까지 알게 된 보람찬 시간이었다. 조언 주신 팀원들, 친구들 모두 감사합니다😭


참고

반응형