Skip to content

Instantly share code, notes, and snippets.

@sukyology
Created January 17, 2021 02:44
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sukyology/9c43f8a2b0c7c1dc3cfbb8d257197eac to your computer and use it in GitHub Desktop.
Save sukyology/9c43f8a2b0c7c1dc3cfbb8d257197eac to your computer and use it in GitHub Desktop.

MockK는 테스트 코드 작성할 때 필요한 코틀린으로 만든 mock 라이브러리(이하 목케이)입니다. MockK는 종래의 JVM용 목케이 기능 뿐만 아니라 코틀린 언어가 사용성을 높혀주고 있기 때문에, 저는 코틀린 프로젝트에서 애용하고 있습니다.

그런데, MockK에 제한된 이야기는 아닙니다만, JVM용 목케이가 어떻게 동작하는지 궁금해하신 적은 없으신가요? 또한, 목케이를 다른 라이브러리랑 같이 쓰면서 문제가 발생해 원인을 특정하기가 힘들었던 경험이 있지는 않으신가요? 그런 경험을 이유로 목케이를 경원하시는 분들도 있을 거라고 생각됩니다. 저 또한 최근에 안드로이드의 유닛테스트 관련 라이브러리 Robolectric과 같이 쓰면서 문제가 발생하여, 구체적 동작을 확인할 필요성을 느꼈습니다.

그래서 이글에서는 MockK가 mock 메서드로 무엇을 하고 있고, everyverify로 mock의 동작 설정이나 호출 횟수의 체크를 하는지 설명하겠습니다.

이하 every에 설정하는 동작을 MockK의 네이밍 규칙에 따라 앤써(answer)라고 부르겠습니다. 앤써는 값을 반환하거나 익셉션을 던지도록 지시합니다. 표현이 이상할 수도 있지만 앤써를 실행하는 표현을 보면 그런 지시를 내리는 거라 생각해주세요.

이하 목케이는 "MockK" 라이브러리고, mockk 표기는 라이브러리 메써드를 지칭합니다.

그리고, 이 글에서는 목케이의 사용법은 설명하지 않습니다. 또한, 목케이는 안드로이드나 JS에서도 동작하도록 되어있습니다만 기본적으로 JVM(HotSpot)에서 동작방법에 대해서 설명합니다.


mockk로 목인스턴스를 생성한다.

목 대상은 일반 클래스, 인터페이스, 추상 클래스 셋 중 하나

목 대상의 일반 클래스 여부가 생성 인스턴스의 클래스가 영향을 받습니다.

확인을 위해 아래와 같은 테스트를 작성해봅시다.

https://gist.github.com/d9431613c4d7e837d9179c58ca305645

실행하면 아래와 같은 출력을 얻을 수 있습니다.

https://gist.github.com/1ad1f1633ffe8b13ce877467291594c3

일반 클래스는 final 여부와 상관없이 지정한 클래스의 인스턴스가 생성되었습니다만, 추상 클래스나 인터페이스의 경우 subClass가 작성되었습니다. Java의 세계에서는 인터페이스나 추상 클래스의 인스턴스를 그대로 생성할 수 없기 때문에 자식 클래스의 정의가 필요하기 때문에 목 인스턴스를 생성할 때도 같은 제약이 적용됩니다.

그러면, 위에서 나온 AbstractClass$Subclass0 혹ㅇ느 Interface$Subclass1는 도대체 어디서 나온걸까요? 그리고 일반 클래스의 경우 자식 클래스의 생성없이 어떻게 함수의 반환값을 커스터마이즈 할 수 있는 걸까요?

ByteBuddy를 통한 클래스 조작 및 자식 클래스의 생성

위의 질문에 대답하려면, ByteBuddy라고 하는 라이브러리를 알 필요가 있습니다. (어떻게 활용하는지에 대한 설명은 글을 너무 방대하게 만드므로 생략하겠습니다)

ByteBuddy는 클래스의 동적 생성 및 조작이 가능한 라이브러리입니다. 내부적으로는 Java바이트코드를 생성, 수정하고 있습니다만, 바이트코드의 지식없이도 해당 기능을 사용할 수 있도록 API가 만들어져 있습니다.

목케이에서는 목 대상의 클래스부터 시작해서 해당 클래스의 모든 슈퍼클래스를 ByteBuddy를 사용해서 수정하여, 모든 메써드의 호출 결과를 변경할 수 있습니다. 구현부

목케이에서는 테스트 실행시 아래와 같은 VM 옵션을 추가하면 지정한 디렉토리내에 생성한 자식 클래스의 클래스파일을 유지하는 게 가능합니다. https://gist.github.com/0ea941a24e5a6524d457f437b21574f3

이 기능을 사용해 실제 일반 클래스의 목 대상이 되었을 때 메서드가 변경되는 것을 확인합시다.

https://gist.github.com/5ec8e8573a7a5356cef34e382e0c5ef7

위의 class transform 테스트를 실행하면 생성되는 class 파일을 디컴파일하면 아래와 같이 나옵니다.

https://gist.github.com/b24ffb6d6b697d18f0df200ae883ab63

https://gist.github.com/845841680a0ad4d3a6a1f2c991873724

https://gist.github.com/19d4ce17c2fda3a463a3329c035d719f

코드가 조금 중복되어 있는 것은 ByteBuddy의 제약 때문입니다. Sub 클래스 뿐만 아니라 부모 클래스인 Rootjava.lang.Object클래스도 수정되어 있는 걸 확인할 수 있습니다. 수정된 메써드에는 JvmMockKDispatcher라는 클래스가 있습니다. 이 클래스를 통해 목케이가 처리가능한 경우 (다시 말하면 인스턴스가 목인스턴스인 경우) 에는 그 결과를, 그렇지 않은 경우는 원래 구현체의 실행 결과를 반환하는 처리를 하고 있습니다. dispatcher.handler의 내부 처리는 실행 시점에 따라서 달라집니다만, 자세한 건 후술합니다.

또한 목 대상이 인터페이스, 추상클래스일 경우에 서브클래스 생성도 ByteBuddy로 합니다. 구현

이전과 마찬가지로 io.mockk.classdump.path 옵션을 붙여서 출력된 클래스 파일을 디컴파일 해보겠습니다. 아래와 같이 AbstractClassfoo 메소드를 추가하고 mockk 메소드로 목의 생성을 해보겠습니다.

https://gist.github.com/474c6039a9bedf7049690c5ad7a878a2

그러면 아래와 같은 클래스가 생성됩니다.

https://gist.github.com/add17c6576686da865c21565df49350e

디컴파일 결과는 생략하지만 AbstractClass$Subclass0의 슈퍼클래스인 AbstractClassjava.lang.Object도 앞서 설명한 바와 같이 변경됩니다. 생성된 서브클래스에서는 조금 전과는 다른 메소드가 호출되고 있습니다만 실제로는 interceptNoSuper 메소드 내에서 같은 처리를 합니다.

참고로 목케이는 Android에서 동작도 지원하고 있는데 그 경우는 java.lang.reflect.Proxy를 이용합니다. Proxy는 Retrofit 등에서도 이용되고 있기 때문에 Android 개발자에도 익숙하다고 생각합니다.

Objenesis를 통한 인스턴스 생성

그런데, ByteBuddy의 클래스 수정 및 서브 클래스 생성에 대해 알게 됨으로써 everyverify의 동작을 이해하기 위한 힌트를 얻었습니다. 그런데 그 전에 또 하나, mockk 메소드에서는 어떻게 인스턴스를 생성하고 있는지에 대해서 설명을 하겠습니다.

설명을 위해 극단적인 예로 다음과 같은 클래스를 정의합니다. https://gist.github.com/514f579c0cf12474a376d0ee9709ed40

인스턴스를 생성할 때 반드시 NotImplementedError를 던지도록 되어 있으므로 초기화에 실패해야 하지만 목케이를 사용하여 인스턴스를 생성하는데 성공합니다.

또한 non null한 필드변수가 있는 경우에도 목 인스턴스에서는 null이 됩니다.

https://gist.github.com/071192f1d141d8d04213a095002d63a4

이러한 것들로부터도 알 수 있듯이, 일반 컨스트럭터 호출과는 다른 방법으로 인스턴스를 생성하고 있습니다. 목케이에서는, 이것을 Objenesis라고 하는 라이브러리를 이용해 실현하고 있습니다.

Objenesis는 EasyMock 팀에 의해 개발되었으며 EasyMock 이외에도 Mockito 등 유명한 목 라이브러리에서도 사용하고 있습니다. 또, 이야기에서 살짝 벗어나지만, 이 라이브러리는 수많은 JVM, 환경 에 대응하고 있는 것도 특징입니다.

그렇다면 Objenesis에서는 어떻게 인스턴스를 생성할까요? JVM의 종류에 따라서 접근법이 다른데 HotSpot이면 먼저 sun.reflect.ReflectionFactory#newConstructorForSerialization을 호출합니다. 이 메소드는 이름 그대로 Serialization용 컨스트럭터를 작성해 주는 메소드 입니다. 이 컨스트럭터는 필드 초기화 등을 포함하는 모든 행위를 제외하면서 인스턴스를 생성하기 때문에 위 예시와 같이 필드는 null이고 init 블록도 실행하지 않고 인스턴스를 생성할 수 있습니다. 마지막으로 생성된 컨스트럭터를 부르면 인스턴스를 생성할 수 있습니다.

every로 앤써를 설정하다

이전 장에서는 mockk 메소드를 통해서 목 인스턴스를 생성할 뿐만 아니라 클래스의 수정이 이루어진다는 것까지 설명했습니다. 그럼 every 메소드에서는 클래스 수정이 어떻게 활용이 되는 것일까요?

목 인스턴스를 감시하는 CallRecorder

이전 장에 나왔던 JvmMockKDispatcherevery 메소드 내부 처리에서 자주 등장하는 것이 이 CallRecorder 클래스입니다. CallRecorder는 현재 상태나 목 인스턴스의 메소드 호출 횟수를 기록합니다.

CallRecorder의 인스턴스는 ThreadLocal변수로 제공됩니다.

CallRecorder가 기록하는 「상태」는 이하의 6 종류입니다.

Answering... 정상 상태.앤써를 실행하는 것은 이 상태.

Stubbing ·· every 블록 내 처리를 기록하는 상태

Stubbing Awaiting Answer ·· Stubbing 후 returns 메서드 등으로 앤써를 설정하기전 상태

Verifying ·· verify 블록 내 처리를 기록하는 상태

Exclusion ·· excludeRecords 블록 내의 처리를 기록하는 상태

정확히는 그 외에 Object#toString 등 특정 메소드를 부를 때 앤써를 실행하지 않고 수정전 메소드를 부르도록 하는 SafeLogging 상태도 있습니다만, MockK의 내부에서만 이용되기 때문에 신경쓸 필요는 없습니다.

이 상태가 달라지면, 목 인스턴스의 메소드 호출시의 동작도 바뀝니다.

every 메소드가 호출되면, 우선 CallRecorder의 상태를 Answering에서 Stubbing으로 바꾼 후 블록(역주: every`에 넘긴 람다 함수`) 을 실행합니다. 블록내에서 목 인스턴스의 메소드가 불리면 JvmMockKDispatcher를 경유하여 CallRecorder``에게 메소드의 호출 정보 (메소드 자체의 정보나 인수, 반환값의 형태 등)를 기록합니다. 이 호출 정보는 나중에 앤써를 실행 할 때 패턴매칭에 쓰입니다.

블록이 실행된 후에는 CallRecorder의 상태를 Stubbing에서 StubbingAwaitingAnswer로 바꾸고 앤써의 설정을 기다립니다. returns 와 같은 앤써를 설정하는 메소드의 실행 후에는 StubbingAwaitingAnswer에서 Answering으로 되돌립니다.

때로는 여러 메소드가 블록내에서 불리기도 하는데 테스트 구현자가 앤써를 설정하는 것은 마지막으로 불려진 메소드이기 때문에 그 외의 메소드에 대해서는 "목 인스턴스를 반환한다"라고 하는 앤써를 설정합니다.

아래와 같은 메소드 체인을 예로 설명합니다.

https://gist.github.com/4913a9f81c03c3b4806b60da512c6770

foo.bar()의 앤써는 Bar$Subclass1의 목 인스턴스(mockInstance1이라고 하겠습니다.)을 반환하는 것이 되고, mockInstance1.value의 앤써는 "mock"을 반환한다가 됩니다. 따라서 예제와 같이 every로 설정한 메소드는 반드시 한번에 호출하지 않아도 됩니다.

앤써 및 실행 내역을 기록하는 MockKStub

사실 mockk 메소드에서는 목 인스턴스 외에 MockKStub이라고 하는 클래스의 인스턴스도 생성합니다. 목 인스턴스가 생성된 클래스는 조금 전에 디컴파일 했듯이 메소드가 변경됩니다. 그러나, every에서 행해지는 호출 정보와 앤써의 페어의 설정이나, verify에서 나중에 사용되는 호출 이력은 목 인스턴스에 기록되지 않습니다. 그대신 목 인스턴스와 일대일로 대응하는 MockKStub 클래스의 인스턴스를 생성하고 이 MockKStub 인스턴스에 그것들을 기록합니다.

목 인스턴스에 MockKStub 인스턴스를 참조하지 않는 대신 목 인스턴스를 키, MockKStub을 값으로 싱글턴의 WeakMap에서 관리됩니다. 이 WeakMap은 인스턴스가 목인지 보통 인스턴스인지 판별하는데도 쓰입니다.

TDD 용어로 말하는 "stub"의 역할이 아닌 기능도 포함하고 있기 때문에, 작명이 좋지 않은 것 같기도 합니다.

실제 메소드 호출

여기까지 설명을 읽으신 분들 중에 통찰력이 좋으신 분들은 메소드호출로 앤써를 실행하는 방법도 상상이 갈 것 같습니다.

테스트 대상 클래스 내에서 메소드가 호출 될 때 CallRecorder의 상태는 Answering이 되어 있을 것입니다. Answering 상태의 목 인스턴스의 메소드가 호출되면 JvmMockKDispatcher를 경유하여 CallRecorder는 메소드에 전달된 인수 등의 정보를 가져오고 every에서 설정된 호출과 앤써의 쌍 중 패턴이 일치하는 것이 없는지 확인합니다. 존재하면 그 앤써를 실행, 없다면 일반적으로 MockKException을 던집니다.(릴랙스목이면 목을 인스턴스를 생성해서 반환합니다.)

또한, 목의 메소드가 호출되면 호출 내역을 MockKStub에 기록합니다.


verify 메서드 호출

이제 이쯤 되면 verify가 뭘 할 지 설명이 필요 없을지도 모르지만 일단. verify가 호출되면 CallRecorder의 상태를 Verifying으로 합니다. 이 상태에서 블록을 실행하여 다른 것과 마찬가지로 호출 정보를 취득하고, MockKStub에 기록된 호출 기록을 대조하여 정말 불리고 있는지 확인합니다.


기타 이야기

제네릭

제네릭의 형 정보는 Java 실행시에는 참조할 수 없습니다. 따라서 다음 3가지 테스트는 조금 재미있는 동작을 합니다.

https://gist.github.com/7a77b8b1725283249163cdd202301665

test1에서는 형 정보가 손실되어 assertEquals에 실패합니다. Sealed$Subclass0이라는 새로운 서브클래스가 생성되고 container.sealed는 그 클래스의 instance가 되기 때문입니다.

test2 역시 container.sealedSealed$Subclass0의 인스턴스가 되므로 메소드의 두 번째 줄에서 ClassCastException을 던집니다. 하지만 test3는 통과합니다. 사실 every에서는 블록 내에서 발생하는 ClassCastException 메시지를 바탕으로 형 정보를 보완해 줍니다.또한 returns mockk()가 SubSealed 클래스의 인스턴스를 생성하는 것은 컴파일 시에 결정됩니다.그래서 시험이 통과하는 것입니다.

말하고 싶은 게 더 있습니다.

먼저, 당연하지만 절대로 every블록 내에서 사이드 이펙트가 있는 메소드를 실행하지 마세요.

예를들면 아까 시험을 수정해서

https://gist.github.com/d546abf54f87809cd4010b2f9feafe61

상기와 같이 작성하면 i는 2가 됩니다. 이는 제네릭의 형 정보를 보완하기 위해 여러 번 every 블록이 실행되기 때문입니다.

다음으로 sealed class의 제약(서브 클래스를 파일 밖에서는 만들 수 없다는 것)이 깨지는 것에 주의해주세요.

sealed class는 자바에서는 그냥 abstract class입니다. 클래스의 동적생성의 테크닉을 사용하면, 파일내에서 정의한 서브클래스 이외의 클래스도 만들 수 있습니다.

spyk , mockkObject 그리고 mockkStatic

spykmockkObject, mockkStatic의 설명을 빼먹었습니다만, mockk에 대한 설명으로 이들의 동작 방식을 예상할 수 있을 것 같습니다.

spyk의 경우 mockk 다른 점은 생성한 목 인스턴스의 메소드를 전달받은 인스턴스의 메소드에 프록시하는 것과 앤써 및 실행 이력을 기록하는 MockKStub 에서 설명한 WeakMap에 spykStub이라고 하는 MockKStub의 서브클래스를 기록하는 것입니다. spykStub같은 경우는 앤써가 존재하지 않을 때 예외를 던지는 게 아니라 원래 메소드를 호출하도록 구현되어 있습니다.

mockkObjectmockkStatic도 클래스의 변경 대상이 다르다는 점, WeakMap에 객체나 Class<*>를 키로 spykStub을 저장하는 것 정도의 차이가 있을 뿐입니다.

눈치가 빠르신 분들은 "어? spykStub이야?" 라고 생각하셨을 겁니다. 네, 그렇습니다. mockkObjectmockkStatic은 실제로는 스파이의 동작에 더 가깝습니다.


마지막으로

긴 글을 끝까지 읽어주셔서 감사합니다.

클래스 수정을 하고 있다는 것을 알고, 역시 흑마술 같다고 느끼셨을까요?. 솔직히 저도 그렇게 느낀 바가 없지 않아 있습니다만, static 몹 등은 다른 부분에서도 유용하게 쓸 수 있다는 생각에 매우 흥미롭다고 개인적으로는 생각했습니다.

구조나 구현을 알아 두면 에러가 발생했을 때 디버깅이 필요한 포인트를 판별할 수 있으므로, 만약 이 글로 흥미가 생기셨다면 GitHub 소스 을 통해 새로운 해결책을 찾으실 수 있을 것 같습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment