Testing Android PagingSource

An easy guide to test PagingSource

In the previous article, I wrote about paging implementation. If you didn't check that you can check it. In this article, I will write about how to test PagingSource.

Why testing

Testing is very important in development. Test code makes your code base stable. With test code, refactoring is easy because after refactoring if all tests pass then you can ensure that you didn't create a new bug.

Testing PagingSource

My PagingSource implementation is like below.

class NewsPagingSource(private val newsService: NewsService) :  PagingSource<Int, NewsArticleUi>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, NewsArticleUi> {
        val requestPage = params.key ?: 1
        return try {
            val response = newsService.getTopHeadlines(page = requestPage)
            val body = response.body()
            if (response.isSuccessful && body != null) {
                val articleList = body.articles.map { it.toNewsArticleUi() }
                val previousLoadCount = 0.coerceAtLeast(requestPage - 1) * NewsRemotePagingSource.networkPageSize
                val remainingCount = body.total - (previousLoadCount + articleList.size)
                check(remainingCount >= 0) {
                    "remaining count shouldn't negative"
                }
                val nextPage = if (remainingCount == 0) {
                    null
                } else {
                    requestPage + 1
                }

                val prePage = if (requestPage == 1) null else requestPage -1
                LoadResult.Page(
                    data = articleList,
                    prevKey = prePage,
                    nextKey = nextPage
                )
            } else {
                LoadResult.Error(Exception("Response body Invalid"))
            }
        } catch (ex : HttpException) {
            LoadResult.Error(ex)
        } catch ( ex: IOException) {
            LoadResult.Error(ex)
        }

    }

    override fun getRefreshKey(state: PagingState<Int, NewsArticleUi>): Int {
        return 1
    }

    companion object {
        const val networkPageSize = 20
        const val initialLoad = 20
        const val prefetchDistance = 2
    }    
}

To test PagingSource we need to decide first what we want to test. In NewsPagingSource class, we have 2 method load and getRefreshKey . getRefreshKey always returns 1 , so I will focus on load the method for testing.

Test case

Depending on method input, output, and implementation we can consider the following case for test

  1. load returns LoadResult.Page as a successful API response

  2. If the remote server has a total 3 pages of data then load method can return all page data sequentially.

  3. In case of any error occurs, load method returns LoadResult.Error

Now NewsPagingSource has a dependency. It depends on NewsService. When load method is called newsService.getTopHeadlines returns Response.

We don't need to call newsService.getTopHeadlines on real newsService as we are only testing NewsPagingSource . So how can we avoid that?

Mocking for test

We can do that in 2 ways so that our real service is not called.

  • Mocking the method return

  • Using a fake implementation of the method

I will use mocking here. For the mocking library, I will use mockK . Let's see how can we mock getTopHeadlines method.

  1. First, you have to mock NewsService

  2. Second, you have to define what should return when getTopHeadlines method is called.

So mocking is easy. Let's see the code for mocking.

private val mockNewsService = mockk<NewsService>()
val mockNewsResponse = ... // create your mock response here

Here mockk<NewsService>() returns a mock instance of NewsService

coEvery { mockNewsService.getTopHeadlines(eq("us"), eq(1)) } returns mockNewsResponse

The above line means, every time mockNewsService.getTopHeadlines method is called mockNewsResponse will be returned.

As getTopHeadlines is a suspend method so I have to use coEvery instead of every

Check the official doc to learn about mockK

Let's implement case 1

Test successful response

For this case, we need to return a successful response and check return result is the same as expected

For mocking response, I used the below method

    private fun getMockOkResponse(total : Int, list: List<NewsArticle>) : Response<NewsResponse> {
        val mockNesResponse = NewsResponse(
            status = "ok",
            total = total,
            articles = list
        )
        return Response.success(mockNesResponse)
    }

Here getMockOkResponse took the total size and article list which I generated with dummy value.

Now, the test code is like below

    @Test
    fun `test item loaded with refresh`() = runTest {
        val mockArticleList = getArticleListForPage(1)
        val mockNewsResponse = getMockOkResponse(totalArticle, mockArticleList)

        coEvery { mockNewsService.getTopHeadlines(eq("us"), eq(1)) } returns mockNewsResponse
        val topHeadlinePagingSource = NewsPagingSource(mockNewsService)

        // input 
        val refreshLoadParams = PagingSource.LoadParams.Refresh<Int>(
            key = null,
            loadSize = pageLoadSize,
            placeholdersEnabled = false
        )

        val actualLoadResult = topHeadlinePagingSource.load(refreshLoadParams)
        val expectedLoadResultPage = PagingSource.LoadResult.Page(
            // here just mapping one data class to another data class
            data = mockArticleList.map { it.toNewsArticleUi() },
            prevKey = null,
            nextKey = 2
        )

        // checking result are expected
        assertTrue(actualLoadResult is PagingSource.LoadResult.Page)
        assertEquals(expectedLoadResultPage.prevKey, (actualLoadResult as PagingSource.LoadResult.Page).prevKey )
        assertEquals(expectedLoadResultPage.nextKey, actualLoadResult.nextKey )

        assertEquals(expectedLoadResultPage.data.size, actualLoadResult.data.size)
        (0 until  expectedLoadResultPage.data.size).forEach {
            assertEquals(expectedLoadResultPage.data[it], actualLoadResult.data[it])
        }
    }

Now let's implement test case 3 (don't worry case 2 is almost the same as case 1 so I am skipping this. You will get the final code at the bottom of this article)

Test error response

In this case load the method needs to return LoadResult.Error. load method handles 2 Exceptions HttpException and IOException . We need to mock the getTopHeadlines method so that it throws Exception .

Let's throw HttpException by mocking

coEvery { mockNewsService.getTopHeadlines(eq("us"), any()) }.throws(HttpException(getFailedMockResponse(511)))

throws is used to throw an Exception

Now here is the test implementation

    @Test
    fun `test load resul error with http exception` () = runTest {

        coEvery { mockNewsService.getTopHeadlines(eq("us"), any()) }.throws(HttpException(getFailedMockResponse(511)))
        val topHeadlinePagingSource = TopHeadlinePagingSource(mockNewsService)
        val refreshLoadParams = PagingSource.LoadParams.Refresh<Int>(
            key = null,
            loadSize = pageLoadSize,
            placeholdersEnabled = false
        )

        val loadResult = topHeadlinePagingSource.load(refreshLoadParams)
        assertTrue(loadResult is PagingSource.LoadResult.Error)
        assertTrue((loadResult as PagingSource.LoadResult.Error).throwable is HttpException)
    }

I only discussed here 2 cases one for success and one for error. You can try now to write more test cases so that your code coverage is at least 80%

Here you will find the complete TC code here

@OptIn(ExperimentalCoroutinesApi::class)
class NewsPagingSourceTest {

    private val newsArticleFactory = NewsArticleFactory()
    private val mockNewsService = mockk<NewsService>()
    private val pageLoadSize = 20
    private val totalPage = 3
    private val totalArticle = totalPage * pageLoadSize
    private val totalNewsArticleList = newsArticleFactory.getTestNewsArticleList(totalArticle)


    @Test
    fun `test item loaded with refresh`() = runTest {
        val mockArticleList = getArticleListForPage(1)
        val mockNewsResponse = getMockOkResponse(totalArticle, mockArticleList)

        coEvery { mockNewsService.getTopHeadlines(eq("us"), eq(1)) } returns mockNewsResponse
        val topHeadlinePagingSource = NewsPagingSource(mockNewsService)

        val refreshLoadParams = PagingSource.LoadParams.Refresh<Int>(
            key = null,
            loadSize = pageLoadSize,
            placeholdersEnabled = false
        )

        val actualLoadResult = topHeadlinePagingSource.load(refreshLoadParams)
        val expectedLoadResultPage = PagingSource.LoadResult.Page(
            data = mockArticleList.map { it.toNewsArticleUi() },
            prevKey = null,
            nextKey = 2
        )

        assertTrue(actualLoadResult is PagingSource.LoadResult.Page)
        assertEquals(expectedLoadResultPage.prevKey, (actualLoadResult as PagingSource.LoadResult.Page).prevKey )
        assertEquals(expectedLoadResultPage.nextKey, actualLoadResult.nextKey )

        assertEquals(expectedLoadResultPage.data.size, actualLoadResult.data.size)
        (0 until  expectedLoadResultPage.data.size).forEach {
            assertEquals(expectedLoadResultPage.data[it], actualLoadResult.data[it])
        }
    }

    @Test
    fun `test all item loaded`() = runTest {
        (1..totalPage).forEach { page ->
            val mockArticleList = getArticleListForPage(page)
            val mockNewsResponse = getMockOkResponse(totalArticle, mockArticleList)

            coEvery { mockNewsService.getTopHeadlines(eq("us"), eq(page)) } returns mockNewsResponse
            val topHeadlinePagingSource = TopHeadlinePagingSource(mockNewsService)

            val loadParams =
                if (page == 1)
                    PagingSource.LoadParams.Refresh(
                        key = page,
                        loadSize = pageLoadSize,
                        placeholdersEnabled = false
                    )
                else
                    PagingSource.LoadParams.Append(
                        key = page,
                        loadSize = pageLoadSize,
                        placeholdersEnabled = false
                    )

            val actualLoadResult = topHeadlinePagingSource.load(loadParams)
            val expectedLoadResultPage = PagingSource.LoadResult.Page(
                data = mockArticleList.map { it.toNewsArticleUi() },
                prevKey = if (page > 1) page -1 else null,
                nextKey = if (page < totalPage) page + 1 else null
            )
            println(expectedLoadResultPage)

            assertTrue(actualLoadResult is PagingSource.LoadResult.Page)
            assertEquals(expectedLoadResultPage.prevKey, (actualLoadResult as PagingSource.LoadResult.Page).prevKey )
            assertEquals(expectedLoadResultPage.nextKey, actualLoadResult.nextKey )

            assertEquals(expectedLoadResultPage.data.size, actualLoadResult.data.size)
            (0 until  expectedLoadResultPage.data.size).forEach {
                assertEquals(expectedLoadResultPage.data[it], actualLoadResult.data[it])
            }
        }
    }


    @Test
    fun `test load resul error with response is not successful` () = runTest {
        coEvery { mockNewsService.getTopHeadlines(eq("us"), any()) } returns getFailedMockResponse(404)
        val topHeadlinePagingSource = TopHeadlinePagingSource(mockNewsService)
        val refreshLoadParams = PagingSource.LoadParams.Refresh<Int>(
            key = null,
            loadSize = pageLoadSize,
            placeholdersEnabled = false
        )

        val loadResult = topHeadlinePagingSource.load(refreshLoadParams)
        assertTrue(loadResult is PagingSource.LoadResult.Error)
    }

    @Test
    fun `test load resul error with http exception` () = runTest {

        coEvery { mockNewsService.getTopHeadlines(eq("us"), any()) }.throws(HttpException(getFailedMockResponse(511)))
        val topHeadlinePagingSource = TopHeadlinePagingSource(mockNewsService)
        val refreshLoadParams = PagingSource.LoadParams.Refresh<Int>(
            key = null,
            loadSize = pageLoadSize,
            placeholdersEnabled = false
        )

        val loadResult = topHeadlinePagingSource.load(refreshLoadParams)
        assertTrue(loadResult is PagingSource.LoadResult.Error)
        assertTrue((loadResult as PagingSource.LoadResult.Error).throwable is HttpException)
    }


    private fun getMockOkResponse(total : Int, list: List<NewsArticle>) : Response<NewsResponse> {
        val mockNesResponse = NewsResponse(
            status = "ok",
            total = total,
            articles = list
        )
        return Response.success(mockNesResponse)
    }

    private fun getFailedMockResponse(code : Int) : Response<NewsResponse> {
        return Response.error(code, ResponseBody.create(null, "No data found"))
    }

    private fun getArticleListForPage(page : Int) : List<NewsArticle> {
        val start = ((page - 1).coerceAtLeast(0) * pageLoadSize)
        val totalSize = totalNewsArticleList.size
        if (start < totalNewsArticleList.size) {
            return totalNewsArticleList.subList(start, (start + pageLoadSize).coerceAtMost(totalSize) )
        }
        return listOf()
    }

}
class NewsArticleFactory {

    fun getTestNewsArticleList(size:Int) : List<NewsArticle> {
        return (1..size).map { getTestNewsArticle(
            it.toString()
        ) }
    }

    fun getTestNewsArticle(suffix: String) : NewsArticle {
        return NewsArticle(
            source = Source(
                id = "SourceId $suffix",
                name = "Source name: $suffix"
            ),
            author = "Author $suffix",
            title = "Author $suffix",
            description = "Description $suffix",
            url = null,
            imageUrl = null,
            publishedAt = "2023-09-06T18:37:08Z"
        )
    }
}

Thanks for reading. Happy coding