Paging3: Testing

Paging3: Testing

Now that you've implemented Paging3, it's essential to pair it with a robust testing strategy. This involves testing data loading components (PagingSource) to ensure they work as expected, as well as testing the pager flow passed to the UI to verify it passes the correct states to the UI as expected.

Luckily, Paging3 has an artifact called paging-testing to facilitate this process.
This artifact was released in 3.2.0-alpha03. You can find more information at this link here.

To use it, add this to your module's build.gradle.kts file (which has already been done):

plugins {
    ...
}

android {
    ...
}

dependencies {
    ...
    testImplementation(libs.paging.test)
    // OR
    testImplementation("androidx.paging:paging-testing:3.2.1")
    ...
}

We'll start off with the PagingSource and finish it off with the ViewModel.

PagingSource

Before writing test cases for the paging source, we need to set up our test objects.
We'll be using MockK to mock our api and then instantiate our paging source like so:

class CharacterPagingSourceTest {
    private val api: CharacterApi = mockk()
    private val source = CharacterPagingSource(api)
}

There are 2 main cases which we'd like to cover here:

  1. Getting the refresh key2.
  2. Loading pages

Getting the refresh key

Getting the refresh key depends on 2 things:

  • A valid anchor position which would mean that the previously invalidated PagingSource loaded successfully at some point
  • The data loaded previously was not empty

That lead us to the 3 test cases below:

class CharacterPagingSourceTest {
    ...
    @Test
    fun `given state has no anchor position, refresh key returns null`() {
        val state = PagingState<String, Character>(emptyList(), null, PagingConfig(0), 0)
        assertNull(source.getRefreshKey(state))
    }
    @Test
    fun `given state has anchor position but no pages, refresh key returns null`() {
        val state = PagingState<String, Character>(emptyList(), 1, PagingConfig(0), 0)
        assertNull(source.getRefreshKey(state))
    }
    @Test
    fun `given state has anchor position and pages, refresh key returns value`() {
        val page = PagingSource.LoadResult.Page(listOf(character), "prevKey", null)
        val state = PagingState(listOf(page), 1, PagingConfig(0), 0)
        assertEquals("prevKey", source.getRefreshKey(state))
    }
    ...
}

Loading pages

When loading pages, there are 2 main cases we need to keep track of:

  • Initial page load
  • Non-initial page load

Both of these have 2 more cases which we'd like to test for our specific implementation:

  • Success
  • Failure

Most of this is pretty straightforward so take a look at the following test cases:

class CharacterPagingSourceTest {
    ...
    @Test
    fun `given initial load, default link is used and returns successfully`() {
        runTest {
            coEvery { api.getAllCharacters(DEFAULT_URL) } returns characterResponse
            val expected = PagingSource.LoadResult.Page(
                data = listOf(character),
                nextKey = null,
                prevKey = null
            )
            assertEquals(expected, source.load(getLoadParam()))
        }
    }
    @Test
    fun `given initial load, default link is used and throws exception`() {
        runTest {
            val exception = RuntimeException()
            coEvery { api.getAllCharacters(DEFAULT_URL) } throws exception
            val expected = PagingSource.LoadResult.Error<String, Character>(exception)
            assertEquals(expected, source.load(getLoadParam()))
        }
    }
    @Test
    fun `given non-initial load, default link is used and returns successfully`() {
        runTest {
            coEvery { api.getAllCharacters("next-link") } returns characterResponse
            val expected = PagingSource.LoadResult.Page(
                data = listOf(character),
                nextKey = null,
                prevKey = null
            )
            assertEquals(expected, source.load(getLoadParam("next-link")))
        }
    }
    @Test
    fun `given non-initial load, default link is used and throws exception`() {
        runTest {
            val exception = RuntimeException()
            coEvery { api.getAllCharacters("next-link") } throws exception
            val expected = PagingSource.LoadResult.Error<String, Character>(exception)
            assertEquals(expected, source.load(getLoadParam("next-link")))
        }
    }
    private fun getLoadParam(key: String? = null): PagingSource.LoadParams.Refresh<String> =
        PagingSource.LoadParams.Refresh<String>(
            key = key,
            loadSize = 10,
            placeholdersEnabled = false
        )
    private companion object {
        const val DEFAULT_URL = "https://rickandmortyapi.com/api/character"
        val character = Character(
            id = 1,
            name = "Dummy Character",
            status = Character.Status.ALIVE,
            species = "Human",
            type = "Dummy Type",
            gender = "Male",
            origin = Location("Dummy Origin", "https://dummyorigin.com"),
            location = Location("Dummy Location", "https://dummylocation.com"),
            image = "https://dummyimage.com",
            episode = listOf("https://dummyepisode.com/1", "https://dummyepisode.com/2"),
            url = "https://dummycharacter.com",
            created = "2021-01-01"
        )
        val characterResponse = CharacterResponse(
            info = Info(
                count = 1,
                pages = 1,
                next = null,
                prev = null,
            ),
            characters = listOf(character)
        )
    }
    ...
}

Final look

package dev.kadirkid.pagingtest.character.data

import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import dev.kadirkid.pagingtest.character.model.Character
import dev.kadirkid.pagingtest.character.model.CharacterResponse
import dev.kadirkid.pagingtest.character.model.Info
import dev.kadirkid.pagingtest.character.model.Location
import io.mockk.coEvery
import io.mockk.mockk
import kotlin.RuntimeException
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CharacterPagingSourceTest {
    private val api: CharacterApi = mockk()
    private val source = CharacterPagingSource(api)
    @Test
    fun `given state has no anchor position, refresh key returns null`() {
        val state = PagingState<String, Character>(emptyList(), null, PagingConfig(0), 0)
        assertNull(source.getRefreshKey(state))
    }
    @Test
    fun `given state has anchor position but no pages, refresh key returns null`() {
        val state = PagingState<String, Character>(emptyList(), 1, PagingConfig(0), 0)
        assertNull(source.getRefreshKey(state))
    }
    @Test
    fun `given state has anchor position and pages, refresh key returns value`() {
        val page = PagingSource.LoadResult.Page(listOf(character), "prevKey", null)
        val state = PagingState(listOf(page), 1, PagingConfig(0), 0)
        assertEquals("prevKey", source.getRefreshKey(state))
    }
    @Test
    fun `given initial load, default link is used and returns successfully`() {
        runTest {
            coEvery { api.getAllCharacters(DEFAULT_URL) } returns characterResponse
            val expected = PagingSource.LoadResult.Page(
                data = listOf(character),
                nextKey = null,
                prevKey = null
            )
            assertEquals(expected, source.load(getLoadParam()))
        }
    }
    @Test
    fun `given initial load, default link is used and throws exception`() {
        runTest {
            val exception = RuntimeException()
            coEvery { api.getAllCharacters(DEFAULT_URL) } throws exception
            val expected = PagingSource.LoadResult.Error<String, Character>(exception)
            assertEquals(expected, source.load(getLoadParam()))
        }
    }
    @Test
    fun `given non-initial load, default link is used and returns successfully`() {
        runTest {
            coEvery { api.getAllCharacters("next-link") } returns characterResponse
            val expected = PagingSource.LoadResult.Page(
                data = listOf(character),
                nextKey = null,
                prevKey = null
            )
            assertEquals(expected, source.load(getLoadParam("next-link")))
        }
    }
    @Test
    fun `given non-initial load, default link is used and throws exception`() {
        runTest {
            val exception = RuntimeException()
            coEvery { api.getAllCharacters("next-link") } throws exception
            val expected = PagingSource.LoadResult.Error<String, Character>(exception)
            assertEquals(expected, source.load(getLoadParam("next-link")))
        }
    }
    private fun getLoadParam(key: String? = null): PagingSource.LoadParams.Refresh<String> = PagingSource.LoadParams.Refresh<String>(
        key = key,
        loadSize = 10,
        placeholdersEnabled = false
    )
    private companion object {
        const val DEFAULT_URL = "https://rickandmortyapi.com/api/character"
        val character = Character(
            id = 1,
            name = "Dummy Character",
            status = Character.Status.ALIVE,
            species = "Human",
            type = "Dummy Type",
            gender = "Male",
            origin = Location("Dummy Origin", "https://dummyorigin.com"),
            location = Location("Dummy Location", "https://dummylocation.com"),
            image = "https://dummyimage.com",
            episode = listOf("https://dummyepisode.com/1", "https://dummyepisode.com/2"),
            url = "https://dummycharacter.com",
            created = "2021-01-01"
        )
        val characterResponse = CharacterResponse(
            info = Info(
                count = 1,
                pages = 1,
                next = null,
                prev = null,
            ),
            characters = listOf(character)
        )
    }
}

ViewModel

Testing the flow exposed from the Pager object in a ViewModel for Paging3 is important to ensure that the data loading and pagination functionality is working correctly, validating that the correct data is being loaded and that the pagination logic is functioning as expected. This is also where the paging-testing artifact from Paging3 becomes useful as it provides a way for us to collect the data emitted by the flow from the Pager object and validate it.

To start off, let's create the mocks for this test:

class MainViewModelTest {
    private val source: CharacterPagingSource = mockk(relaxUnitFun = true) {
        every { keyReuseSupported } returns false
    }
    private val sourceFactory: CharacterPagingSourceFactory = mockk {
        every { create() } returns source
    }
    private val viewModel = MainViewModel(sourceFactory)
    ...
}

There are 4 main cases which we'd like to cover for our specific use case:

  1. Successful single page load when there's no next key
  2. Successful multiple page load when there is a next key and data is insufficient according to the set page size
  3. Successful multiple page load when there is a next key and data is insufficient according to the set page size until there's no next page in one of the api calls
  4. Failure

So let's get started

  1. Successful single page load
@Test
fun successfulWithSingleLoadIfNoNextKey() {
    runTest {
        coEvery { source.load(any()) } returns PagingSource.LoadResult.Page(
            data = listOf(character),
            prevKey = null,
            nextKey = null
        )
        val result = viewModel.uiState.asSnapshot()
        assertEquals(listOf(character), result)
    }
}

Here we mock our paging source to return some data successfully but make sure that there's no next key so no subsequent page is loaded. viewModel.uiState.asSnapshot() allows us to collect the data from our flow easily and here is how the extension from paging-testing looks:

@VisibleForTesting
public suspend fun <Value : Any> Flow<PagingData<Value>>.asSnapshot(
    onError: LoadErrorHandler = LoadErrorHandler { THROW },
    loadOperations: suspend SnapshotLoader<Value>.() -> @JvmSuppressWildcards Unit = { }
): @JvmSuppressWildcards List<Value>

You could override the onError handler if you want some other behavior but the default works for us.

2. Successful multiple page load until data is sufficient

@Test
fun successfulWithMultipleLoadsIfDataIsInsufficient() {
    runTest {
        coEvery { source.load(any()) } returns PagingSource.LoadResult.Page(
            data = generateSequence { character }.take(3).toList(),
            prevKey = null,
            nextKey = "2"
        ) andThen PagingSource.LoadResult.Page(
            data = generateSequence { character }.take(4).toList(),
            prevKey = "2",
            nextKey = "3"
        ) andThen PagingSource.LoadResult.Page(
            data = generateSequence { character }.take(3).toList(),
            prevKey = "3",
            nextKey = "4"
        ) andThen PagingSource.LoadResult.Page(
            data = listOf(character),
            prevKey = "4",
            nextKey = "5"
        ) andThen PagingSource.LoadResult.Page(
            data = listOf(character),
            prevKey = "5",
            nextKey = "6"
        ) andThen PagingSource.LoadResult.Page(
            data = listOf(character),
            prevKey = "6",
            nextKey = "7"
        ) andThen PagingSource.LoadResult.Page(
            data = listOf(character),
            prevKey = "7",
            nextKey = "8"
        )
        val result = viewModel.uiState.asSnapshot()
        val expected = generateSequence { character }.take(12).toList()
        assertEquals(expected, result)
        coVerify(exactly = 5) { source.load(any()) }
    }
}

Given that we set our page size to 10, Pager will keep fetching data from our source until it matches or exceeds the predefined page size. Here we mock the source to return data multiple times, each with a different number. You'll notice
that the source has been mocked 6 times but once it's called 5 times, we've exceeded the page size so the Pager no longer attempts to fetch new data.

3. Successful multiple page load until next key is not present

@Test
fun successfulWithMultipleLoadsUntilNextKeyIsNull() {
    runTest {
        coEvery { source.load(any()) } returns PagingSource.LoadResult.Page(
            data = generateSequence { character }.take(3).toList(),
            prevKey = null,
            nextKey = "2"
        ) andThen PagingSource.LoadResult.Page(
            data = generateSequence { character }.take(4).toList(),
            prevKey = "2",
            nextKey = "3"
        ) andThen PagingSource.LoadResult.Page(
            data = listOf(character, character),
            prevKey = "3",
            nextKey = null
        ) andThen PagingSource.LoadResult.Page(
            data = listOf(character), // NOT CALLED
            prevKey = "4",
            nextKey = "5"
        )
        val result = viewModel.uiState.asSnapshot()
        val expected = generateSequence { character }.take(9).toList()
        assertEquals(expected, result)
        coVerify(exactly = 3) { source.load(any()) }
    }
}

This follows a similar approach to the previous test case but on the 3rd call to the source, it returns a Page with no next key, hence the Pager no longer attempts to fetch any new data even though the data is insufficient according to the previously defined page size.

4. Failure

@Test
fun failure() {
    runTest {
        coEvery { source.load(any()) } returns PagingSource.LoadResult.Error(RuntimeException("Tough luck"))
        try {
            viewModel.uiState.asSnapshot()
            fail("Should fail with RuntimeException")
        } catch (e: RuntimeException) {
            assertEquals("Tough luck", e.message)
        }
    }
}

This is straightforward as we expect it to fail with a runtime exception. That won't be the case in reality as we expect an error state in the UI part and if we really wanted, we could play with the error handler in asSnapshot() to achieve
whatever result we want but this works for us for now.

Conclusion

With that, we've covered our PagingSource and ViewModel with test cases to assure that our implementation works as
expected.