lupuuss / Mokkery

The mocking library for Kotlin Multiplatform, easy to use, boilerplate-free and compiler plugin driven.
https://mokkery.dev
Apache License 2.0
203 stars 8 forks source link

Provide a way to block the return from a mocked suspend function #20

Closed dalewking closed 3 months ago

dalewking commented 4 months ago

Sometimes you need to test a state that is present while a suspending call is being made. For example, loading is true while the suspending call is still being executed.

So you need a way to have a mocked suspend function not return immediately but only return when you tell it to. Here is the way i am handling it now, but it would be nice to have support for this built-in

I created a mutable map to record what calls are being blocked:

    val deferred = mutableMap<Any, CompletableDeferred<Unit>>()

    fun Any.whilePaused(block: () -> Unit) {
        try {
            deferred[this] = CompletableDeferred()
            block()
        }
        finally {
            deferred.remove(this)?.complete(Unit)
        }
    }

Then in any suspend mock set up that i need to support blocking I have to do this (In this case it is a fun interface being mocked):

    val fooBar =
        mock<FooBar> {
            everySuspend { invoke(any()) } calls {
                success(returnValue)
                    .also { deferred[this@mock]?.await() }
            }
        }

This then lets me do tests like this:

    fooBar.whilePaused {
        sut.doLoad()
        assertTrue(sut.loading)
    }

    assertFalse(sut.loading)
dalewking commented 4 months ago

Later realized I can use deferred[self]

I added these functions which handle the delaying for me, but would be nice to have it built in:

    infix fun <T> SuspendAnsweringScope<in Result<T>>.returnsSuccessBy(block: () -> T) {
        calls {
            try { success(block()) } finally { deferred[self]?.await() }
        }
    }

    infix fun <T> SuspendAnsweringScope<T>.callsDelayable(
        block: suspend SuspendCallDefinitionScope<T>.(CallArgs) -> T,
    ) {
        calls {
            try { block(this, it) } finally { deferred[self]?.await() }
        }
    }
lupuuss commented 4 months ago

I'm considering implementing a set of coroutine-related utilities, and such a util might be a part of that set. However, it would require changing kotlinx.coroutines to an api dependency. Before that I need to consider how it should be done.

Currently, Mokkery depends internally on coroutines - as an implementation dependency. I see 3 possible options:

Anyway, until I have this figured, any part of public API that uses coroutines API is kind of blocked.

CXwudi commented 4 months ago

I personally prefer 3rd option, moving all coroutines related stuff to a separate optional library called maybe like mockkery-coroutine-extension that user can optionally implement. MVIKotlin is one example library that does this. The core mvikotlin module doesn't have any coroutine related stuff, but the mvikotlin-extensions-coroutines does. Alternativelly, one can use mvikotlin-extensions-reaktive if coroutine is not the favourite

lupuuss commented 4 months ago

It's blocked by #7. Currently, wasm wasi support without coroutines causes some complications in the coroutines setup. With coroutines 1.9.0 release it will be simpler and then I can think about extracting coroutines module.

lupuuss commented 4 months ago

@dalewking my API proposal for your use case:

val deferred = CompletableDeferred<Int>()
everySuspend { mock.bar() } awaits deferred

launch { sut.load() }

assertTrue(sut.isLoading)
deferred.complete(1)
assertFalse(sut.isLoading)

What do you think?

dalewking commented 4 months ago

My use case is slightly different. I know you have the idea that setting up mocks is done within the test classes. We have over 50 Use Case interfaces. Setting them up in individual tests is totally impractical.

So we have a MockUseCases class that sets up mock versions of all of them and creates a dependency injection module to bind all the mock implementations. A test class can subclass this and get mock implementations of all of the use cases

The class sets up var properties to define all the default implementations. If a test needs to change the behavior it can change the value of the property to be returned. They don't usually make calls to mokkery unless they need to throw an exception.

The problem with this proposal is that it doesn't work well with this predefined nature of the mocks. What you propose would require redefining the behavior of the mock within the test in order to add the deferred behavior.

It could work if you:

lupuuss commented 3 months ago

I've implemented the initial version of coroutine utils module. It contains a everySuspend { ... } awaits { deferred } that fits your use case. It will be released in Mokkery 2.2.0.

dalewking commented 3 months ago

Sorry to report that the new functionality does not fit my use case at all unless I go through the step of redefining the mocking setup all over again like I mentioned in my last comment. Not too much of a problem for a simple use case that returns a simple value but for complex cases it is not practical and causes duplication

lupuuss commented 3 months ago

The fun <T> SuspendAnsweringScope<T>.awaits(provider: SuspendCallDefinitionScope<T>.(CallArgs) -> Deferred<T>) is supposed to satisfy:

  • allow a lambda to get the deferred at the time of the call
  • the lambda would need a parameter of the mock
  • support the deferred being optional
    • this could be null on which you do nothing
    • if not we can just return an already completed deferred

Could you explain why it's not sufficient?

dalewking commented 3 months ago

You did not really satisfy the part about the deferred being optional.

As I have said I have a base class that sets up default mocking behavior for over 50 mock use cases. So in that base class i might have something like:

val someUseCase = mock<SomeUseCaseInterface> {
    everySuspend { invoke(any()) } returnsSuccessBy {
        // Here is 30 lines of code that computes the 
        // return value based on the input parameters and other variables
    }
}

And say I have one place where all I need to do is prevent the mock from returning immediately so i can check a condition that should be true while the suspending call has started but not finished.

The way you have it the only way to really do that is to completely repeat the setup of the mock in that test. So in that test I would have to do something along the lines of:

    val deferred = CompletableDeferred<Result<SomeType>>()
    everySuspend { someUseCase(any()) } awaits deferred

    // check condition

    deferred.complete(
        success(
            // Here is 30 lines of code that computes the 
            // return value based on the input parameters and other variables
        )
    }

I already have defined the value I want returned. I do not want to re-define that all over again. I want to tell it to delay the return until I tell it to do the return

lupuuss commented 3 months ago

Optional Deferred is problematic as then it's not clear what method should return. Also, one of your previous comment stated that if not we can just return an already completed deferred and that is possible. However, by the last comment I don't think it's enough. If I understand you correctly, the potential answer API must have at least 2 parameters (probably lambdas with access to mocked method context). One responsible for optional suspension (that defaults to no suspension) and the other responsible for return value. I don't really have a good idea that satisfies this requirement, reduces the boilerplate and also it's not easily replaceable by calls.

dalewking commented 3 months ago

I think with either the callsCatching or the callback mechanism i described in #34 i can get to where I need to be. Where I am right now is:

    infix fun <T> SuspendAnsweringScope<Result<T>>.callsCatching(
        block: suspend (CallArgs) -> T,
    ) {
        calls {
            try {
                success(block(it))
            } catch (t: Throwable) {
                failure(t)
            } finally { await(self) }
        }
    }

where the await function optionally blocks based on other code. With calls catching I can simplify.

I wanted to stress that what you did add for blocking is valuable, just not for the way I was using blocking. It works fine for simple cases.

CXwudi commented 3 months ago

@dalewking PR welcome