PagingData Assertions made possible!

In this post we are going to explore how to assert pages in the Jetpack Paging library

Vaios Tsitsonis

9 minute read

Motives

With the release of Jetpack Paging, loading RecyclerView’s data in batches has become pretty fun and easy. Still, part of our job is not only to implement cool things but to write tests to ensure feature sustainability. The question in using Jetpack Paging is how easy it is to unit test our functionality.

Houston, we have a problem!

According to the documentation and the latest Android programming trends, after implementing paging, you will end up with something like a ViewModel which contains a Flow<PagingData> field. Unit-testing a ViewModel with a Flow field is pretty easy with the help of the turbine. All you have to do is invoke the test function, and inside the trailing lambda assert that the expected values are emitted. For the assertion, we can use some provided functions such as awaitItem, awaitError, awaitComplete, and so on. For example, we could have something like that:

class AwesomeViewModel: ViewModel() {

    // Values can be emitted from Database, Preferences, BroadcastReceiver, etc.
    // Just let your imagination free here!
    val awesomeValues: Flow<Int> = // ...
}

class AwesomeViewModelTest {

    @Test
    fun `is the first emitted value equal to 1` = runBlockingTest {
        // Given
        val viewModel = AwesomeViewModel()
        
        // When
        // Somehow trigger new emission
        
        // Then
        viewModel.test { // Here is the turbine call! :)
            assertEquals(1, awaitItem())
        }
    }
}

Someone could think that unit-testing a ViewModel with a Flow<PagingData> field would be possible using the turbine. The problem is that PagingData is not a data class, so any equality check would fail. A naive solution could be to compare the fields of PagingData one by one. After checking the source of PagingData, we can see that it contains a Flow to emit changes on every page, a.k.a PageEvent. This Flow is marked as internal, so it could be used only by the classes of the paging library and not for our comparisons.

We need to mention here that using reflection to change the visibility modifier seems too much effort as we need to change visibility modifiers for other classes too, such as PageEvent. And even if we changed visibility modifiers in every needed class, we would generate a lot of boilerplate code in every test case to instantiate the required classes. The worst thing about this boilerplate code is that it would depend on the library’s implementation details (we only want to test our code, not the library). Long story short, the following code would not work:

class AwesomeViewModel: ViewModel() {

    // Data flows from Database, Network or both with the help of Jetpack Paging
    val awesomeValues: Flow<PagingData<Int>> = // ...
}

class AwesomeViewModelTest {

    @Test
    fun `does the first emitted value contains only 1` = runBlockingTest {
        // Given
        val viewModel = AwesomeViewModel()

        // When
        // Somehow trigger new  emission

        // Then
        viewModel.test {
            val expected = PagingData.from(1)
             // The next assertion would fail even if the emitted PagingData object
             // contained the expected value. The reason is that PagingData data is
             // nor a data class neither implemnets equals()
            assertEquals(expected, awaitItem())
        }
    }
}

Don’t be prone to panic

Fortunately, we have some possible ways to proceed:

  1. No test at all
  2. UI testing
  3. Somehow access the pages in unit tests and assert them as usual

Let’s discuss our options here:

No test at all

Sometimes there is a pretty valid argument to avoid testing in cases where the effort of testing a component is too much, but the value that we gain is too little. I can not think in what feature we could add paging, but this feature’s stability would not be critical. So the value of testing here is not negligible.

UI testing

Another approach we could adopt is to test paging in Android Tests and assert that the expected items are displayed in the corresponding RecyclerView. Running Android Tests is a slower process than running JUnit tests, and we do not want to change the responsibility of UI tests without reason. We want to be able to test ViewModels in unit tests. We can keep this as an option, but we want to explore how we could test PagingData emissions in unit tests first.

Somehow access the pages in unit tests and assert them as usual

First of all, we need to clarify here that we want to avoid instantiating any Activity, Fragment, RecyclerView in our unit tests (which is doable with the help of Robolectric). Using Robolectric recklessly could take a larger amount of time to run the entire test suite. With this precondition, we want to find a way to collect values emitted by the internal Flow of a PagingData without using any Android-specific classes.

After digging more into the source code of the paging library, we can see that the collection of the PagingData’s internal Flow is delegated from the PagingDataAdapter to the AsyncPagingDataDiffer. AsyncPagingDataDiffer does not extend any Android class or implement any Android interface, so maybe it could be used to start collecting values from PagingData in our unit tests. Let’s check if this is possible.

The ListUpdateCallback implementation

To instantiate AsyncPagingDataDiffer we need to inject some values. The first value is an implementation of the ListUpdateCallback. This callback informs the class that uses the AsyncPagingDataDiffer about changes in data (most of the time, the user class is a PagingDataAdapter). We need to notice that it does not inform what values have changed but in which items there were changes. Namely, it gives us the positions of the changes. So we can not use this callback to assert data, but we could use it as a trigger to know when a new page was inserted. The implementation would look like that:

private class SameActionListUpdateCallback(
    private val onChange: () -> Unit
) : ListUpdateCallback {

    override fun onInserted(position: Int, count: Int) {
        onChange()
    }

    override fun onRemoved(position: Int, count: Int) {
        onChange()
    }

    override fun onMoved(fromPosition: Int, toPosition: Int) {
        onChange()
    }

    override fun onChanged(position: Int, count: Int, payload: Any?) {
        onChange()
    }
}

The DiffUtil.ItemCallback implementation

The second value is an implementation of DiffUtil.ItemCallback. This callback is responsible for calculating differences between two items in our data set. We do not care to implement any sort of diffing, but to collect every item on each page change. So, for now, we are going to treat each item as different. This would be done with the following implementation:

private class NoDiffCallback<T> : DiffUtil.ItemCallback<T>() {
    override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = false
    override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = false
}

There are two more values that we could inject during instantiation. Both of them are coroutine Dispatchers. The AsyncPagingDataDiffer provides default values for them. Still, we could consider using a TestCoroutineDispatcher to be aligned with the best practices of testing coroutines.

Exposing data with AsyncPagingDataDiffer

The only problem with the path that we chose is that AsyncPagingDataDiffer does not expose the collected data. So we need to find a way to expose them. We can improvise here and use some of the operators that are provided for the PagingData. Some of the available operators are:

  • transform
  • filter
  • map
  • flatMap

A solution could be to use the map to start adding the values to a list. Whenever a page is about to be delivered in our UI, the implemented ListUpdateCallback will be triggered. Thus, we can consider the collected items as part of the same page, and we can save them while continuing to collect the next pages. This approach could block execution if something goes wrong. That’s why we need to wrap our code with a withTimeout block. This ensures that our code would not block execution. The amount of timeout could be pretty small as we expect this code to be executed almost instantly. With all our previous work, we can now create a toPagination function that collects the elements of PagingData:

suspend fun <T : Any> PagingData<T>.toPagination(): Pagination<T> {
    val pages: MutableList<List<T>> = mutableListOf()
    var currentPage: MutableList<T> = mutableListOf()

    val updateCallback = SameActionListUpdateCallback {
        pages.add(currentPage)
        currentPage = mutableListOf()
    }

    val differ = AsyncPagingDataDiffer<T>(
        diffCallback = NoDiffCallback(),
        updateCallback = updateCallback,
        mainDispatcher = Dispatchers.Main,
        workerDispatcher = Dispatchers.Default
    )

    try {
        withTimeout(5) {
            differ.submitData(this@toPagination.onEach { currentPage.add(it) })
        }
    } catch (e: TimeoutCancellationException) {
        // Ignore exception we just need it in order to stop
        // the underlying implementation blocking the main thread
    }

    // Pagination is just a data class that contains the pages
    // to avoid exposing List<List<T>> which is a bit ugly. We
    // could think of another way to expose pages in the future.
    return Pagination(pages = pages)
}

After creating the toPagination function, we can easily write unit tests about our paging implementation, and the following code would work!

class AwesomeViewModel: ViewModel() {

    // Values can be emitted from Database, Preferences, BroadcastReceiver, etc.
    // Just let your imagination free here!
    val awesomeValues: Flow<Int> = // ...
}
class AwesomeViewModelTest {
  
    @Test
    fun `is the first emitted value equal to 1` = runBlockingTest {
        // Given
        val viewModel = AwesomeViewModel()
        
        // When
        // Somehow trigger new  emission
        
        // Then
        viewModel.test { // Here is the turbine call! :)
            val expectedPage = listOf(1)
            val actualPage = awaitItem().toPagination().pageAt(0)
            Assert.assertEquals(expectedPage, actualPage)
        }
    }
}

Loading pages that are not prefetched

The toPagination function seems to work, but it has a limitation. It can deliver only the first page. This happens because the paging library prefetches a specific amount of items (the number can change by changing the value of prefetchDistance field in the used PagingConfig) and fetches the next page whenever we reach the end of our dataset. Somehow we need to mimic this behavior without using the Android UI classes. After diving more into the source code of AsyncPagingDataDiffer we see that a fetch happens every time the getItem function is called. With this information, we can keep the sum of retrieved items and trigger the getItem function whenever the ListUpdateCallback implementation signals that a new page was delivered. Then the toPagination function would look like that:

suspend fun <T : Any> PagingData<T>.toPagination(): Pagination<T> {
    val pages: MutableList<List<T>> = mutableListOf()
    var currentPage: MutableList<T> = mutableListOf()

    val currentPosition = MutableStateFlow(0)
    val updateCallback = SameActionListUpdateCallback {
        pages.add(currentPage)
        currentPosition.value = currentPosition.value + currentPage.size
        currentPage = mutableListOf()
    }

    val differ = AsyncPagingDataDiffer<T>(
        diffCallback = NoDiffCallback(),
        updateCallback = updateCallback,
        mainDispatcher = Dispatchers.Main,
        workerDispatcher = Dispatchers.Default
    )

    currentPosition.filter { it > 0 }
        .onEach { differ.getItem(it - 1) }
        .launchIn(TestCoroutineScope())

    try {
        withTimeout(5) {
            differ.submitData(this@toPagination.onEach { currentPage.add(it) })
        }
    } catch (e: TimeoutCancellationException) {
        // Ignore exception we just need it in order to stop
        // the underlying implementation blocking the main thread
    }

    // Pagination is just a data class that contains the pages
    // to avoid exposing List<List<T>> which is a bit ugly. We
    // could think of another way to expose pages in the future.
    return Pagination(pages = pages)
}

Final thoughts

After investigating many options, we managed to find a way to assert PagingData in unit tests without using Robolectric. The important thing about the proposed solution is that it does not rely on our streaming mechanism. In our examples, we used Flow as a streaming mechanism, but it could easily be something else like LiveData. Additionally, it is not bound in the view system we use, which means that the same function can be used even if we use Jetpack Compose to render our pages.

A sample app that uses the function mentioned above for testing can be found here.

comments powered by Disqus