MocKMP : a Mocking processor for Kotlin/Multiplatform

Salomon BRYS
Kodein Koders
Published in
7 min readJan 17, 2022
Photo by Stefano Pollio on Unsplash

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 fake User, whatever the id 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 are null
- Booleans are false
- Numerics are 0
- 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 classes 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:

  1. Optional TestsWithMocks inheritance.
    You can have your test classes inherit from TestsWithMocks 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.
  2. Implementation of setUpMocks.
    When you class includes properties annotated with @Mock or @Fake, the symbol processor will generate an injectMocks extension function for it. In this example, the processor generated the MyTests.injectMocks function. Because this function is generated and different for each class, TestWithMocks cannot access it automatically. You must define it manually by overriding the TestsWithMocks.setUpMocks method.
  3. Injecting a mock.
    The TestsWithMocks class registers a @BeforeTest hook that will inject a fresh View mock for each test. The property is lateinit because it will be injected before each test (and not once at initialization).
  4. Injecting a fake.
    The TestsWithMocks class registers a @BeforeTest hook that will inject a fresh Model fake for each test. The property is lateinit because it will be injected before each test (and not once at initialization).
  5. Injecting mock & fake dependent types.
    The TestsWithMocks class registers a @BeforeTest hook that will inject a fresh value to each property using the withMocks delegate. In this example, a new Controller will be created with a fresh view and model before each new test is run.
  6. Configuring mock behaviour.
    To define the behaviour of a mock instance method in a test, use the every 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).
  7. Using mocks & fakes.
    Here, we assume that by calling controller.start, the controller will use the view mock that was passed to it as constructor parameter.
  8. Verifying mock calls.
    Since assuming is not enough, we need to actually verify that the calls were made. Here, we verify that the view.render mock method was actually called with the model 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:

  1. 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, type is followed by the completion shortcut (ctrl+space in IDEA) and you'll find them all (isAny, isEqual, isNotEqual, isSame, isNotSame, isNull, isNotNull).
  2. There is no every { foo.bar() } just Runs. This is Kotlin: no function "just runs". A function always has a return value. If it returns Unit, then write it, just like any other: every { foo.bar() } returns Unit.
  3. The verify function is exhaustive by default.
    This means that verify {} actually verifies that no calls was made to any mock instance.
    You can disable this with verify(exhaustive = false).
  4. The verify function is ordered by default.
    This means that verify { m.foo() ; m.bar() } verifies that foo and bar were called on m in that order.
    You can disable this with verify(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.

--

--