4

My ViewModel has a method which returns a flow of PagingData. In my app, the data is fetched from the remote server, which is then saved to Room (the single source of truth):

fun getChocolates(): Flow<PagingData<Chocolate>> {
    val pagingSourceFactory = { dao().getChocolateListData() }
    return Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            maxSize = MAX_MEMORY_SIZE,
            enablePlaceholders = false
        ),
        remoteMediator = ChocolateRemoteMediator(
                api,
                dao
        ),
        pagingSourceFactory = pagingSourceFactory
    ).flow
}

How do I test this method? I want to test if the returning flow contains the correct data.

What I've tried so far:

@InternalCoroutinesApi
@Test
fun getChocolateListReturnsCorrectData() = runBlockingTest {
    val chocolateListDao: ChocolateListDao by inject()
    val chocolatesRepository: ChocolatesRepository by inject()
    val chocolateListAdapter: ChocolateListAdapter by inject()

    // 1
    val chocolate1 = Chocolate(
        name = "Dove"
    )
    val chocolate2 = Chocolate(
        name = "Hershey's"
    )

    // 2
    // You need to launch here because submitData suspends forever while PagingData is alive
    val job = launch {
        chocolatesRepository.getChocolateListStream().collectLatest {
            chocolateListAdapter.submitData(it)
        }
    }

    // Do some stuff to trigger loads
    chocolateListDao.saveChocolate(chocolate1, chocolate2)

    // How to read from adapter state, there is also .peek() and .itemCount
    assertEquals(listOf(chocolate1, chocolate2).toMutableList(), chocolateListAdapter.snapshot())

    // We need to cancel the launched job as coroutines.test framework checks for leaky jobs
    job.cancel()
}

I'm wondering if I'm on the right track. Any help would be greatly appreciated!

rabyunghwa
  • 178
  • 3
  • 24

2 Answers2

3

I found using Turbine from cashapp would be much much easier.(JakeWharton comes to rescue again :P)

testImplementation "app.cash.turbine:turbine:0.2.1"

According to your code I think your test case should looks like:

@ExperimentalTime
@ExperimentalCoroutinesApi
@Test
fun `test if receive paged chocolate data`() = runBlockingTest {

    val expected = listOf(
      Chocolate(name = "Dove"),
      Chocolate(name = "Hershey's")
    )

    coEvery {
        dao().getChocolateListData()
    }.returns(
        listOf(
            Chocolate(name = "Dove"),
            Chocolate(name = "Hershey's")
        )
    )

    launchTest {
        viewModel.getChocolates().test(
            timeout = Duration.ZERO,
            validate = {
                val collectedData = expectItem().collectData()
                assertEquals(expected, collectedData)
                expectComplete()
            })
    }
}

I also prepare a base ViewModelTest class for taking care of much of setup and tearDown tasks:

abstract class BaseViewModelTest {
    @get:Rule
    open val instantTaskExecutorRule = InstantTaskExecutorRule()

    @get:Rule
    open val testCoroutineRule = CoroutineTestRule()

    @MockK
    protected lateinit var owner: LifecycleOwner

    private lateinit var lifecycle: LifecycleRegistry

    @Before
    open fun setup() {
        MockKAnnotations.init(this)

        lifecycle = LifecycleRegistry(owner)
        every { owner.lifecycle } returns lifecycle
    }

    @After
    fun tearDown() {
        clearAllMocks()
    }

    protected fun initCoroutine(vm: BaseViewModel) {
        vm.apply {
            setViewModelScope(testCoroutineRule.testCoroutineScope)
            setCoroutineContext(testCoroutineRule.testCoroutineDispatcher)
        }
    }

    @ExperimentalCoroutinesApi
    protected fun runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) =
        testCoroutineRule.runBlockingTest(block)


    protected fun launchTest(block: suspend TestCoroutineScope.() -> Unit) =
        testCoroutineRule.testCoroutineScope.launch(testCoroutineRule.testCoroutineDispatcher) { block }

}

As for extension function collectData() that's borrowed from answer from another post (Thanks @Farid!!)

And a slide show introducing turbine

Robert
  • 1,660
  • 22
  • 39
1

There's basically two approaches to this depending on if you want pre-transformation or post-transformation data.

If you want to just assert the repository end, that your query is correct - you can just query PagingSource directly, this is pre-transform though so any mapping you do or filtering you do to PagingData in ViewModel won't be accounted for here. However, it's more "pure" if you want to test the query directly.

@Test
fun repo() = runBlockingTest {
  val pagingSource = MyPagingSource()
  val loadResult = pagingSource.load(...)
  assertEquals(
    expected = LoadResult.Page(...),
    actual = loadResult,
  )
}

The other way if you care about transforms, you need to load data from PagingData into a presenter API.

@Test
fun ui() = runBlockingTest {
  val viewModel = ... // Some AndroidX Test rules can help you here, but also some people choose to do it manually.
  val adapter = MyAdapter(..)

  // You need to launch here because submitData suspends forever while PagingData is alive
  val job = launch {
    viewModel.flow.collectLatest {
      adapter.submitData(it)
    }
  }

  ... // Do some stuff to trigger loads
  advanceUntilIdle() // Let test dispatcher resolve everything

  // How to read from adapter state, there is also .peek() and .itemCount
  assertEquals(..., adapter.snapshot())

  // We need to cancel the launched job as coroutines.test framework checks for leaky jobs
  job.cancel()
}
dlam
  • 3,547
  • 17
  • 20
  • Thanks for the answer. I already did the PagingSource query when I tested the Dao queries. Do I have to test it again in the repository layer? Again, I don't want to deal with the UI in the repository tests so I don't think I'll take the latter approach. – rabyunghwa Feb 02 '21 at 00:31
  • 1
    If you want to test the output of `Pager.flow` including any transformations, you need some way to assert the output of `PagingData`. The only way to do that since the entire event stream is internal, is to collect it into some presenter API. This doesn't necessarily mean UI, but you do need an instance of a `PagingDataAdapter` or `AsyncPagingDataDiffer`. Better test-utils replacing these is a WIP FR for future release of paging and will probably come as a separate module. – dlam Feb 04 '21 at 03:45
  • Again, thanks for the answer. I've updated my current progress. However, it seems that _adapter.snapshot()_ is empty. Can a Dao insert operation actually trigger the loads? My thought is it can as the paging library uses the *Flow* APIs under the hood. Also, where can I learn about this _WIP FR_? Thanks in advance. – rabyunghwa Feb 08 '21 at 01:38
  • Sorry I did not see your reply here - hope I'm not too late. You are using `runBlockingTest` and I don't see where `chocolateListAdapter` is getting provided from, but you need to await the actual load itself and let it complete before doing the assertion. e.g., passing `TestCoroutineDispatcher` to fetch / main dispatcher and calling `advanceUntilIdle()` – dlam May 19 '21 at 00:49
  • @dlam - How to validate transformations in repository layer? Do I need to use `PagingDataAdapter`? How to trigger loading in 2nd approach? Do you mean `adapter.refresh`? If yes, then how about subsequent loading? – wangadu Sep 08 '21 at 16:01
  • You can trigger page loads by emulating scroll distance using `adapter.get(index)`. The easiest way right now is to use `AsyncPagingDataDiffer` or `PagingDataAdapter` to collect the presenter state and then `.snapshot()` to get the data. In the future we hope to create a more test-focused version of these presenter APIs. – dlam Sep 09 '21 at 02:10
  • Is there an ETA on the test-utl? @dlam – saiedmomen Apr 10 '22 at 20:24