https://kotlinlang.org logo
Title
m

masteramyx

04/10/2023, 10:54 PM
Hey, I'm trying to test that a suspending function is invoked N times. Every 30 seconds, this coroutine should invoke the same suspending function
ViewModel

// Make request every 30sec
fun startRepeatingCall(){
    viewModelScope.launch(<http://Dispatchers.IO|Dispatchers.IO>){
        while(true){
            fetchFromNetwork()
            delay(30000)
        }
    }
}

private suspend fun fetchFromNetwork(){
    runCatching { repository.fetchFromNetwork() }
    ////
}
class TestVmClass {
    @MockK
    private lateinit var repository
    private lateinit var viewModel
        private val scheduler = TestCoroutineScheduler()
    private val testDispatcher = StandardTestDispatcher(scheduler)

@Before
fun setup() {
    Dispatchers.setMain(testDispatcher)
}

@Test
fun `test repeating task`() = runTest(testDispatcher) {
   viewModel.startRepeatingCall()
   // tried different ways of advancing virtual clock
   coVerify(exactly = 3) { repository.fetchFromNetwork() }
}
My goal was to call the viewmodel function, advance time by 90 seconds and assert that it's been called 3 times. The method is only called once though, it seems like I'm unable to synchronize the test coroutine context with the one used in my viewmodel. I thought that by setting the test dispatcher, my
viewModelScope
would use this dispatcher and when I call
advanceTimeBy(x)
the "time" within that
viewModelScope.launch{}
block would respect that. Thus, the
delay
would reach it's end point and my
fetchFromNetwork()
would be called again.
p

Patrick Steiger

04/11/2023, 12:33 AM
I suggest to not use
viewModelScope
and instead you can (e.g) pass in a
CoroutineScope
that is a child of the scope created by
runTest
to your ViewModel constructor
ViewModel
base class takes in a vararg of
Closeable
, so you can pass CoroutineScope as a Closeable to it (so it auto closes the scope on
onCleared()
Or take in a dispatcher in constructor and do
(viewModelScope + dispatcher)
to run your ViewModel coroutines. I prefer the previous suggestion because it also sets the Job so structured concurrency with
runTest
job is respected
m

masteramyx

04/11/2023, 1:45 PM
I've never seen the 1st approach you're suggesting. One problem is that I'm using
AndroidViewModel
so i don't believe we have that constructor available. What I don't like about 2nd approach, although it works. Is that I have to expose the new
val scope = (viewModelScope + dispatcher)
because in my tests, I'm unable to cancel the coroutines running within that scope without access to it. For example
@Test
    fun `test polling every 30 seconds`() = runTest {
        coEvery { repository.fetchFromNetwork() } coAnswers {
            Response()
        }
        println("Virtual Clock Before: ${this.currentTime}")
        viewModel.startRepeatingTask()
        advanceTimeBy(90000)
        println("Virtual Clock After: ${this.currentTime}")
        viewModel.scope.cancel()
// I can't cancel coroutine running inside VM with `TestScope` or `TestDispatcher` but only with scope those coroutines were launched in
        coVerify(exactly = 3) { repository.fetchFromNetwork() }
    }
It makes sense that you could only cancel a coroutine from scope it was launched in. I guess I could also have one of these VM functions return a job which then I could have a reference to and cancel?
m

Matt Rea

04/11/2023, 5:43 PM
Patrick is correct, that's the best way to make your ViewModel coroutines testable. Note that the vararg Closeable in the ViewModel constructor is only available on Lifecycle version 2.5 and up https://developer.android.com/topic/libraries/architecture/viewmodel#clear-dependencies