MockK의 흑마술을 파헤치자!

이 글은 원글의 한글 버젼입니다. 영문 버젼도 있습니다. 원저작자 오보에니쿠이님의 동의를 구해 작성한 글입니다.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

설명을 위해 극단적인 예로 다음과 같은 클래스를 정의합니다.

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

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

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

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

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

every로 앤써를 설정하다

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

목 인스턴스를 감시하는 CallRecorder

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

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

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

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

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

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

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

`CallRecorder` 상태 전환 다이어그램
`CallRecorder` 상태 전환 다이어그램
CallRecorder 상태 전환 다이어그램

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

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

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

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

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

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

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

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

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

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

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

verify 메서드 호출

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

기타 이야기

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

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

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

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

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

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

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

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

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

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

마지막으로

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

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

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

역주 — medium 에 퍼블리싱이 처음인 점, 블로그 글 쓰는 게 익숙하지 않은 점 때문에 어색한 부분들이 있을 것 같습니다. 피드백 주시면 재빠르게 수정하겠습니다. 감사합니다.

developer @ htbeyond, SNU math graduate

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store