• Stars
    star
    184
  • Rank 209,187 (Top 5 %)
  • Language
    Kotlin
  • License
    MIT License
  • Created about 3 years ago
  • Updated 11 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

A mocking processor for Kotlin Multiplatform

MocKMP

Quick Start Guide

  1. 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()
    }
  2. 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) }
        }
    }
    1. 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.

    Note
    Every property annotated by @Mock, annotated by @Fake or delegated to withMocks 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)
    }
}
  1. returns mocks the method to return the provided instance.

  2. 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"
    }
}
  1. 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()
  1. Here, openTestDB can be suspending.

Warning
  • You must use every to mock non suspending functions.

  • You must use everySuspending to mock suspending functions.

Adding argument constraints

Available constraints are:

  • isAny is always valid (even with null values).

  • isNull and isNotNull check nullability.

  • isEqual and isNotEqual check regular equality.

  • isSame and isNotSame 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) }
  1. 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)
}
  1. 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)
  1. Captures the setSession first argument into the sessionCapture mutable list.

  2. As setSession should have been called only once, retrieve the one and only Session 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)
    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 to false.

  • Numeric values are set to 0.

  • String values are set to empty "".

  • Other non-nullable non-primitive values are faked.

Tip

By using a data class, you can easily tweak your fakes according to your needs:

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) }
    }
}
  1. Resets the mocker before any test (which removes all mocked behaviour & logged calls), so that each test gets a "clean" mocker.

  2. Injects mocks and fakes.

  3. 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, and verifyWithSuspend without mocker..

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)
    }
}
  1. The class inherits TestsWithMocks, which provides helpers.

  2. setUpMocks must be overriden, and can generally be just a delegation to the injectMocks generated function.

  3. Controller will be (re)created before each tests with the new mock dependencies.

  4. Note the absence of mocker. as you can use every and verify 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 @BeforeTest methods. You can override initMocksBeforeTest if you need to initialize your mocks before each test:

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...
}
  1. The class implements the ITestsWithMocks interface, which provides all helper methods.

  2. 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".

build.gradle.kts
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)
}
  1. Applying the MocKMP plugin.

  2. Requesting the optional test-helper dependency

  3. Must be called after the kotlin configuration and must be the last line of the mockmp 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.

build.gradle.kts
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):

build.gradle.kts
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)
    }
}
  1. Applying the KSP plugin.

  2. Adding the dependencies to the MocKMP runtime and the optional test helper.

  3. Use KSP generated JVM sources on all targets.

  4. Apply the processor only on the JVM target.

  5. 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:

build.gradle.kts
// When using the MocKMP plugin:
mockmp {
    public = true
}

// When using KSP directly:
ksp {
    arg("org.kodein.mock.visibility", "public")
}