diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index dddb7e3d1..3667d432e 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - java-version: [ 11, 17, 19 ] # test LTS versions, and the newest + java-version: [ 11, 17, 18 ] # test LTS versions, and the newest kotlin-version: [ 1.5.31, 1.6.21, 1.7.20-RC ] kotlin-ir-enabled: [ true, false ] fail-fast: false # in case one JDK fails, we still want to see results from others diff --git a/README.md b/README.md index 952f42852..63d9a09ac 100644 --- a/README.md +++ b/README.md @@ -696,7 +696,7 @@ confirmVerified(obj) To double check that all calls were verified by `verify...` constructs, you can use `confirmVerified`: -``` +```kotlin confirmVerified(mock1, mock2) ``` @@ -706,7 +706,7 @@ It will throw an exception if there are some calls left without verification. Some calls can be excluded from this confirmation, check the next section for more details. -``` +```kotlin val car = mockk() every { car.drive(Direction.NORTH) } returns Outcome.OK @@ -727,7 +727,7 @@ confirmVerified(car) // makes sure all calls were covered with verification Because clean & maintainable test code requires zero unnecessary code, you can ensure that there is no unnecessary stubs. -``` +```kotlin checkUnnecessaryStub(mock1, mock2) ``` @@ -739,13 +739,13 @@ This can happen if you have declared some really unnecessary stubs or if the tes To exclude unimportant calls from being recorded, you can use `excludeRecords`: -``` +```kotlin excludeRecords { mock.operation(any(), 5) } ``` All matching calls will be excluded from recording. This may be useful if you are using exhaustive verification: `verifyAll`, `verifySequence` or `confirmVerified`. -``` +```kotlin val car = mockk() every { car.drive(Direction.NORTH) } returns Outcome.OK @@ -854,6 +854,25 @@ car.drive(Direction.NORTH) // returns OK coVerify { car.drive(Direction.NORTH) } ``` + +And to simulate a never returning `suspend` function, you can use `coJustAwait`: + +```kotlin +runTest { + val car = mockk() + + coJustAwait { car.drive(any()) } // car.drive(...) will never return + + val job = launch(UnconfinedTestDispatcher()) { + car.drive(Direction.NORTH) + } + + coVerify { car.drive(Direction.NORTH) } + + job.cancelAndJoin() // Don't forget to cancel the job +} +``` + ### Extension functions There are three types of extension function in Kotlin: @@ -923,7 +942,7 @@ mockkStatic(Obj::squareValue) If `@JvmName` is used, specify it as a class name. KHttp.kt: -``` +```kotlin @file:JvmName("KHttp") package khttp @@ -931,16 +950,16 @@ package khttp ``` Testing code: -``` +```kotlin mockkStatic("khttp.KHttp") ``` Sometimes you need to know a little bit more to mock an extension function. For example the extension function `File.endsWith()` has a totally unpredictable `classname`: ```kotlin - mockkStatic("kotlin.io.FilesKt__UtilsKt") - every { File("abc").endsWith(any()) } returns true - println(File("abc").endsWith("abc")) +mockkStatic("kotlin.io.FilesKt__UtilsKt") +every { File("abc").endsWith(any()) } returns true +println(File("abc").endsWith("abc")) ``` This is standard Kotlin behaviour that may be unpredictable. Use `Tools -> Kotlin -> Show Kotlin Bytecode` or check `.class` files in JAR archive to detect such names. @@ -950,37 +969,37 @@ Use `Tools -> Kotlin -> Show Kotlin Bytecode` or check `.class` files in JAR arc From version 1.9.1, more extended vararg handling is possible: ```kotlin - interface ClsWithManyMany { - fun manyMany(vararg x: Any): Int - } +interface ClsWithManyMany { + fun manyMany(vararg x: Any): Int +} - val obj = mockk() +val obj = mockk() - every { obj.manyMany(5, 6, *varargAll { it == 7 }) } returns 3 +every { obj.manyMany(5, 6, *varargAll { it == 7 }) } returns 3 - println(obj.manyMany(5, 6, 7)) // 3 - println(obj.manyMany(5, 6, 7, 7)) // 3 - println(obj.manyMany(5, 6, 7, 7, 7)) // 3 +println(obj.manyMany(5, 6, 7)) // 3 +println(obj.manyMany(5, 6, 7, 7)) // 3 +println(obj.manyMany(5, 6, 7, 7, 7)) // 3 - every { obj.manyMany(5, 6, *anyVararg(), 7) } returns 4 +every { obj.manyMany(5, 6, *anyVararg(), 7) } returns 4 - println(obj.manyMany(5, 6, 1, 7)) // 4 - println(obj.manyMany(5, 6, 2, 3, 7)) // 4 - println(obj.manyMany(5, 6, 4, 5, 6, 7)) // 4 +println(obj.manyMany(5, 6, 1, 7)) // 4 +println(obj.manyMany(5, 6, 2, 3, 7)) // 4 +println(obj.manyMany(5, 6, 4, 5, 6, 7)) // 4 - every { obj.manyMany(5, 6, *varargAny { nArgs > 5 }, 7) } returns 5 +every { obj.manyMany(5, 6, *varargAny { nArgs > 5 }, 7) } returns 5 - println(obj.manyMany(5, 6, 4, 5, 6, 7)) // 5 - println(obj.manyMany(5, 6, 4, 5, 6, 7, 7)) // 5 +println(obj.manyMany(5, 6, 4, 5, 6, 7)) // 5 +println(obj.manyMany(5, 6, 4, 5, 6, 7, 7)) // 5 - every { - obj.manyMany(5, 6, *varargAny { - if (position < 3) it == 3 else it == 4 - }, 7) - } returns 6 - - println(obj.manyMany(5, 6, 3, 4, 7)) // 6 - println(obj.manyMany(5, 6, 3, 4, 4, 7)) // 6 +every { + obj.manyMany(5, 6, *varargAny { + if (position < 3) it == 3 else it == 4 + }, 7) +} returns 6 + +println(obj.manyMany(5, 6, 3, 4, 7)) // 6 +println(obj.manyMany(5, 6, 3, 4, 4, 7)) // 6 ``` ### Private functions mocking / dynamic calls @@ -1092,10 +1111,10 @@ every { quit(1) } throws Exception("this is a test") A very simple way to create new matchers is by attaching a function to `MockKMatcherScope` or `MockKVerificationScope` and using the `match` function: -``` - fun MockKMatcherScope.seqEq(seq: Sequence) = match> { - it.toList() == seq.toList() - } +```kotlin +fun MockKMatcherScope.seqEq(seq: Sequence) = match> { + it.toList() == seq.toList() +} ``` It's also possible to create more advanced matchers by implementing the `Matcher` interface. @@ -1105,7 +1124,6 @@ It's also possible to create more advanced matchers by implementing the `Matcher Example of a custom matcher that compares list without order: ```kotlin - @Test fun test() { class MockCls { @@ -1164,7 +1182,6 @@ inline fun , E : Any> MockKMatcherScope.matchListWithoutOrde vararg items: E, refEq: Boolean = true ): T = match(ListWithoutOrderMatcher(listOf(*items), refEq)) - ``` ## Settings file @@ -1309,6 +1326,7 @@ An Answer can be followed up by one or more additional answers. |`answers answerObj`|specify that the matched call answers with an Answer object| |`answers { nothing }`|specify that the matched call answers null| |`just Runs`|specify that the matched call is returning Unit (returns null)| +|`just Awaits`|specify that the matched call never returns (available since v1.13.3)| |`propertyType Class`|specify the type of the backing field accessor| |`nullablePropertyType Class`|specify the type of the backing field accessor as a nullable type| @@ -1328,6 +1346,7 @@ So this is similar to the `returnsMany` semantics. |`andThenAnswer answerObj`|specify that the matched call answers with an Answer object| |`andThen { nothing }`|specify that the matched call answers null| |`andThenJust Runs`|specify that the matched call is returning Unit (available since v1.12.2)| +|`andThenJust Awaits`|specify that the matched call is never returning (available since v1.13.3)| ### Answer scope diff --git a/buildSrc/src/main/kotlin/buildsrc/config/Deps.kt b/buildSrc/src/main/kotlin/buildsrc/config/Deps.kt index 2023f26db..200aa2123 100644 --- a/buildSrc/src/main/kotlin/buildsrc/config/Deps.kt +++ b/buildSrc/src/main/kotlin/buildsrc/config/Deps.kt @@ -46,5 +46,6 @@ object Deps { const val kotlinCoroutinesBom = "org.jetbrains.kotlinx:kotlinx-coroutines-bom:${Versions.coroutines}" const val kotlinCoroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core" + const val kotlinCoroutinesTest = "org.jetbrains.kotlinx:kotlinx-coroutines-test" } } diff --git a/modules/mockk-dsl/api/mockk-dsl.api b/modules/mockk-dsl/api/mockk-dsl.api index cecd93fc8..e2bcb72a0 100644 --- a/modules/mockk-dsl/api/mockk-dsl.api +++ b/modules/mockk-dsl/api/mockk-dsl.api @@ -1,9 +1,11 @@ public final class io/mockk/APIKt { + public static final fun andThenJust (Lio/mockk/MockKAdditionalAnswerScope;Lio/mockk/Awaits;)Lio/mockk/MockKAdditionalAnswerScope; public static final fun andThenJust (Lio/mockk/MockKAdditionalAnswerScope;Lio/mockk/Runs;)Lio/mockk/MockKAdditionalAnswerScope; public static final fun checkEquals (Lio/mockk/MockKAssertScope;Ljava/lang/Object;)V public static final fun checkEquals (Lio/mockk/MockKAssertScope;Ljava/lang/String;Ljava/lang/Object;)V public static final fun internalSubstitute (Ljava/lang/Object;Ljava/util/Map;)Ljava/lang/Object; public static final fun internalSubstitute (Ljava/util/List;Ljava/util/Map;)Ljava/util/List; + public static final fun just (Lio/mockk/MockKStubScope;Lio/mockk/Awaits;)Lio/mockk/MockKAdditionalAnswerScope; public static final fun just (Lio/mockk/MockKStubScope;Lio/mockk/Runs;)Lio/mockk/MockKAdditionalAnswerScope; public static final fun use (Lio/mockk/Deregisterable;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public static final fun use (Lio/mockk/MockKUnmockKScope;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; @@ -59,6 +61,10 @@ public final class io/mockk/ArrayMatcher : io/mockk/CapturingMatcher, io/mockk/M public fun toString ()Ljava/lang/String; } +public final class io/mockk/Awaits { + public static final field INSTANCE Lio/mockk/Awaits; +} + public final class io/mockk/BackingFieldValue { public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V public final fun getGetter ()Lkotlin/jvm/functions/Function0; diff --git a/modules/mockk-dsl/src/commonMain/kotlin/io/mockk/API.kt b/modules/mockk-dsl/src/commonMain/kotlin/io/mockk/API.kt index 0378e1a0e..c467cd206 100644 --- a/modules/mockk-dsl/src/commonMain/kotlin/io/mockk/API.kt +++ b/modules/mockk-dsl/src/commonMain/kotlin/io/mockk/API.kt @@ -5,6 +5,7 @@ package io.mockk import io.mockk.InternalPlatformDsl.toStr import io.mockk.MockKGateway.* import io.mockk.core.ValueClassSupport.boxedClass +import kotlinx.coroutines.awaitCancellation import kotlin.coroutines.Continuation import kotlin.reflect.KClass @@ -2096,6 +2097,12 @@ private fun formatAssertMessage(actual: Any?, expected: Any?, message: String? = object Runs typealias runs = Runs +/** + * Part of DSL. Object to represent phrase "just Awaits" + */ +object Awaits +typealias awaits = Awaits + /** * Stub scope. Part of DSL * @@ -2146,6 +2153,12 @@ class MockKStubScope( @Suppress("UNUSED_PARAMETER") infix fun MockKStubScope.just(runs: Runs) = answers(ConstantAnswer(Unit)) +/** + * Part of DSL. Answer placeholder for never returning suspend functions. + */ +@Suppress("UNUSED_PARAMETER") +infix fun MockKStubScope.just(awaits: Awaits) = coAnswers { awaitCancellation() } + /** * Scope to chain additional answers to reply. Part of DSL */ @@ -2184,6 +2197,11 @@ class MockKAdditionalAnswerScope( @Suppress("UNUSED_PARAMETER") infix fun MockKAdditionalAnswerScope.andThenJust(runs: Runs) = andThenAnswer(ConstantAnswer(Unit)) +/** + * Part of DSL. Answer placeholder for never returning functions. + */ +@Suppress("UNUSED_PARAMETER") +infix fun MockKAdditionalAnswerScope.andThenJust(awaits: Awaits) = coAndThen { awaitCancellation() } internal fun List.allConst() = this.map { ConstantAnswer(it) } diff --git a/modules/mockk/api/mockk.api b/modules/mockk/api/mockk.api index 068b1f553..2a644165f 100644 --- a/modules/mockk/api/mockk.api +++ b/modules/mockk/api/mockk.api @@ -32,6 +32,7 @@ public final class io/mockk/MockKKt { public static final fun coEvery (Lkotlin/jvm/functions/Function2;)Lio/mockk/MockKStubScope; public static final fun coExcludeRecords (ZLkotlin/jvm/functions/Function2;)V public static synthetic fun coExcludeRecords$default (ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static final fun coJustAwait (Lkotlin/jvm/functions/Function2;)Lio/mockk/MockKAdditionalAnswerScope; public static final fun coJustRun (Lkotlin/jvm/functions/Function2;)Lio/mockk/MockKAdditionalAnswerScope; public static final fun coVerify (Lio/mockk/Ordering;ZIIIJLkotlin/jvm/functions/Function2;)V public static synthetic fun coVerify$default (Lio/mockk/Ordering;ZIIIJLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V diff --git a/modules/mockk/build.gradle.kts b/modules/mockk/build.gradle.kts index 7c53e65ad..bc8eaf0c4 100644 --- a/modules/mockk/build.gradle.kts +++ b/modules/mockk/build.gradle.kts @@ -33,6 +33,7 @@ kotlin { val commonTest by getting { dependencies { implementation(kotlin("test-junit5")) + implementation(Deps.Libs.kotlinCoroutinesTest) } } val jvmMain by getting { diff --git a/modules/mockk/src/commonMain/kotlin/io/mockk/MockK.kt b/modules/mockk/src/commonMain/kotlin/io/mockk/MockK.kt index c94315735..6131b5af9 100644 --- a/modules/mockk/src/commonMain/kotlin/io/mockk/MockK.kt +++ b/modules/mockk/src/commonMain/kotlin/io/mockk/MockK.kt @@ -124,6 +124,14 @@ fun coEvery(stubBlock: suspend MockKMatcherScope.() -> T): MockKStubScope Unit) = coEvery(stubBlock) just Runs +/** + * Stub block to never return. Part of DSL. + * + * Used to define what behaviour is going to be mocked. + * @see [coJustRun] + */ +fun coJustAwait(stubBlock: suspend MockKMatcherScope.() -> Unit) = coEvery(stubBlock) just Awaits + /** * Verifies that calls were made in the past. Part of DSL * diff --git a/modules/mockk/src/commonTest/kotlin/io/mockk/it/CoJustAwaitTest.kt b/modules/mockk/src/commonTest/kotlin/io/mockk/it/CoJustAwaitTest.kt new file mode 100644 index 000000000..049c253e2 --- /dev/null +++ b/modules/mockk/src/commonTest/kotlin/io/mockk/it/CoJustAwaitTest.kt @@ -0,0 +1,79 @@ +package io.mockk.it + +import io.mockk.andThenJust +import io.mockk.awaits +import io.mockk.coEvery +import io.mockk.coJustAwait +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.coVerifySequence +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CoJustAwaitTest { + + private val mock = mockk() + + @Test + fun `coJustRun completes as expected`() = runTest { + coJustRun { mock.coOp() } + + val job = launch { mock.coOp() } + runCurrent() + + coVerify(exactly = 1) { mock.coOp() } + verify(exactly = 0) { mock.notImplemented() } + confirmVerified(mock) + assertTrue(job.isCompleted) + } + + @Test + fun `coJustAwait awaits until cancellation`() = runTest { + coJustAwait { mock.coOp() } + + val job = launch { mock.coOp() } + runCurrent() + + coVerify(exactly = 1) { mock.coOp() } + verify(exactly = 0) { mock.notImplemented() } + confirmVerified(mock) + assertTrue(job.isActive) + job.cancelAndJoin() + } + + @Test + fun `coJustAwait andThenJust answers and awaits until cancellation`() = runTest { + coEvery { mock.coOp(any()) } answers { 1 } andThenJust awaits + + val job = launch { + repeat(2) { mock.coOp(it) } + } + runCurrent() + + coVerifySequence { + mock.coOp(0) + mock.coOp(1) + } + verify(exactly = 0) { mock.notImplemented() } + confirmVerified(mock) + assertTrue(job.isActive) + job.cancelAndJoin() + } + + class MockCls { + @Suppress("RedundantSuspendModifier") + suspend fun coOp(a: Int = 1): Int = a + notImplemented() + fun notImplemented(): Int = TODO() + } + +} +