MocKMP
A Kotlin/Multiplatform Kotlin Symbol Processor that generates Mocks & Fakes.
Limitations:
-
Mocking only applies to interfaces
-
Faking only applies to concrete trees
Built in collaboration with Deezer.
- Quick Start Guide
- Full Usage
- Mocks
- Requesting generation
- Defining behaviour
- Mocking properties
- Defining suspending behaviour
- Adding argument constraints
- Verifying
- Verifying exception thrown
- Configuring verification exhaustivity & order
- Capturing arguments
- Accessing run block arguments
- Mocking functional types
- Defining custom argument constraints
- Fakes
- Injecting your tests
- Using the test class helper
- Mocks
- Setup
Quick Start Guide
-
Apply the Gradle plugin and activate the helper dependency:
plugins { kotlin("multiplatform") id("org.kodein.mock.mockmp") version "1.15.0" } kotlin { // Your Koltin/Multiplatform configuration } mockmp { usesHelper = true installWorkaround() }
-
Create a test class that declares injected mocks and fakes:
class MyTest : TestsWithMocks() { override fun setUpMocks() = injectMocks(mocker) //(1) @Mock lateinit var view: View @Fake lateinit var model: Model val controller by withMocks { Controller(view = view, firstModel = model) } @Test fun controllerTest() { every { view.render(isAny()) } returns true controller.start() verify { view.render(model) } } }
-
This is mandatory and cannot be generated. You need to run the KSP generation at least once for your IDE to see the
injectMocks
generated function.
NoteEvery property annotated by @Mock
, annotated by@Fake
or delegated towithMocks
will be reset fresh between each test. -
Full Usage
Tip
|
This section covers the use of the MocKMP mocker by itself.
MocKMP also provides a very useful abstract class helper for test classes.
The TestWithMocks helper class usage is recommended when possible (as it makes your tests reasier to read), and is documented later in the Using the test class helper chapter.
|
Mocks
Caution
|
Only interfaces can be mocked! |
Requesting generation
You can declare that a class needs a specific mocked interface by using the @UsesMocks
annotation.
@UsesMocks(Database::class, API::class)
class MyTests {
}
Once a type appears in @UsesMocks
, the processor will generate a mock class for it.
Defining behaviour
To manipulate a mocked type, you need a Mocker
.
You can then create mocked types and define their behaviour:
@UsesMocks(Database::class, API::class)
class MyTests {
@Test fun myUnitTest() {
val mocker = Mocker()
val db = MockDatabase(mocker)
val api = MockAPI(mocker)
mocker.every { db.open(isAny()) } returns Unit //(1)
mocker.every { api.getCurrentUser() } runs { fakeUser() } //(2)
}
}
-
returns
mocks the method to return the provided instance. -
runs
mocks the method to run and return the result of the provided function.
Note that a method must be mocked to run without throwing an exception (there is no "relaxed" mode).
You can mock methods according to specific argument constraints:
mocker.every { api.update(isNotNull()) } returns true
mocker.every { api.update(isNull()) } runs { nullCounter++ ; false }
You can also keep the Every
reference to change the behaviour over time:
val everyApiGetUserById42 = mocker.every { api.getUserById(42) }
everyApiGetUserById42 returns fakeUser()
// Do things...
everyApiGetUserById42 returns null
// Do other things...
Mocking properties
You can mock property getters & setters much like regular methods:
@UsesMocks(User::class)
class MyTests {
@Test fun myUnitTest() {
val mocker = Mocker()
val user = MockUser(mocker)
// Mocking a val property:
mocker.every { user.id } returns 1
// Mocking a var property:
mocker.every { user.name = isAny() } returns Unit //(1)
mocker.every { user.name } returns "John Doe"
}
}
-
A setter always returns
Unit
.
A var (read & write) property can be "backed" by the mocker:
@UsesMocks(User::class)
class MyTests {
@Test fun myUnitTest() {
val mocker = Mocker()
val user = MockUser(mocker)
mocker.backProperty(user, User::rwString, default = "")
}
}
Defining suspending behaviour
You can define the behaviour of a suspending function with everySuspending
:
mocker.everySuspending { app.openDB() } runs { openTestDB() } //(1)
mocker.everySuspending { api.getCurrentUser() } returns fakeUser()
-
Here,
openTestDB
can be suspending.
Warning
|
|
Adding argument constraints
Available constraints are:
-
isAny
is always valid (even withnull
values). -
isNull
andisNotNull
check nullability. -
isEqual
andisNotEqual
check regular equality. -
isSame
andisNotSame
check identity. -
isInstanceOf
checks type.
Note that passing a non-constraint value to the function is equivalent to passing isEqual(value)
mocker.every { api.getUserById(42) } returns fakeUser()
is strictly equivalent to:
mocker.every { api.getUserById(isEqual(42)) } returns fakeUser()
Warning
|
You cannot mix constraints & non-constraint values. This fails: mocker.every { api.registerCallback(42, isAny()) } returns Unit …​and needs to be replaced by: mocker.every { api.registerCallback(isEqual(42), isAny()) } returns Unit |
Verifying
You can check that mock functions has been run in order with verify
.
val fakeUser = fakeUser()
mocker.every { db.loadUser(isAny()) } returns null
mocker.every { db.saveUser(isAny()) } returns Unit
mocker.every { api.getUserById(isAny()) } returns fakeUser
controller.onClickUser(userId = 42)
mocker.verify {
db.loadUser(42)
api.getUserById(42)
db.saveUser(fakeUser)
}
You can of course use constraints (in fact, not using passing a constraint is equivalent to passing isEqual(value)
):
mocker.verify {
api.getUserById(isAny())
db.saveUser(isNotNull())
}
Warning
|
You cannot mix constraints & non-constraint values. |
If you want to verify the use of suspend functions, you can use verifyWithSuspend
:
mocker.verifyWithSuspend {
api.getUserById(isAny())
db.saveUser(isNotNull())
}
Note
|
You can check suspending and non suspending functions in verifyWithSuspend .
Unlike everySuspending , all verifyWithSuspend does is running verify in a suspending context, which works for both regular and suspending functions.
|
Verifying exception thrown
If you define a mock function behaviour to throw an exception, you must verify the call with threw
:
mocker.every { db.saveUser(isAny()) } runs { error("DB is not accessible") }
//...
mocker.verify {
val ex = threw<IllegalStateException> { db.saveUser(isAny()) }
assertEquals("DB is not accessible", ex.message)
}
If you configure your behaviour to maybe throw an exception, you can verify a call that may or may not have thrown an exception with called
:
mocker.every { api.getUserById(isAny()) } runs { args ->
val idArg = args[0] as Int
if (idArg == 42) return MockUser()
else throw UnknownUserException(idArg)
}
//...
mocker.verify {
called { api.getUserById(isAny()) }
}
Configuring verification exhaustivity & order
By default, the verify
block is exhaustive and in order: it must list all mocked functions that were called, in order.
This means that you can easily check that no mocked methods were run:
mocker.verify {}
You can use clearCalls
to clear the call log, in order to only verify for future method calls:
controller.onClickUser(userId = 42)
mocker.clearCalls() //(1)
controller.onClickDelete()
mocker.verify { db.deleteUser(42) }
-
All mocked calls before this won’t be verified.
You can verify with:
-
exhaustive = false
, which will verify each call, in their relative order, but won’t fail if you didn’t mention every calls. -
inOrder = false
, which allows you to define all calls in any order, but will fail if you did not mention all of them. -
exhaustive = false, inOrder = false
, which checks required calls without order nor exhaustiveness.
mocker.verify(exhaustive = false, inOrder = false) { //(1)
db.deleteUser(42)
api.deleteUser(42)
}
-
Verify that both calls have been made, no matter the order. Other calls to mocks may have been made since exhaustiveness is not checked.
Capturing arguments
You can capture an argument into a MutableList
to use or verify it later.
This can be useful, for example, to capture delegates and call them.
val delegate = MockDelegate()
mocker.every { delegate.setSession(isAny()) } returns Unit
val controller = Controller(delegate)
controller.startNewSession()
assertEquals(1, controller.runningSessions.size)
val sessionCapture = ArrayList<Session>()
mocker.verify { delegate.setSession(isAny(capture = sessionCapture)) } //(1)
val session = sessionCapture.single() //(2)
session.close()
assertEquals(0, controller.runningSessions.size)
-
Captures the
setSession
first argument into thesessionCapture
mutable list. -
As
setSession
should have been called only once, retrieve the one and onlySession
from the capture list.
Captures can also be used in definition blocks. The previous example could be rewritten as such:
val delegate = MockDelegate()
val sessionCapture = ArrayList<Session>()
mocker.every { delegate.setSession(isAny(capture = sessionCapture)) } returns Unit
val controller = Controller(delegate)
controller.startNewSession()
assertEquals(1, controller.runningSessions.size)
val session = sessionCapture.single()
session.close()
assertEquals(0, controller.runningSessions.size)
Note that, when declared in a definition block, the capture list may be filled with multiple values (one per call).
Accessing run block arguments
There are 2 ways you can access arguments in a run block.
-
You can use capture lists:
val sessions = ArrayList<String>() mocker .every { delegate.setSession(isAny(capture = sessions)) } .runs { sessions.last().close() } //(1)
-
.last()
returns the last call argument, which is always the current.
-
-
You can access function parameters in a run block arguments. This is less precise than using capture lists as they are non typed, but allows to write very concise code:
mocker
.every { delegate.setSession(isAny()) }
.runs { args -> (args[0] as Session).close() }
Mocking functional types
You can create mocks for functional type by using mockFunctionX
where X is the number of arguments.
val callback: (User) -> Unit = mockFunction1()
mocker.every { callback(isAny()) } returns Unit
userRepository.fetchUser(callback)
mocker.verify { callback(fakeUser) }
The mockFunctionX
builders can accept a lambda parameter that defines behaviour & return type of the mocked function (so that you don’t have to call mocker.every
).
The above mocked callback function can be declared as such:
val callback: (User) -> Unit = mockFunction1() {} // implicit Unit
Defining custom argument constraints
You can define your own constraints:
fun ArgConstraintsBuilder.isStrictlyPositive(capture: MutableList<Int>? = null): Int =
isValid(ArgConstraint(capture, { "isStrictlyPositive" }) {
if (it >= 0) ArgConstraint.Result.Success
else ArgConstraint.Result.Failure { "Expected a strictly positive value, got $it" }
})
…​and use them in definition:
mocker.every { api.getSuccess(isStrictlyPositive()) } returns true
mocker.every { api.getSuccess(isAny()) } returns false
…​or in verification:
mocker.verify { api.getUserById(isStrictlyPositive()) }
Fakes
Caution
|
Only concrete trees (concrete classes containing concrete classes) can be faked!. |
Data classes are ideal candidates for faking.
Requesting generation
You can declare that a class needs a specific faked data by using the @UsesFakes
annotation.
@UsesFakes(User::class)
class MyTests {
}
Once a type appears in @UsesFakes
, the processor will generate a fake function for it.
Instantiating
Once a class has been faked, you can get a new instance by calling its fake*
corresponding function:
@UsesFakes(User::class)
class MyTests {
val user = fakeUser()
}
Here are the rules the processor uses to generate fakes:
-
Nullable values are always
null
. -
Boolean
values are set tofalse
. -
Numeric values are set to
0
. -
String
values are set to empty""
. -
Other non-nullable non-primitive values are faked.
Tip
|
By using a val user = fakeUser().copy(id = 42) |
Providing fake instances
Classes that do not have a public constructor cannot be automatically faked.
For these types, you need to provide your custom fake provider with @FakeProvider
:
@FakeProvider
fun provideFakeInstant() = Instant.fromEpochSeconds(0)
Caution
|
There can be only one provider per type, and it needs to be a top-level function. |
Injecting your tests
Instead of creating your own mocks & fakes, it can be useful to inject them in your test class, especially if you have multiple tests using them.
@UsesFakes(User::class)
class MyTests {
@Mock lateinit var db: Database
@Mock lateinit var api: API
@Fake lateinit var user: User
lateinit var controller: Controller
val mocker = Mocker()
@BeforeTest fun setUp() {
mocker.reset() //(1)
this.injectMocks(mocker) //(2)
controller = ControllerImpl(db, api) //(3)
}
@Test fun controllerTest() {
mocker.every { view.render(isAny()) } returns true
controller.start()
mocker.verify { view.render(model) }
}
}
-
Resets the mocker before any test (which removes all mocked behaviour & logged calls), so that each test gets a "clean" mocker.
-
Injects mocks and fakes.
-
Create classes to be tested with injected mocks & fakes.
As soon as a class T
contains a @Mock
or @Fake
annotated property, a T.injectMocks(Mocker)
function will be created by the processor.
Important
|
Don’t forget to reset the Mocker in a @BeforeTest method!
|
Using the test class helper
MocKMP provides the TestsWithMocks
helper class that your test classes can inherit from.
It provides the following benefits:
-
Provides a
Mocker
. -
Resets the
Mocker
before each tests. -
Provides
withMocks
property delegates to initialize objects with mocks. -
Allows to call
every
,everySuspending
,verify
, andverifyWithSuspend
withoutmocker.
.
It does not come with the standard runtime (as it forces the dependency to JUnit on the JVM), so to use it you need to either:
-
define
usesHelper = true
in the MocKMP Gradle plulgin configuration block, -
or add the
mockmp-test-helper
implementation dependency.
The above MyTests
sample can be rewritten as such:
@UsesFakes(User::class)
class MyTests : TestsWithMocks() { //(1)
override fun setUpMocks() = injectMocks(mocker) //(2)
@Mock lateinit var db: Database
@Mock lateinit var api: API
@Fake lateinit var user: User
val controller by withMocks { ControllerImpl(db, api) } //(3)
@Test fun controllerTest() {
every { view.render(isAny()) } returns true //(4)
controller.start()
verify { view.render(model) } //(4)
}
}
-
The class inherits
TestsWithMocks
, which provides helpers. -
setUpMocks
must be overriden, and can generally be just a delegation to theinjectMocks
generated function. -
Controller will be (re)created before each tests with the new mock dependencies.
-
Note the absence of
mocker.
as you can useevery
andverify
directly.
Note
|
Properties delegated to withMocks will be (re)initialized before each tests, after the mocks have been (re)injected.
|
Caution
|
Because of this issue, you cannot consider that the mocks have been initialized in yout class MyTests : TestsWithMocks() {
override fun initMocksBeforeTest() {
// Access all injected values:
// mocks, fakes & withMocks properties
}
} |
In case your test class already extends another class, you can use the ITestsWithMocks
interface instead:
@UsesFakes(User::class)
class MyTests : MySuperAbstractTests(), ITestsWithMocks { //(1)
override val mocksState = ITestsWithMocks.State() //(2)
override fun setUpMocks() = injectMocks(mocker)
// ...your tests...
}
-
The class implements the
ITestsWithMocks
interface, which provides all helper methods. -
The class needs to provide an
ITestsWithMocks.State
(since the interface cannot provide one).
Setup
With the official plugin
The MocKMP Gradle plugin configures your project to use the Kotlin Symbol Processor using a workaround to a current KSP limitation.
Once KSP properly supports hierarchical Multiplatform, this plugin will apply MocKMP "normally".
plugins {
kotlin("multiplatform")
id("org.kodein.mock.mockmp") version "1.15.0" //(1)
}
repositories {
mavenCentral()
}
kotlin {
jvm()
ios()
js(IR) {
browser()
}
sourceSets {
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
}
}
mockmp {
// OPTIONAL!
usesHelper = true //(2)
// REQUIRED!
installWorkaround() // (3)
}
-
Applying the MocKMP plugin.
-
Requesting the optional
test-helper
dependency -
Must be called after the
kotlin
configuration and must be the last line of themockmp
block.
The plugin takes care of:
-
Applying the KSP Gradle plugin
-
Declaring the MocKMP KSP dependency
-
Declaring the MocKMP runtime dependencies
-
Applying the incomplete multiplatform support workaround:
-
Using Android if the Android plugin is applied
-
Using the JVM otherwise
-
Warning
|
If you are not using the same Kotlin version than the plugin, then you’ll need to manually apply the KSP plugin before applying MocKMP: plugins {
id("com.google.devtools.ksp") version "1.7.22-1.0.8"
id("org.kodein.mock.mockmp") version "{version}"
} |
Applying to main source sets
In some cases, you may need to apply the processor to the common-main source-set instead of common-test.
mockmp {
targetSourceSet = CommonMain
}
With KSP and its incomplete multiplatform support
KSP for multiplatform is in beta, and KSP for common tests is not supported (yet).
To have IDEA completion, here’s a trick that you can use (in fact, that’s what the MocKMP plugin does):
plugins {
kotlin("multiplatform")
id("com.google.devtools.ksp") version "1.0.0-1.0.12" //(1)
}
repositories {
mavenCentral()
}
kotlin {
jvm()
ios()
js(IR) {
browser()
}
sourceSets {
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation("org.kodein.mock:mockmp-runtime:1.15.0") //(2)
// OPTIONAL!
implementation("org.kodein.mock:mockmp-test-helper:1.15.0") //(2)
}
kotlin.srcDir("build/generated/ksp/jvm/jvmTest/kotlin") //(3)
}
}
}
dependencies {
"kspJvmTest"("org.kodein.mock:mockmp-processor:1.15.0") //(4)
}
tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinCompile<*>>().all {
if (name.startsWith("compileTestKotlin")) {
dependsOn("kspTestKotlinJvm") //(5)
}
}
-
Applying the KSP plugin.
-
Adding the dependencies to the MocKMP runtime and the optional test helper.
-
Use KSP generated JVM sources on all targets.
-
Apply the processor only on the JVM target.
-
Make compilation of all targets dependant on the JVM KSP processor.
Generated classes & functions visibility
By default, every generated class or function is internal
.
If you wish to have it public
(because you need to share it across modules), then you can configure the processor to generate public classes & functions:
// When using the MocKMP plugin:
mockmp {
public = true
}
// When using KSP directly:
ksp {
arg("org.kodein.mock.visibility", "public")
}