diff --git a/README.adoc b/README.adoc index 5260806..d980785 100644 --- a/README.adoc +++ b/README.adoc @@ -56,6 +56,10 @@ NOTE: Every property annotated by `@Mock`, annotated by `@Fake` or delegated to == 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 <> chapter. + === Mocks CAUTION: Only *interfaces* can be mocked! @@ -107,6 +111,38 @@ 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: + +[source,kotlin] +---- +val everyApiGetUserById42 = mocker.every { api.getUserById(42) } +everyApiGetUserById42 returns fakeUser() +// Do things... +everyApiGetUserById42 returns null +// Do other things... +---- + + +==== Defining suspending behaviour + +You can define the behaviour of a suspending function with `everySuspending`: + +[source,kotlin] +---- +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). @@ -128,21 +164,26 @@ is strictly equivalent to: mocker.every { api.getUserById(isEqual(42)) } returns fakeUser() ---- -WARNING: You cannot mix constraints & non-constraint values. +[WARNING] +==== +You cannot mix constraints & non-constraint values. +This fails: -You can also keep the `Every` reference to change the behaviour over time: +[source,kotlin] +---- +mocker.every { api.registerCallback(42, isAny()) } returns Unit +---- + +...and needs to be replaced by: [source,kotlin] ---- -val everyApiGetUserById42 = mocker.every { api.getUserById(42) } -everyApiGetUserById42 returns fakeUser() -// Do things... -everyApiGetUserById42 returns null -// Do other things... +mocker.every { api.registerCallback(isEqual(42), isAny()) } returns Unit ---- +==== -==== Verification +==== Verifying You can check that mock functions has been run in order with `verify`. @@ -173,27 +214,23 @@ mocker.verify { } ---- -[WARNING] -==== -You cannot mix constraints & non-constraint values. -This fails: +WARNING: You cannot mix constraints & non-constraint values. + +If you want to verify the use of suspend functions, you can use `verifyWithSuspend`: [source,kotlin] ---- -mocker.verify { - api.registerCallback(42, isAny()) +mocker.verifyWithSuspend { + api.getUserById(isAny()) + db.saveUser(isNotNull()) } ---- -...and needs to be replaced by: +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. -[source,kotlin] ----- -mocker.verify { - api.registerCallback(isEqual(42), 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: @@ -232,32 +269,7 @@ mocker.verify(exhaustive = false, inOrder = false) { //<1> Other calls to mocks may have been made since exhaustiveness is not checked. -==== Functional types - -You can create mocks for functional type by using `mockFunctionX` where X is the number of arguments. - -[source,kotlin] ----- -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: - -[source,kotlin] ----- -val callback: (User) -> Unit = mockFunction1() {} // implicit Unit ----- - - -==== Accessing arguments - -===== Captures +==== 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. @@ -304,7 +316,7 @@ 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). -===== Run block +==== Accessing run block arguments You can access function parameters from a run block. This is less precise than using caputre lists as they are non typed, but allows to write very concise code: @@ -323,7 +335,30 @@ assertEquals(0, controller.runningSessions.size) ---- -==== Custom constraints +==== Mocking functional types + +You can create mocks for functional type by using `mockFunctionX` where X is the number of arguments. + +[source,kotlin] +---- +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: + +[source,kotlin] +---- +val callback: (User) -> Unit = mockFunction1() {} // implicit Unit +---- + + +==== Defining custom argument constraints You can define your own constraints: @@ -442,8 +477,8 @@ As soon as a class `T` contains a `@Mock` or `@Fake` annotated property, a `T.in IMPORTANT: Don't forget to `reset` the `Mocker` in a `@BeforeTest` method! - -=== Test class helper +[[test-helper]] +=== Using the test class helper MocKMP provides the `TestsWithMocks` helper class that your test classes can inherit from. It provides the following benefits: @@ -451,9 +486,12 @@ 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` and `verify` without `mocker.`. +- 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: -It does not come with the stndard runtime (as it forces the dependency to JUnit on the JVM), so you need to add the `mockmp-test-helper` dependency to use it. +* 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: @@ -487,14 +525,9 @@ NOTE: Properties delegated to `withMocks` will be (re)initialized *before each t == Setup -=== With KSP - -MocKMP is a Kotlin Symbol Processor, so you need to apply KSP to use it. - - -=== With the plugin +=== 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 (see <>). +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". @@ -539,15 +572,16 @@ The plugin takes care of: * Applying the KSP Gradle plugin * Declaring the MocKMP KSP dependency * Declaring the MocKMP runtime dependencies -* Applying the buggy multiplatform workaround +* Applying the incomplete multiplatform support workaround: +** Using Android if the Android plugin is applied +** Using the JVM otherwise -[[buggy-multiplatform]] -==== Buggy multiplatform +=== With KSP and its incomplete multiplatform support KSP for multiplatform is in beta, and *https://github.com/google/ksp/issues/567[KSP for common tests is not supported]* (yet). -To have IDEA completion, here's a trick that you can use: +To have IDEA completion, here's a trick that you can use (in fact, that's what the MocKMP plugin does): [source,kotlin,subs="verbatim,attributes"] .build.gradle.kts diff --git a/build.gradle.kts b/build.gradle.kts index 118df0d..e1cb2a4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,5 +7,5 @@ val kspVersion by extra { "1.6.10-1.0.2" } allprojects { group = "org.kodein.mock" - version = "1.1.0" + version = "1.2.0" } diff --git a/mockmp-processor/src/main/kotlin/org/kodein/mock/ksp/MocKMPProcessor.kt b/mockmp-processor/src/main/kotlin/org/kodein/mock/ksp/MocKMPProcessor.kt index d276cc7..afc2f13 100644 --- a/mockmp-processor/src/main/kotlin/org/kodein/mock/ksp/MocKMPProcessor.kt +++ b/mockmp-processor/src/main/kotlin/org/kodein/mock/ksp/MocKMPProcessor.kt @@ -213,7 +213,8 @@ public class MocKMPProcessor( } val paramsDescription = vFun.parameters.joinToString { (it.type.resolve().declaration as? KSClassDeclaration)?.qualifiedName?.asString() ?: "?" } val paramsCall = if (vFun.parameters.isEmpty()) "" else vFun.parameters.joinToString(prefix = ", ") { it.name!!.asString() } - gFun.addStatement("return this.%N.register(this, %S$paramsCall)", mocker, "${vFun.simpleName.asString()}($paramsDescription)") + val register = if (Modifier.SUSPEND in vFun.modifiers) "registerSuspend" else "register" + gFun.addStatement("return this.%N.$register(this, %S$paramsCall)", mocker, "${vFun.simpleName.asString()}($paramsDescription)") gCls.addFunction(gFun.build()) } gFile.addType(gCls.build()) diff --git a/mockmp-runtime/src/commonMain/kotlin/org/kodein/mock/Mocker.kt b/mockmp-runtime/src/commonMain/kotlin/org/kodein/mock/Mocker.kt index dc24ff7..94a13de 100644 --- a/mockmp-runtime/src/commonMain/kotlin/org/kodein/mock/Mocker.kt +++ b/mockmp-runtime/src/commonMain/kotlin/org/kodein/mock/Mocker.kt @@ -1,6 +1,8 @@ package org.kodein.mock +private typealias RegistrationMap = HashMap, MutableList>, E>>> + public class Mocker { public class MockingException(message: String) : Exception(message) @@ -13,9 +15,10 @@ public class Mocker { private var specialMode: SpecialMode? = null - internal class CallDefinition(val receiver: Any?, val method: String, val args: Array<*>) : RuntimeException("This exception should have been caught!") + internal class CallDefinition(val isSuspend: Boolean, val receiver: Any?, val method: String, val args: Array<*>) : RuntimeException("This exception should have been caught!") - internal val regs = HashMap, MutableList>, Every<*>>>>() + private val regFuns = RegistrationMap>() + private val regSuspendFuns = RegistrationMap>() @Suppress("ArrayInDataClass") private data class Call(val receiver: Any?, val method: String, val arguments: Array<*>, val returnValue: Any?) @@ -26,15 +29,21 @@ public class Mocker { public fun reset() { calls.clear() - regs.clear() + regFuns.clear() + regSuspendFuns.clear() } private fun methodName(receiver: Any?, methodName: String) = if (receiver == null) methodName else "${receiver::class.simpleName}.$methodName" - public fun register(receiver: Any?, method: String, vararg args: Any?): R { + private sealed class ProcessResult { + class Value(val value: R) : ProcessResult() + object FromRegistration : ProcessResult() + } + + private fun process(isSuspend: Boolean, receiver: Any?, method: String, args: Array<*>): ProcessResult { when (val mode = specialMode) { is SpecialMode.DEFINITION -> { - throw CallDefinition(receiver, method, args) + throw CallDefinition(isSuspend, receiver, method, args) } is SpecialMode.VERIFICATION -> { val constraints = mode.builder.getConstraints(args) @@ -72,34 +81,53 @@ public class Mocker { @Suppress("UNCHECKED_CAST") constraints.forEachIndexed { i, constraint -> (constraint.capture as MutableList?)?.add(call.arguments[i]) } @Suppress("UNCHECKED_CAST") - return call.returnValue as R + return ProcessResult.Value(call.returnValue as R) } null -> { - val list = regs[receiver to method] ?: throw MockingException("${methodName(receiver, method)} has not been mocked") - val (constraints, every) = list - .firstOrNull { (constraints, _) -> - constraints.size == args.size && constraints.indices.all { - @Suppress("UNCHECKED_CAST") - (constraints[it] as ArgConstraint).isValid(args[it]) - } - } - ?: throw MockingException( - "${methodName(receiver, method)} has not been mocked for arguments ${args.joinToString()}\n" + - " Registered mocked:\n" + list.map { it.first.joinToString { it.description } } .joinToString("\n") { " $it" } - ) @Suppress("UNCHECKED_CAST") - args.forEachIndexed { i, a -> (constraints[i].capture as? MutableList)?.add(a) } - val ret = every.mocked(args) + return ProcessResult.FromRegistration as ProcessResult + } + } + } + + private fun findEvery(regs: RegistrationMap, receiver: Any?, method: String, args: Array<*>): E { + val list = regs[receiver to method] ?: throw MockingException("${methodName(receiver, method)} has not been mocked") + val (constraints, every) = list + .firstOrNull { (constraints, _) -> + constraints.size == args.size && constraints.indices.all { + @Suppress("UNCHECKED_CAST") + (constraints[it] as ArgConstraint).isValid(args[it]) + } + } + ?: throw MockingException( + "${methodName(receiver, method)} has not been mocked for arguments ${args.joinToString()}\n" + + " Registered mocked:\n" + list.map { it.first.joinToString { it.description } } .joinToString("\n") { " $it" } + ) + @Suppress("UNCHECKED_CAST") + args.forEachIndexed { i, a -> (constraints[i].capture as? MutableList)?.add(a) } + return every + } + + private inline fun registerImpl(isSuspend: Boolean, regs: RegistrationMap, run: E.(Array<*>) -> Any?, receiver: Any?, method: String, args: Array<*>): R { + when (val result = process(isSuspend, receiver, method, args)) { + is ProcessResult.Value -> return result.value + is ProcessResult.FromRegistration -> { + val every = findEvery(regs, receiver, method, args) + val ret = every.run(args) calls.addLast(Call(receiver, method, args, ret)) @Suppress("UNCHECKED_CAST") return ret as R } } } - public inner class Every internal constructor(receiver: Any?, method: String) { + public fun register(receiver: Any?, method: String, vararg args: Any?): R = + registerImpl(false, regFuns, { mocked(it) }, receiver, method, args) - internal var mocked: (Array<*>) -> T = { throw MockingException("${methodName(receiver, method)} has not been mocked") } + public suspend fun registerSuspend(receiver: Any?, method: String, vararg args: Any?): R = + registerImpl(true, regSuspendFuns, { mocked(it) }, receiver, method, args) + public inner class Every internal constructor(receiver: Any?, method: String) { + internal var mocked: (Array<*>) -> T = { throw MockingException("${methodName(receiver, method)} has not been mocked") } public infix fun returns(ret: T) { mocked = { ret } } @@ -108,18 +136,29 @@ public class Mocker { } } + public inner class EverySuspend internal constructor(receiver: Any?, method: String) { + internal var mocked: suspend (Array<*>) -> T = { throw MockingException("${methodName(receiver, method)} has not been mocked") } + public infix fun returns(ret: T) { + mocked = { ret } + } + public infix fun runs(ret: suspend (Array<*>) -> T) { + mocked = ret + } + } + // This will be inlined twice: once for regular functions, and once for suspend functions. - private inline fun everyImpl(block: ArgConstraintsBuilder.() -> T): Every { + private inline fun everyImpl(isSuspend: Boolean, newEvery: (Any?, String) -> ET, map: RegistrationMap, block: ArgConstraintsBuilder.() -> T): ET { if (specialMode != null) error("Cannot be inside a definition block AND a verification block") specialMode = SpecialMode.DEFINITION val builder = ArgConstraintsBuilder() try { builder.block() error("Expected a Mock call") - } catch (ex: CallDefinition) { - val every = Every(ex.receiver, ex.method) - regs.getOrPut(ex.receiver to ex.method) { ArrayList() } - .add(builder.getConstraints(ex.args) to every) + } catch (call: CallDefinition) { + if (call.isSuspend != isSuspend) error("Calling a ${if (call.isSuspend) "suspend" else "non suspend"} function inside a ${if (isSuspend) "suspending" else "non suspending"} every block") + val every = newEvery(call.receiver, call.method) + map.getOrPut(call.receiver to call.method) { ArrayList() } + .add(builder.getConstraints(call.args) to every) return every } finally { specialMode = null @@ -127,10 +166,10 @@ public class Mocker { } public fun every(block: ArgConstraintsBuilder.() -> T) : Every = - everyImpl { block() } + everyImpl(false, ::Every, regFuns) { block() } - public suspend fun everySuspend(block: suspend ArgConstraintsBuilder.() -> T): Every = - everyImpl { block() } + public suspend fun everySuspending(block: suspend ArgConstraintsBuilder.() -> T): EverySuspend = + everyImpl(true, ::EverySuspend, regSuspendFuns) { block() } @Deprecated("Renamed every", ReplaceWith("every(block)"), level = DeprecationLevel.ERROR) public fun on(block: ArgConstraintsBuilder.() -> T) : Every = every(block) @@ -156,6 +195,6 @@ public class Mocker { public fun verify(exhaustive: Boolean = true, inOrder: Boolean = true, block: ArgConstraintsBuilder.() -> Unit): Unit = verifyImpl(exhaustive, inOrder) { block() } - public suspend fun verifySuspend(exhaustive: Boolean = true, inOrder: Boolean = true, block: suspend ArgConstraintsBuilder.() -> Unit): Unit = + public suspend fun verifyWithSuspend(exhaustive: Boolean = true, inOrder: Boolean = true, block: suspend ArgConstraintsBuilder.() -> Unit): Unit = verifyImpl(exhaustive, inOrder) { block() } } diff --git a/mockmp-test-helper/src/commonMain/kotlin/org/kodein/mock/tests/TestsWithMocks.kt b/mockmp-test-helper/src/commonMain/kotlin/org/kodein/mock/tests/TestsWithMocks.kt index 48b2ade..0e47b2b 100644 --- a/mockmp-test-helper/src/commonMain/kotlin/org/kodein/mock/tests/TestsWithMocks.kt +++ b/mockmp-test-helper/src/commonMain/kotlin/org/kodein/mock/tests/TestsWithMocks.kt @@ -52,11 +52,11 @@ public abstract class TestsWithMocks { protected fun every(block: ArgConstraintsBuilder.() -> T) : Mocker.Every = mocker.every(block) - protected suspend fun everySuspend(block: suspend ArgConstraintsBuilder.() -> T) : Mocker.Every = - mocker.everySuspend(block) + protected suspend fun everySuspending(block: suspend ArgConstraintsBuilder.() -> T) : Mocker.EverySuspend = + mocker.everySuspending(block) public fun verify(exhaustive: Boolean = true, inOrder: Boolean = true, block: ArgConstraintsBuilder.() -> Unit): Unit = mocker.verify(exhaustive = exhaustive, inOrder = inOrder, block) - public suspend fun verifySuspend(exhaustive: Boolean = true, inOrder: Boolean = true, block: suspend ArgConstraintsBuilder.() -> Unit): Unit = - mocker.verifySuspend(exhaustive = exhaustive, inOrder = inOrder, block) + public suspend fun verifyWithSuspend(exhaustive: Boolean = true, inOrder: Boolean = true, block: suspend ArgConstraintsBuilder.() -> Unit): Unit = + mocker.verifyWithSuspend(exhaustive = exhaustive, inOrder = inOrder, block) } diff --git a/tests/src/commonTest/kotlin/tests/VerificationTests.kt b/tests/src/commonTest/kotlin/tests/VerificationTests.kt index 44b7c0c..39f0479 100644 --- a/tests/src/commonTest/kotlin/tests/VerificationTests.kt +++ b/tests/src/commonTest/kotlin/tests/VerificationTests.kt @@ -175,10 +175,10 @@ class VerificationTests { @ExperimentalCoroutinesApi fun testSuspend() = runTest { val bar = MockBar(mocker) - mocker.everySuspend { bar.newData() } returns fakeData() + mocker.everySuspending { bar.newData() } returns fakeData() val data = bar.newData() assertEquals(fakeData(), data) - mocker.verifySuspend { bar.newData() } + mocker.verifyWithSuspend { bar.newData() } } @Test @@ -186,8 +186,27 @@ class VerificationTests { fun testSuspendFails() = runTest { val bar = MockBar(mocker) val ex = assertFailsWith { - mocker.verifySuspend { bar.newData() } + mocker.verifyWithSuspend { bar.newData() } } assertEquals("Expected a call to MockBar.newData() but call list was empty", ex.message) } + + @Test + @ExperimentalCoroutinesApi + fun testNonSuspendInSuspendingEvery() = runTest { + val foo = MockFoo(mocker) + val ex = assertFailsWith { + mocker.everySuspending { foo.defaultT } returns 42 + } + assertEquals("Calling a non suspend function inside a suspending every block", ex.message) + } + + @Test + @ExperimentalCoroutinesApi + fun testNonSuspendInSuspend() = runTest { + val foo = MockFoo(mocker) + mocker.every { foo.defaultT } returns 42 + assertEquals(42, foo.defaultT) + mocker.verifyWithSuspend { foo.defaultT } + } }