Unraveling MockK’s black magic
This article is an English version of the blog. All credits belong to oboenikui.
MockK is a mocking library written in Kotlin and for Kotlin. In addition to the traditional functionality provided by JVM based mocking libraries, Kotlin’s language feature significantly improves MockK’s usability.
However, aside from MockK, have you ever wondered how a JVM based mocking library works under the hood? Moreover, when using other libraries together with a mocking library, have you ever had trouble in identifying the root cause encountered? These questions might explain why some may shy away from using MockK, or other mocking libraries. To solve such problems, we need to investigate the details underneath.
In this article, we are going to look under the hood: In MockK, what does the mockk
method actually do? How do every
and verify
set up the mock and check the number of calls?
This article does not intend to explain how to use MockK. Although MockK is made to work on Android and JS, the explanation will focus on JVM (Hotspot).
Note: For the rest of this article, MockK refers to the library, while mockk
refers to the method.
Create mocks using `mockk`
What’s the class of mock instance?
The type used to mock affects the class of the created mock instance. The answer might be different based on whether we are mocking a regular class, an interface, or an abstract class. Let’s try with the following code:
We will get the following output:
class OpenClass
class FinalClass
class AbstractClass$Subclass0
class Interface$Subclass1
For a regular class, whether qualified with final
or not, the class of the created instance is the given class itself. In the case of an abstract class or an interface, an instance of the subclass is created. It is a well-known fact that it is impossible to instantiate an interface or abstract class immediately and we can only instantiate by implementing or inheriting a concrete class instead. Such limitation applies to mock instance.
Where do the names, AbstractClass$Subclass0
and Interface$Subclass1
, come from exactly? Furthermore, without generating subclasses, how do regular classes allow customization such as stubbing the return value of a specific method?
ByteBuddy: rewriting class and generating subclass
To answer the question above, we need to touch base on the library ByteBuddy. It is a library that provides the ability to dynamically create and modifying Java classes. Although it internally manipulates Java bytecode, the provided API is so convenient that it does not require knowledge of Java bytecode.
In MockK, the mocked class and all its superclass are rewritten using ByteBuddy in order to intercept the method call. Check out the implementation InliningClassTransformer.kt.
If we specify the following test VM option, the .class
files of the generated subclass will be saved in the specified location:
// build.gradle
test {
systemProperty 'io.mockk.classdump.path', 'output'
}
Let’s try with the above option and confirm that the class is rewritten when a regular class is mocked.
Once the class transform
test method is executed, we could decompile the generated .class
files that look like the following:
The implementation may look a bit verbose. We can tell that not only the Sub
class but also its superclassesRoot
and the java.lang.Object
are rewritten. The rewritten method here depends onJvmMockKDispatcher
. If MockK is able to handle the call given the JvmMockKDispatcher
(most likely the instance is a mock), the handled result will be returned; otherwise, the original implementation would be executed and returned. The internal processing is handled inside dispatcher.handler()
, which varies depending on the context. Let’s discuss the details later.
In addition, ByteBuddy also generates subclasses when the mock target is an interface or abstract class (SubclassInstrumentation.kt). Let’s add the method foo()
to AbstractClass
and decompile the dumped .class
file with io.mockk.classdump.path
enabled.
The decompiled .class
file becomes:
We omit its superclasses AbstractClass
and java.lang.Object
here since they were similar to what was described above. For the subclass AbstractClass$Subclass0
, it seems to invoke a different method interceptNoSuper()
. As a matter of fact, interceptNoSuper()
internally invokes the same method dispatcher.handler()
.
MockK supports Android by usingjava.lang.reflect.Proxy
instead (and uses DexMaker to generate the DEX bytecode). As Proxy
is also used by Retrofit and other libraries, it should be familiar to most Android developers.
Objenesis: creating a mock instance
As we have discussed how ByteBuddy is used to rewrite class and generate subclass, it should be easy to understand the behavior of every
and verify
. Before that, there is still one topic that I would like to mention: How mockk
creates new instances. Although the following example might be a bit extreme, let’s take a look:
NotImplementedError
is thrown when we construct an instance of UnimplementedClass
, and we should expect the test to fail. But why does it succeed when we create the mock instance with MockK?
In addition, even if there is a non-null property, it will become null for a mock instance:
As we can see from the results above, the instance is created in a different way from the common constructor call. In fact, MockK uses Objenesis library to achieve instance creation.
Objenesis was developed by the EasyMock team. Apart from EasyMock itself, it is also used in well-known mocking libraries like Mockito. Objenesis features its support for a wide range of supported JVMs.
How does Objenesis create an instance? The approach varies with the type of JVM. For Hotspot JVM, sun.reflect.ReflectionFactory#newConstructorForSerialization
was called. As its name implies, this method creates a constructor for serialization. Other than creating an instance, it does not do anything else. As shown in the examples above, the property remains null and the init block is not executed.
Setting answers using `every`
In the previous chapter, we discussed how mockk
method not only creates a mock instance but also rewrites the class. How does every
method set up the answers for the specified method?
CallRecorder: Monitoring the mock instance
JvmMockKDispatcher
and every
method are handled internally by the class CallRecorder
. CallRecorder
manages the “current state” and the number of method calls for the mock instance.
Note: CallRecorder
is provided as thread-local variables.
In total, there are 6 types of states for CallRecorder
:
- Answering: The common state at the test runtime.
- Stubbing: The state inside
every
block. - SutbbingAwaitingAnswer: After
Stubbing
but before setting the answer byreturns
or other methods. - Verifying: The state inside
verify
block. - Exclusion: The state inside
excludeRecords
block. - SafeLogging: When specific methods are invoked, like
Object#toString
, there is a SafeLogging state representing before the answer is returned. Since this state is only used internally in MockK, let’s put it aside for now.
The behavior of calling a mock instance will change based on the state. When every
method is invoked, CallRecorder
changes its state from Answering to Stubbing before the block. Inside the every
block, if there is a method call on the mock instance, the information of method invocation (including the method signature, argument, and return types) are passed to the CallRecorder
through JvmMockKDispatcher
. The invocation information will be used for pattern matching later during answering.
After executing the block, the CallRecorder will change its state from Stubbing to StubbingAwaitingAnswer, waiting for the answer to be set up. After returns
or other methods to set the answer, the state changes from StubbtingAwaitingAnswer to Answering accordingly.
Sometimes multiple methods could be invoked inside the same block, but the answer is only set on the last method call. All the other methods simply return the mock instance instead. Let’s look at the following example:
The answer to foo.bar()
becomes a mock instance of Bar$Subclass1
. If we name the mock instance as mockInstance1
, the answer to mockInstance1.value
becomes the “mock” return. As shown in the example, the methods set up by every
do not need to be called consecutively.
MockKStub: Recording answers and execution history
As shown in the decompiled result earlier, the methods of the mock instance are rewritten. However, the method invocations in every
block and verify
block are not recorded. This is because mockk
method not only creates the mock instance, but also generates a one-to-one correspondingMockKStub
instance, where the method invocations of every
block and verify
block will be recorded.
MockKStub
does not keep a reference to the mock instance though. The relationship is managed by a WeakMap
singleton where the key is the mock instance and the value is the MockKStub
. This WeakMap
is also used to determine whether an instance is a mock instance or a regular instance.
Answering the method call
It is easy to figure out how to answer the method call, given the explanation so far.
When the method is executed in a test class, the state of CallRecorder
should be Answering. When the method of a mock instance is called in the Answering state, invocation information, such as the method arguments, is passed to CallRecorder
through JvmMockKDispatcher
, a pattern match will be performed against the answers set throughevery
. If the pattern matches, return the specified answer. If not, throw an MockKException
(unless for a relaxed mock instance, which creates and returns a mock).
Additionally, when a mock method is called, the call history is recorded in MockKStub
.
Verifying the method call
Explaining how verify
works might not be necessary. A simple summary is: when a verify
block is executed, the state of CallRecorder
becomes Verifying. In this state, the method invocation information is recorded and verified in MockKStub
.
Generics
Because generic type information is not available at Java runtime, the behavior of the following three tests is quite interesting:
In test1()
, assertEquals
fails because type information is missing. Its subclass Sealed$Subclass0
is generated and container.sealed
becomes an instance of the generated subclass type. It is the same story for test2()
, container.sealed
is an instance of Sealed$Subclass0
, therefore ClassCastException
is thrown.
On the other hand, test3()
succeeds. This is because inside the every
block, a ClassCastException
is caught, which complements the type information. The type of return mockk()
therefore would be determined at compile-time, resulting in the successful test result.
It is a good practice to not include code side-effects inside every
block. We could explore a bit more here by tweaking the test:
Why would i
become 2? This is because every
block is executed twice to complement the type information!
Although we know the subclasses of a sealed class cannot be declared outside the file, please be aware that this limitation is actually breached for the test, since a sealed class is converted to an abstract class in Java. Hence dynamic class generation technique can be used to creat subclasses, which is not limited to all the subclasses declared in the file.
spyk, mockkObject, and mockkStatic
spyk
, mockkObject
, and mockkStatic
are not discussed in detail in this story. But you should be able to infer their implementation by reading their documentation.
spyk
is different from mockk
where the generated mock instance would proxy to the instance method. The WeakMap
mentioned in MockKStub: Recording answers and execution history would store a SpyKStub
, subclassing MockKStub
. In the case where the stubbed answer does not exist, instead of throwing an exception, the original implementation will be executed.
mockkObject
and mockkStatic
are different from mockk
in their mocking target class. In terms of the WeakMap
, the stored key becomes the object
or Class<*>
respectively, while the stored value also becomes SpkyKStub
.
Why SpkyKStub?
As you may think, the behavior of mockkObject
and mockStatic
is very close to the behavior of spyk
Summary
Thank you for reading this long story. Frankly speaking, rewriting the class feels like black magic to all of us. However, the idea could be useful for implementing static mocking.
Knowing what is under the hood should be able to help you debug when things go wrong. If you are interested in this article, you might also be interested in reading the source code directly on Github.