Android — How to test Paging 3 (PagingSource)?
Paging 3 is one of the best libraries for pagination — how not if it gives you all that you need to implement paging with the lowest efforts not only, but also gets your data from different data sources (API/local database)?
But it’s a so tricky one as well for some reasons:
- If you have a listing view without pagination, it’s not easy to migrate your old codebase to Paging 3 — some architectural modifications are needed!
- Which parts of the code will get affected? mmmm here’s a good question, for sure data layer won’t be, but
Repository
will change a little bit with having a new classPagingSource
- How to test the
PagingSource
?!!
Here I get reviews
from some Api
-> getReviews()
api.getReviews(
limit = params.loadSize,
offset = offset
)
Here’s an example for this Api
response ->
val reviewsResponse = ReviewsResponse(
reviews = listOf(
ReviewItemResponse(id = "id")
),
totalCount = 100, // total number of items.
pagination = PaginationResponse(limit = 10, offset = 0)
)
This class is the paging source used to deal with pagination using the paging 3 library:
class ReviewsPagingSource(private val api: Api) : PagingSource<Int, Review>() { override suspend fun load(params: LoadParams<Int>) = try {
// offset always starts with 0
val offset = params.key ?: 0
val response =
api.getReviews(
limit = params.loadSize,
offset = offset
)LoadResult.Page(
data = response.reviews.map { Review(it) },
prevKey =
if (response.pagination.offset - response.reviews.size <= 0) null
else response.pagination.offset - response.reviews.size,
nextKey =
if (response.pagination.offset + response.reviews.size >= response.totalCount) null
else response.pagination.offset + response.reviews.size
)
} catch (exception: Exception) {
LoadResult.Error(exception)
}
}
As you notice here:
- No need to have your own
Result
class for handling responses and helping in making a generic error handling approach as Paging 3 already handles this by havingLoadResult
sealed class
that takes care of this. - Pass
null
value tonextKey
if there’s no next page to be loaded, and toprevKey
if there’s no previous page. - So 3 interesting things here are tricky to be tested, Error Handling,
offset
and returned data.. and one thing to keep in mind,load
function is asuspend
one ;)
To call this source, I prefer to call it throw a repo instead of calling it directly from the view model, so I created this repo:
class ReviewsRepository(private val api: Api) {fun getReviews() = Pager(
config = PagingConfig(
pageSize = REVIEWS_PAGE_SIZE,
enablePlaceholders = false
),
pagingSourceFactory = {
ReviewsPagingSource(api)
}
).flowcompanion object {
private const val REVIEWS_PAGE_SIZE = 15
}}
So, how can you test this PagingSource
? The key here to identify expectedResult
is LoadResult
as mentioned above, and the key to fire the event is LoadParams
LoadParams
is a sealed class
that’s used for the loading request, it has 3 types:
1- Refresh
which’s used for the initial load or refresh.
2- Append
to load a page of data and append it to the end of the list.
3- Prepend
to load a page of data and prepend it to the start of the list.
Using this info, here’s an example of how can you test ReviewsPagingSource
:
- It starts with the class initialization, I stubbed the response here with a limit of only 1 item per load, just don’t need to use more for testing logic.
@ExperimentalCoroutinesApi
class ReviewsPagingSourceTest {@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()@get:Rule
var coroutineTestRule = CoroutineTestRule()@Mock lateinit var api: Apilateinit var reviewsPagingSource: ReviewsPagingSourcecompanion object { val reviewsResponse = ReviewsResponse(
reviews = listOf(
ReviewItemResponse(id = "id")
),
totalCount = 10,
pagination = PaginationResponse(limit = 1, offset = 0)
) val nextReviewsResponse = ReviewsResponse(
reviews = listOf(
ReviewItemResponse(id = "id")
),
totalCount = 10,
pagination = PaginationResponse(limit = 1, offset = 1)
)}@Before
fun setup() {
MockitoAnnotations.initMocks(this)
reviewsPagingSource = ReviewsPagingSource(api)
}
- Test failures/errors:
The expected result here should be of type Error
with the same exception thrown while calling the API
@Test
fun `reviews paging source load - failure - http error`() = runBlockingTest { val error = RuntimeException("404", Throwable())
given(api.getReviews(any(), any())).willThrow(error) val expectedResult = PagingSource.LoadResult.Error<Int, Review>(error) assertEquals(
expectedResult, reviewsPagingSource.load(
PagingSource.LoadParams.Refresh(
key = 0,
loadSize = 1,
placeholdersEnabled = false
)
)
)
}@Test
fun `reviews paging source load - failure - received null`() = runBlockingTest { given(api.getReviews(any(), any())).willReturn(null) val expectedResult = PagingSource.LoadResult.Error<Int, Review>(NullPointerException()) assertEquals(
expectedResult.toString(), reviewsPagingSource.load(
PagingSource.LoadParams.Refresh(
key = 0,
loadSize = 1,
placeholdersEnabled = false
)
).toString()
)
}
- Test refresh functionality:
The expected result should be the mapped reviews + nextKey
value should be 1 and prevKey
value should be null
as it’s just the first page is loaded
@Test
fun `reviews paging source refresh - success`() = runBlockingTest { given(api.getReviews(any(), any())).willReturn(reviewsResponse) val expectedResult = PagingSource.LoadResult.Page(
data = reviewsResponse.reviews.map { Review(it) },
prevKey = null,
nextKey = 1
) assertEquals(
expectedResult, reviewsPagingSource.load(
PagingSource.LoadParams.Refresh(
key = 0,
loadSize = 1,
placeholdersEnabled = false
)
)
)
}
- Test append functionality:
The expected result should be the mapped reviews + nextKey
value should be 2 and prevKey
value should be 1 as it’s the 2nd page is loaded
@Test
fun `reviews paging source append - success`() = runBlockingTest { given(api.getReviews(any(), any())).willReturn(nextReviewsResponse) val expectedResult = PagingSource.LoadResult.Page(
data = reviewsResponse.reviews.map { Review(it) },
prevKey = 0,
nextKey = 2
) assertEquals(
expectedResult, reviewsPagingSource.load(
PagingSource.LoadParams.Append(
key = 1,
loadSize = 1,
placeholdersEnabled = false
)
)
)
}
- Test prepend functionality:
The expected result should be the mapped reviews + nextKey
value should be 1 and prevKey
value should be null
as it’s just the first page is loaded again
@Test
fun `reviews paging source prepend - success`() = runBlockingTest { given(api.getReviews(any(), any())).willReturn(reviewsResponse) val expectedResult = PagingSource.LoadResult.Page(
data = reviewsResponse.reviews.map { Review(it) },
prevKey = null,
nextKey = 1
) assertEquals(
expectedResult, reviewsPagingSource.load(
PagingSource.LoadParams.Prepend(
key = 0,
loadSize = 1,
placeholdersEnabled = false
)
)
)
}
Enjoy analyzing the code, and for any questions don’t hesitate to ask me 🙌🏼
Credits: Thank you Wasim Reza for reviewing the article and suggesting some changes 🫡