mockito / mockito-kotlin

Using Mockito with Kotlin
MIT License
3.09k stars 198 forks source link

Kotlin - Unable to return value classes #456

Open kargath opened 2 years ago

kargath commented 2 years ago

When returning a value class from a mocked method, class cast can not be done properly

Sample code:

value class Base64Data(val data: String)
fun doSomething(data: Base64Data): Base64Data = Base64Data("test")

/**
 * Workaround to support matching of value classes
 *
 * From: https://github.com/mockito/mockito-kotlin/issues/445#issuecomment-983619131
 */
inline fun <Outer, reified Inner> eqValueClass(
    expected: Outer,
    crossinline access: (Outer) -> Inner,
    test: ((actual: Any) -> Boolean) -> Any? = ::argWhere
): Outer {
    val assertion: (Any) -> Boolean = { actual ->
        if (actual is Inner) {
            access(expected) == actual
        } else {
            expected == actual
        }
    }
    @Suppress("UNCHECKED_CAST")
    return test(assertion) as Outer? ?: expected
}

    @Test
    fun `test something`() = runTest {
        // GIVEN
        val screenshotEncoded = Base64Data("screenshot-encoded")
        whenever(
            client.doSomething(
               eqValueClass(levelData, { it.data })
            )
        ) doReturn screenshotEncoded

        // WHEN
        {...}
}

When executing that code and getting the mock the response is as follows:

java.lang.ClassCastException: class com.king.uplevelmanager.util.Base64Data cannot be cast to class java.lang.String (com.king.uplevelmanager.util.Base64Data is in unnamed module of loader 'app'; java.lang.String is in module java.base of loader 'bootstrap')

Tested returning a simple type like String (to discard matcher failing) and worked successfully.

kargath commented 2 years ago

Update: The issue seems to arise only when the mocked method is used within a runBlocking corroutine.

When returning the mock inside a "normal" piece of code, the mocking is done properly.

kargath commented 2 years ago

I'm adding a fully functional test:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.`should be equal to`
import org.junit.jupiter.api.Test
import org.mockito.kotlin.argWhere
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever

class SimpleTest2 {

    private val instance = MockSuspendClassImpl()
    private val mockedInstance: MockSuspendClass = mock()

    @Test
    fun `test with corroutines simple - no mock`() {
        // GIVEN
        val data = Data("something")
        val unit = UnderTest(instance)

        // WHEN
        val ret = unit.doSomethingPlain(data)

        // THEN
        ret `should be equal to` data.data
    }

    @Test
    fun `test with corroutines data - no mock`() {
        // GIVEN
        val data = Data("something")
        val unit = UnderTest(instance)

        // WHEN
        val ret = unit.doSomethingValue(data)

        // THEN
        ret `should be equal to` data
    }

    @Test
    fun `test with corroutines simple - mock`() = runTest {
        // GIVEN
        val data = Data("something")
        val unit = UnderTest(mockedInstance)
        whenever(mockedInstance.printData(eqValueClass(data, { it.data }))) doReturn data.data

        // WHEN
        val ret = unit.doSomethingPlain(data)

        // THEN
        ret `should be equal to` data.data
    }

    @Test
    fun `test with corroutines data - mock`() = runTest {
        // GIVEN
        val data = Data("something")
        val unit = UnderTest(mockedInstance)
        whenever(mockedInstance.printDataReturn(eqValueClass(data, { it.data }))) doReturn data

        // WHEN
        val ret = unit.doSomethingValue(data)

        // THEN
        ret `should be equal to` data
    }
}

class UnderTest(val mockedClass: MockSuspendClass) {
    fun doSomethingPlain(data: Data): String {
        return runBlocking(Dispatchers.Default) { mockedClass.printData(data) }
    }

    fun doSomethingValue(data: Data): Data {
        return runBlocking(Dispatchers.Default) { mockedClass.printDataReturn(data) }
    }
}

interface MockSuspendClass {
    suspend fun printData(data: Data): String
    suspend fun printDataReturn(data: Data): Data
}

class MockSuspendClassImpl : MockSuspendClass {
    override suspend fun printData(data: Data): String {
        println("Data is $data")
        delay(10)
        return data.data
    }

    override suspend fun printDataReturn(data: Data): Data {
        println("Data is $data")
        delay(10)
        return data
    }
}

@JvmInline
value class Data(val data: String)

/**
 * Workaround to support matching of value classes
 *
 * From: https://github.com/mockito/mockito-kotlin/issues/445#issuecomment-983619131
 */
inline fun <Outer, reified Inner> eqValueClass(
    expected: Outer,
    crossinline access: (Outer) -> Inner,
    test: ((actual: Any) -> Boolean) -> Any? = ::argWhere
): Outer {
    val assertion: (Any) -> Boolean = { actual ->
        if (actual is Inner) {
            access(expected) == actual
        } else {
            expected == actual
        }
    }
    @Suppress("UNCHECKED_CAST")
    return test(assertion) as Outer? ?: expected
}
fat-fellow commented 2 years ago

Related issue https://youtrack.jetbrains.com/issue/KT-51641

renannprado commented 8 months ago

does anybody have a workaround for this?

I'm trying to use any currently, not even eq.

BernatCarbo commented 5 months ago

Same issue here, it's really annoying.

foster commented 3 months ago

For anyone else who found their way here, I found a workaround to using eq() and any() for an inline value class in #309

Lingviston commented 20 hours ago

For me using the doSuspendableAnswer method with delay helps. So instead of doReturn(value) I use

doSuspendableReturn {
    delay(1)
    value
}
Lingviston commented 19 hours ago

Is this issue taken into work or it's root cause is known? It seems to be different from the one of https://github.com/mockito/mockito/pull/2280, as it is specific to the mocked method execution within a runBlocking coroutine.

I've also discovered that the behaviour somehow depends on the value class implementation. For instance, this test fails:


    @JvmInline
    value class Foo(
        val value: String,
    )

    interface ToBeMocked {

        suspend fun doSmth(): Foo
    }

    @Test
    fun test() = runTest {
        val expected = Foo("test")
        val mock = mock<ToBeMocked> {
            onBlocking { doSmth() } doReturn expected
        }

        val actual = mock.doSmth()

        assertThat(actual).isEqualTo(expected)
    }

While this one doesn't:


    @JvmInline
    value class Bar<out V, out E> internal constructor(
        private val inlineValue: Any?,
    )

    interface ToBeMocked {

        suspend fun doSmth(): Bar<String, Int>
    }

    @Test
    fun test() = runTest {
        val expected = Bar<String, Int>("test")
        val mock = mock<ToBeMocked> {
            onBlocking { doSmth() } doReturn expected
        }

        val actual = mock.doSmth()

        assertThat(actual).isEqualTo(expected)
    }

And the only difference is in value class impl.