MocKMP : a Mocking processor for Kotlin/Multiplatform
Recently, when working with Deezer on a Kotlin/Multiplatform project, we came across multiple unit tests that were written in src/androidTest/kotlin
, meaning that they were only run on Android. When investing why that choice was made, the answer became evident : there is no mocking system on Kotlin/Multiplatform, and Mockk only supports JVM & Android.
Mocking with reflection
The JVM reflection is an amazing powerful API, allowing libraries to instrument existing classes and create new implementations at run time. When you write :
…the mocking library :
- Creates a new implementation of the
Repository
type - Sets the behaviour of the
getUser
method on this mock to return a fakeUser
, whatever theid
parameter.
Furthermore, a JVM mocking framework is able to create "fake objects" on demand:
All of that is done at run time, by instrumenting the JVM runtime, which is not possible with 2 of the 3 types of targets of Kotlin/Multiplatform (Kotlin/JS & Kotlin/Native).
A Kotlin Symbol Processor
Google KSP allows to inspect Kotlin code at compile time. In essence, it provides a reflection API available at compile time (rather than at run time).
KSP also allows to generate Kotlin code according to the code being inspected. This is meta-programming: writing code to generate code according to code.
With a Kotlin Symbol Processor, we can generate implementation of interfaces at compile time, and delegate the implementation of said implementations to a mocker delegate.
Introducing MocKMP
The MocKMP project consists of multiple moving parts that allow to easily generate and configure mocks:
- A runtime that provides the mocker API
- A Kotlin Symbol Processor that generates configurable mocks
- A test helper library to make the use of mocks as easy as possible
- A Gradle plugin to make all these moving parts work together
When we started working on MocKMP, we defined an important requirement that we felt was needed in order to make this library as easy as possible to use: keep a "standard" mocking API.
Mocking is a pretty standard task when writing unit tests, and we felt that keeping an established API semantic would make the port of unit tests to Kotlin/Multiplatform as effortless as possible.
No need to reinvent an API when one has already existed for years, and has been widely accepted as a standard.
The following declaration work as expected in MocKMP:
Mocks & Fakes
Because MocKMP works without runtime instrumentation, we had to introduce a few more definitions and restrictions then a "regular" mocking library brings.
A mock is an implementation of an interface whose behaviour can be later configured and calls verified.
You can only mock interfaces. This is because there is no way to create a compile time implementation of a non open class
in Kotlin. Technically, we should be able to generate implementations of abstract class
or open class
in Kotlin, but that would require the developer to make all methods to be open
only for testing, which is obviously a bad practice.
There is no relaxed mocks. This is because there is no way in Kotlin/Multiplatform platforms other than the JVM to create a usable instance of a type without actually constructing it with meaningful parameters (in other words, there is no Objenesis in Kotlin/JS and Kotlin/Native).
This means that you need to explicitly define any behaviour (with every
) before using it in a test.
A fake is a concrete class instance filled with inconsequential data:
- Nullables arenull
- Booleans arefalse
- Numerics are0
- Strings are""
- Enums are their first declared value
You can only fake concrete data structures (meaning concrete classes containing only concrete classes). As a rule of thumb, only data class
es should be faked. This is because creating a fake needs no context while creating a mock needs a mocker
.
Injecting mocks and fakes
This article is not going to go in depth into the usage of MocKMP. If you are interested, you can read its documentation, which is rather quick and simple to understand. Instead, let's analyse a usage case:
- Optional
TestsWithMocks
inheritance.
You can have your test classes inherit fromTestsWithMocks
to access syntactic sugars that make your class easier to write & read. This is completely optional and the documentation shows how to access every feature without this inheritance. - Implementation of
setUpMocks
.
When you class includes properties annotated with@Mock
or@Fake
, the symbol processor will generate aninjectMocks
extension function for it. In this example, the processor generated theMyTests.injectMocks
function. Because this function is generated and different for each class,TestWithMocks
cannot access it automatically. You must define it manually by overriding theTestsWithMocks.setUpMocks
method. - Injecting a mock.
TheTestsWithMocks
class registers a@BeforeTest
hook that will inject a freshView
mock for each test. The property islateinit
because it will be injected before each test (and not once at initialization). - Injecting a fake.
TheTestsWithMocks
class registers a@BeforeTest
hook that will inject a freshModel
fake for each test. The property islateinit
because it will be injected before each test (and not once at initialization). - Injecting mock & fake dependent types.
TheTestsWithMocks
class registers a@BeforeTest
hook that will inject a fresh value to each property using thewithMocks
delegate. In this example, a newController
will be created with a freshview
andmodel
before each new test is run. - Configuring mock behaviour.
To define the behaviour of a mock instance method in a test, use theevery
syntax. Each of the method argument must define an argument constraint. Here,isAny()
defines that the behaviour is defined for any value (in other word, no constraint is defined on the argument). - Using mocks & fakes.
Here, we assume that by callingcontroller.start
, the controller will use theview
mock that was passed to it as constructor parameter. - Verifying mock calls.
Since assuming is not enough, we need to actually verify that the calls were made. Here, we verify that theview.render
mock method was actually called with themodel
parameter. The test will fail if the method was not called, or called with a different parameter.
A processor for Multiplatform
So, if we can't use reflection, how does it work?
Basically, all we need is a way to tell to the system "this method was called, with this argument constraint.
If we consider the View
interface:
Then, the MockView
class that will be generated for it looks like:
With this generated behaviour, we inform the Mocker
(which is the object that handles mocking and verification) that the render
method was called, and we leave the actual behaviour and return value to be handled by the mocker itself.
In essence, the processor is generating a proxy.
It is also generating methods to create fakes:
Finally, it generates an injectMocks
extension function for each class that contains injection annotations:
Thoughtful API changes
We have allowed ourselves to slightly change the API of MocKMP to differ from "traditional" mocking frameworks such as MockK or Mockito-Kotlin. We would like to draw your attention to these specificities of the MocKMP API:
- All argument constraints start with
is
.
Instead of:every { view.render(any()) } returns true
,
write:every { view.render(isAny()) } returns true
.
This allows for discoverability. If you want to find all available constraints in your IDE, typeis
followed by the completion shortcut (ctrl+space
in IDEA) and you'll find them all (isAny
,isEqual
,isNotEqual
,isSame
,isNotSame
,isNull
,isNotNull
). - There is no
every { foo.bar() } just Runs
. This is Kotlin: no function "just runs". A function always has a return value. If it returnsUnit
, then write it, just like any other:every { foo.bar() } returns Unit
. - The
verify
function is exhaustive by default.
This means thatverify {}
actually verifies that no calls was made to any mock instance.
You can disable this withverify(exhaustive = false)
. - The
verify
function is ordered by default.
This means thatverify { m.foo() ; m.bar() }
verifies thatfoo
andbar
were called onm
in that order.
You can disable this withverify(inOrder = false)
.
Multi-platform generation trick
The Google KSP (Kotlin Symbol Processor) that MocKMP uses suffers from an multiplatform issue: it does not support running on common code very well, and it does not support running on common test at all.
You can apply the processor to each test platform, and everything will compile and run correctly… except that IntelliJ IDEA will not recognize the injectMocks
extension function (it will show its usage as an error, but the code still actually compiles).
To circumvent this issue, MocKMP comes with a Gradle plugin that:
- Applies the KSP Gradle plugin to the project
- Applies and configures the MocKMP processor to run on JVM test sources
- Adds the generated code as common code
This means that KSP will run only once on JVM code, but its generated code will be applied to all platform as common code.
This is, honestly, a smelly trick, but it's the only one we found that makes the tests compile AND makes IDEA correctly resolve the injectMocks
method in common code.
Having a Gradle plugin means that when this trick becomes unnecessary, we'll be able to update the plugin to a more "standard" way of applying MocKMP to your project.
Conclusion
MocKMP was instrumental in porting Deezer's Android-only unit tests to Kotlin/Multiplatform. Its API makes it easy to use and very understandable.
You can find the project here: https://github.com/kosi-libs/MocKMP
This article is brought to you by Kodein Koders, a European service & training provider focused on Kotlin & Kotlin/Multiplatform. You can visit our blog, or our Youtube channel for more content.