Testing Android PagingSource
An easy guide to test PagingSource

Hi, I'm Abu Yousuf.
Software engineer, currently focusing on Android.
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
loadreturnsLoadResult.Pageas a successful API responseIf the remote server has a total 3 pages of data then
loadmethod can return all page data sequentially.In case of any error occurs,
loadmethod returnsLoadResult.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.
First, you have to mock
NewsServiceSecond, you have to define what should return when
getTopHeadlinesmethod 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


