Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Coroutines unit tests pass individually but not when run together

I have two coroutines tests that both pass when run individually, but if I run them together the second one always fails (even if I switch them around!). The error I get is:

Wanted but not invoked: observer.onChanged([SomeObject(someValue=test2)]); Actually, there were zero interactions with this mock.

There's probably something fundamental I don't understand about coroutines (or testing in general) and doing something wrong.

If I debug the tests I find that the failing test is not waiting for the inner runBlocking to complete. Actually the reason I have the inner runBlocking in the first place is to solve this exact problem and it seemed to work for individual tests.

Any ideas as to why this might be happening?

Test class

@ExperimentalCoroutinesApi
@RunWith(MockitoJUnitRunner::class)
class ViewModelTest {

    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()
    private lateinit var mainThreadSurrogate: ExecutorCoroutineDispatcher

    @Mock
    lateinit var repository: DataSource
    @Mock
    lateinit var observer: Observer<List<SomeObject>>

    private lateinit var viewModel: SomeViewModel


    @Before
    fun setUp() {
        mainThreadSurrogate = newSingleThreadContext("UI thread")
        Dispatchers.setMain(mainThreadSurrogate)
        viewModel = SomeViewModel(repository)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
        mainThreadSurrogate.close()
    }

    @Test
    fun `loadObjects1 should get objects1`() = runBlocking {
        viewModel.someObjects1.observeForever(observer)
        val expectedResult = listOf(SomeObject("test1")) 
        `when`(repository.getSomeObjects1Async())
        .thenReturn(expectedResult)

        runBlocking {
            viewModel.loadSomeobjects1()
        }

        verify(observer).onChanged(listOf(SomeObject("test1")))
    }

    @Test
    fun `loadObjects2 should get objects2`() = runBlocking {
        viewModel.someObjects2.observeForever(observer)
        val expectedResult = listOf(SomeObject("test2"))
        `when`(repository.getSomeObjects2Async())
        .thenReturn(expectedResult)

        runBlocking {
            viewModel.loadSomeObjects2()
        }

        verify(observer).onChanged(listOf(SomeObject("test2")))
    }
}

ViewModel

class SomeViewModel constructor(private val repository: DataSource) : 
    ViewModel(), CoroutineScope {

    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main

    private var objects1Job: Job? = null
    private var objects2Job: Job? = null
    val someObjects1 = MutableLiveData<List<SomeObject>>()
    val someObjects2 = MutableLiveData<List<SomeObject>>()

    fun loadSomeObjects1() {
        objects1Job = launch {
            val objects1Result = repository.getSomeObjects1Async()
            objects1.value = objects1Result
        }
    }

    fun loadSomeObjects2() {
        objects2Job = launch {
            val objects2Result = repository.getSomeObjects2Async()
            objects2.value = objects2Result
        }
    }

    override fun onCleared() {
        super.onCleared()
        objects1Job?.cancel()
        objects2Job?.cancel()
    }
}

Repository

class Repository(private val remoteDataSource: DataSource) : DataSource {

    override suspend fun getSomeObjects1Async(): List<SomeObject> {
        return remoteDataSource.getSomeObjects1Async()
    }

    override suspend fun getSomeObjects2Async(): List<SomeObject> {
        return remoteDataSource.getSomeObjects2Async()
    }
}
like image 410
Michael Vescovo Avatar asked Feb 03 '23 19:02

Michael Vescovo


1 Answers

When you use launch, you're creating a coroutine which will execute asynchronously. Using runBlocking does nothing to affect that.

Your tests are failing because the stuff inside your launches will happen, but hasn't happened yet.

The simplest way to ensure that your launches have executed before doing any assertions is to call .join() on them.

fun someLaunch() : Job = launch {
  foo()
}

@Test
fun `test some launch`() = runBlocking {
  someLaunch().join()

  verify { foo() }
}

Instead of saving off individual Jobs in your ViewModel, in onCleared() you can implement your CoroutineScope like so:

class MyViewModel : ViewModel(), CoroutineScope {
  private val job = SupervisorJob()
  override val coroutineContext : CoroutineContext
    get() = job + Dispatchers.Main

  override fun onCleared() {
    super.onCleared()
    job.cancel()
  }
}

All launches which happen within a CoroutineScope become children of that CoroutineScope, so if you cancel that job (which is effectively cancelling the CoroutineScope), then you cancel all coroutines executing within that scope.

So, once you've cleaned up your CoroutineScope implementation, you can make your ViewModel functions just return Jobs:

fun loadSomeObjects1() = launch {
    val objects1Result = repository.getSomeObjects1Async()
    objects1.value = objects1Result
}

and now you can test them easily with a .join():

@Test
fun `loadObjects1 should get objects1`() = runBlocking {
    viewModel.someObjects1.observeForever(observer)
    val expectedResult = listOf(SomeObject("test1")) 
    `when`(repository.getSomeObjects1Async())
    .thenReturn(expectedResult)

    viewModel.loadSomeobjects1().join()

    verify(observer).onChanged(listOf(SomeObject("test1")))
}

I also noticed that you're using Dispatchers.Main for your ViewModel. This means that you will by default execute all coroutines on the main thread. You should think about whether that's really something that you want to do. After all, very few non-UI things in Android need to be done on the main thread, and your ViewModel shouldn't be manipulating the UI directly.

like image 123
RBusarow Avatar answered Feb 06 '23 15:02

RBusarow