Testing Android PagingSource
Table of contents
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
load
returnsLoadResult.Page
as a successful API responseIf the remote server has a total 3 pages of data then
load
method can return all page data sequentially.In case of any error occurs,
load
method 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
NewsService
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
Subscribe to my newsletter
Read articles from Abu Yousuf directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Abu Yousuf
Abu Yousuf
Hi, I'm Abu Yousuf. Software engineer, currently focusing on Android.