티스토리 뷰

kotlin

mockk 의 mocking 방식

LichKing 2022. 12. 24. 15:51

# 이슈

코틀린에서 테스트 작성시 mock 객체가 필요하면 mockk 를 사용하고 있다. 아마도 가장 대중적인 mock 프레임워크 아닐까 싶다.
얼마전 mockk 를 이용해 테스트를 작성하다가 동료 개발자와 신기한 현상을 발견했는데 간략화하면 아래코드와 같다.

class Simple1 {
    fun simple1(): String {
        return "hello world"
    }
}

class Simple2(
    private val simple1: Simple1,
) {
    fun simple2(): String {
        return simple1.simple1()
    }
}

class MockkApplicationTests {

    @Test
    fun mocking() {
        val simple1 = mockk<Simple1>()
        val simple2 = Simple2(simple1)

        every {
            simple2.simple2()
        } returns "goodbye world"

        assertThat(simple2.simple2()).isEqualTo("goodbye world")
    }

}

테스트에는 2개의 클래스가 필요하다. 예제는 Simple1, Simple2 클래스가 있는데 Simple1 은 mockk 를 이용하여 만들고 Simple2 는 생성자를 이용하여 정상적인 객체를 만들었다. 그리고 메서드에 대한 가정은 Simple2#simple2 에 정의했다.

뭔가 이상하지 않은가? every 메서드 내에 있는 mocking 객체가 mockk 를 이용해 생성한 simple1 이 아니라 실제 생성자를 이용해 생성한 simple2 이다! 이 코드는 어떻게 동작할까? 한 번 상상해보고 아래로 내려가자


코드를 처음 봤을때 나의 예상 동작은 두가지 중 하나 아닐까 생각했고, 내가 생각했던 두가지는

- simple2 에 mocking 을 진행하다가 mock 객체가 아니라는 예외 발생
- every 내에서 예외는 발생하지 않지만 assertThat 내에서 Simple2#simple2 를 실행하다가 simple1 이 mocking 되어있지 않다는 예외 발생

어쨋든 예외가 발생할거라 생각했지만 위 코드는 예외를 발생시키지 않는다. 그럼 테스트는 실패할까? 놀랍게도 테스트는 성공한다.

# 분석

어떻게 이게 가능할까? 처음에는 simple2 에 어떻게 mocking 을 하는건지 의아했다. 하지만 simple2 를 mocking 한다는 내 생각은 금방 아님을 알 수 있었다.

@Test                                                                     
fun mocking() {                                                          
    val simple1 = Simple1() // Simple1 을 mock 이 아니라 정상 생성하면 동작하지 않음       
    val simple2 = Simple2(simple1)                                        
                                                                          
    every {                                                                
        simple2.simple2()                                                 
    } returns "goodbye world"                                             
                                                                          
    assertThat(simple2.simple2()).isEqualTo("goodbye world")              
}

Simple1 을 mock 생성이 아니라 정상 생성하면 테스트가 깨지기 때문이었다. 그럼 첫번째 코드는 어떻게 동작하는걸까? 그 다음 내가 생각했던 가설은 이랬다.

- every 에서 mocking 을 진행할때 메서드의 객체구조를 따라올라가 mock 객체가 있으면 해당 객체에 mocking 진행

이 가설이 맞는지를 검증하기 위해 처음에는 코드를 분석하려 했으나 프레임워크의 복잡한 구현코드를 하나하나 분석하는건 어려움이 있었고, mockk 를 디버깅하기 시작했다. 그래서 대강 Simple1 에 mocking 을 시도하는 코드는 찾을 수 있었다. 하지만 mockk 내에서 디버깅을 시도할때 그 어디서도 정상 객체로 만드는 Simple2 는 찾을 수 없었다.

- every 내에 명시적으로 지정된 Simple2 를 캐치
- Simple2 의 인스턴스가 mock 객체가 아님을 판별하고 내부를 탐색해 Simple1 을 캐치
- Simple1 의 인스턴스에 mocking

내 가설이 맞는지를 검증하려면 위의 플로우대로 진행되어야 하는데, Simple1 을 이용하는 코드는 찾았는데 아무리 디버깅을 돌려도 Simple2 의 흔적을 찾을 수가 없었다. 그래서 더 시간을 투자해 계속 원인을 찾기 시작했다.

# 결과

사실 이걸 찾아낸다고 특별히 내가 하는 개발에 도움될건 없었기에 그냥 넘어가도 무방했으나... 너무 궁금해서 계속 찾아봤고 그 원인을 찾을 수 있었다.

mock 객체란 실제 객체의 행동을 중간에 가로채는 객체를 말한다. 코틀린을 이용하지 않아도 proxy pattern 이나 spring AOP, JPA lazy loading 등을 통해 이미 우리에게 익숙한 개념이다. 이를 구현하는 방법은 보통 두가지가 거론되는데 상속을 이용한 방법과 바이트 코드를 조작하는 방법이다. 자바에서는 보통 상속을 이용하는 방법을 많이 이용하는데 이 때문에 기본적으로 상속을 막는 코틀린에서 이슈가 되기도 한다( https://multifrontgarden.tistory.com/283 ).

mockk 는 상속을 이용하지 않고, 바이트 코드를 조작하여 기존 클래스에 mock 코드를 주입하는 방식으로 mock 객체를 만들게 된다. 이 mock 객체는 런타임에 만들고 사라지기 때문에 그냥 코드를 작성할땐 확인할 수 없는데, system property 를 설정하면 이걸로 만들어지는 코드를 확인할 수 있다.

tasks.withType<Test> {
    useJUnitPlatform()
    systemProperty("io.mockk.classdump.path", "output")
}

위 설정을 하면 mockk 가 만드는 Simple1 을 확인할 수 있다.

public final class Simple1 {
   public final String simple1() {
      Callable var10000;
      label40: {
         // HashMap 이 아니니까 이 if 문은 신경 쓸 필요 없다.
         if (this.getClass() == HashMap.class) {
            if ((new Object[0]).length == 1 && (new Object[0])[0] == HashMap.class) {
               var10000 = null;
               break label40;
            }

            if ((new Object[0]).length == 2 && (new Object[0])[1] == HashMap.class) {
               var10000 = null;
               break label40;
            }
         }

         // 이 부분이 핵심이다.
         JvmMockKDispatcher var1 = JvmMockKDispatcher.get(-3984210221739740466L, this);
         var10000 = var1 != null && var1.isMock(this) ? var1.handler(this, Simple1.class.getMethod("simple1"), new Object[0]) : null;
      }

      Callable var4 = var10000;
      String var2 = var4 != null ? null : "hello world";
      if (var4 != null) {
         var2 = (String)var4.call();
      }

      return var2;
   }
}

JvmMockKDispatcher 를 이용하는 2 라인이 핵심인데 무언가 commander 가 있어서 계층구조를 분석하고, mocking 을 시도하는게 아니라 mock 객체가 주입된 바이트코드로 인해 스스로 mocking 을 하게된다.

every {                    
    simple2.simple2()     
} returns "goodbye world"

mockk 내에서 every 에 람다로 전달된 함수를 실행하게되는데 simple2 를 실행하면 simple2 는 실제 객체기 때문에 내부 구현대로 simple1 을 호출하게되고, simple1 이 호출되면 그때 내부에 바이트코드로 주입된 JvmMockKDispatcher 에 의해 mocking 이 진행되는 것이다. commander 객체가 Simple2 인스턴스를 찾고, 리플렉션을 이용해 Simple1 을 찾는 형태가 아니기 때문에 mockk 에서 디버깅을 아무리해도 Simple2 의 흔적은 찾을 수가 없던 것이다.

사실 이걸 알아봤자 뭔가 큰 도움될건 없는데... 호기심에 찾아보다가 찾기가 힘들어서 그만둘까라는 생각을 여러번 했었다. 그때 포기하지 않고 이렇게 포스팅까지 작성한 나를 칭찬하고 싶다.

참고자료
- https://chao2zhang.medium.com/unraveling-mockks-black-magic-e725c61ed9dd
- https://sukyology.medium.com/mockk의-흑마술을-파헤치자-6fe907129c19

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함